Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,26 @@ jobs:
# statements never run.
go run github.com/go-fleettools/fleettools/cmd/covblocks@v0.27.0 cover.out

# The refbench inventory (docs/perf.md): the allocation count of every case
# equals docs/perf.json, docs/perf-table.md is what perf.json generates, and
# every public function has a case or an entry in docs/perf-exempt.md. No
# Python, no timing: timing runs by hand at a 1-minute load below 8,
# cd bench && GOMAXPROCS=1 go test -run TestPerf -cpu 1 -perf.run .
# GOMAXPROCS=1 is in the environment because go-fft/fft reads it once at init.
perf:
name: perf (refbench CI mode)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: 1.27.3
- name: Allocation counts, table, public-function inventory
working-directory: bench
env:
GOMAXPROCS: "1"
run: go test -count=1 -run TestPerf -cpu 1 .

# Native per-arch execution. amd64/arm64 have GitHub-hosted runners, so the
# generated assembly runs natively. Each regenerates the committed .s via
# go-asmgen and fails if it is stale, then vets/builds/runs the suite —
Expand Down
4 changes: 4 additions & 0 deletions BENCHMARKS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Performance parity — go-ndarray vs NumPy

> **Not a verdict.** This report runs go-ndarray multicore against NumPy 2.2.4
> and gonum; the one-core comparison with the pinned NumPy is
> [docs/perf-table.md](docs/perf-table.md) (method: [docs/perf.md](docs/perf.md)).

_Generated by `benchmarks/gen_report.sh` — every number is measured on this machine, none hand-edited._

## Methodology
Expand Down
32 changes: 11 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,34 +123,24 @@ and they are correct where amd64's `math.Exp` returns +Inf
is wrong on subnormals
([golang/go#56600](https://github.com/golang/go/issues/56600)).

**Measured against NumPy 2.5.3 (OpenBLAS 0.3.34) on an AMD Zen 3, 16 cores**
(full tables in **[docs/perf.md](docs/perf.md)**):

- **Faster:** whole-array reductions (`Sum`/`Mean`/`Max` at 4 Mi: 2.3–4.5×),
row reductions (`SumAxis(1)` 2.2×, `MaxAxis(1)` 1.7×), float64 `Exp` at
every size (on one core 1.2–1.3×; in a `Workspace` on 16 cores 1.3–1.5×
up to 16 Ki and 7–17× from 256 Ki), the `*Into` forms of `Add`/`Mul`/`Sqrt` (faster or at
parity at every size), and `MatMul` against single-threaded OpenBLAS
(3.7–5.9× from 512²); in a `Workspace`, `x + y` and `sqrt(x*y + x)` match or
beat NumPy at every size measured; float64 `Log` on one core with AVX2
(1.55× at 16 Ki, 1.68× at 1 Mi).
- **At parity:** `SumAxis(0)`, `Inner` against 16-thread OpenBLAS.
- **Slower:** `MatMul` against 16-thread OpenBLAS at 512² and up (0.42× at
512², 0.51× at 1024², measured in one run with OpenBLAS; at 256² ours is
1.44× faster); `Dot` and mat·vec against NumPy's 16-thread BLAS (0.69× and 0.52× since v0.6.0's helper pool, measured in one run; 0.16× before); the
*allocating* forms of elementwise ops up to 256 Ki elements (allocating
float64 `Exp` with 16 Ps is at parity there, 0.9–1.0×, although its kernel
is faster), `Concatenate`/`Stack` and slice copies outside a `Workspace`
(0.2–0.5×: Go's allocator and garbage collector, whose cost grows with
GOMAXPROCS and which `Workspace` largely removes).
**Speed against NumPy is measured on one core, both sides**
([docs/perf.md](docs/perf.md), table in **[docs/perf-table.md](docs/perf-table.md)**):
one row per public function (or a named exemption in
[docs/perf-exempt.md](docs/perf-exempt.md)), against the pinned NumPy 2.5.4,
`GOMAXPROCS=1` and one BLAS thread, interleaved rounds, refused at a 1-minute
load of 8 or more, with the allocation count of every row checked in CI. The
earlier page compared multicore Go with single-threaded NumPy 2.2.4, which the
one-core rule does not allow; it is kept as
[docs/perf-history.md](docs/perf-history.md) for its kernel-level analysis, and
its ratios are not a verdict.

On Apple silicon the GEMM reached parity with tuned BLAS at 1024² (OpenBLAS in
an arm64 VM, single-threaded vecLib on an M4 Max), and every product beats the
pure-Go `gonum` 4–10× (**[BENCHMARKS.md](BENCHMARKS.md)**). On the other
targets, against go-ndarray's own scalar code on one core: on a RISC-V
SpacemiT X60 the RVV kernels cut `Sum` by up to 5.8× and `MatMul` time by
31–45%; on an IBM z17 the s390x kernels cut `MatMul` 1024² from 340 to 70 ms
and `Max` by 92–96% ([docs/perf.md](docs/perf.md), *SIMD coverage*). It is a
and `Max` by 92–96% ([docs/perf-history.md](docs/perf-history.md), *SIMD coverage*). It is a
**standalone, reusable** module and the cgo-free ndarray backend behind
[go-embedded-ruby](https://github.com/go-embedded-ruby/ruby)'s `NDArray` class.

Expand Down
135 changes: 135 additions & 0 deletions bench/data_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package bench

import (
"fmt"
"math"
"slices"

"github.com/go-ndarray/ndarray"
)

// The data. Every input is lo + (hi-lo)*frac((i+off)*gold), built in Go and,
// as the same expression, in NumPy; TestDataParity compares the SHA-256 of both
// sides' bytes. The explicit float64 conversions forbid a fused multiply-add,
// so the bytes are the same on arm64.

const gold = 0.6180339887498949

// ds is one argument of a row: the NumPy expression that builds it, and the Go
// values (flat, row-major; re,im interleaved when cplx) it must equal.
type ds struct {
py string
data []float64
shape []int
cplx bool
}

func vec(lo, hi float64, n int, off float64) []float64 {
x := make([]float64, n)
for i := range x {
f := math.Mod(float64((float64(i)+off)*gold), 1)
x[i] = lo + float64((hi-lo)*f)
}
return x
}

func vpy(lo, hi float64, n int, off float64) string {
return fmt.Sprintf("(%v + (%v - %v) * np.mod((np.arange(%d, dtype=np.float64) + %v) * 0.6180339887498949, 1.0))", lo, hi, lo, n, off)
}

func dvec(lo, hi float64, n int, off float64) ds {
return ds{py: vpy(lo, hi, n, off), data: vec(lo, hi, n, off), shape: []int{n}}
}

func dmat(lo, hi float64, r, c int, off float64) ds {
d := dvec(lo, hi, r*c, off)
d.py += fmt.Sprintf(".reshape(%d, %d)", r, c)
d.shape = []int{r, c}
return d
}

// dreshape is d with another shape.
func dreshape(d ds, shape ...int) ds {
d.py += fmt.Sprintf(".reshape(%s)", pyShape(shape))
d.shape = shape
return d
}

func pyShape(s []int) string {
out := ""
for i, v := range s {
if i > 0 {
out += ", "
}
out += fmt.Sprint(v)
}
return out
}

// dcplx is a complex128 array of the given shape.
func dcplx(shape ...int) ds {
size := 1
for _, v := range shape {
size *= v
}
re, im := vec(-1, 1, size, 0), vec(-1, 1, size, 7)
d := make([]float64, 2*size)
for i := range re {
d[2*i], d[2*i+1] = re[i], im[i]
}
sh := pyShape(shape)
if len(shape) == 1 {
sh += ","
}
return ds{
py: fmt.Sprintf("(lambda r, i: np.stack([r, i], -1).view(np.complex128)[..., 0])(%s.reshape(%s), %s.reshape(%s))",
vpy(-1, 1, size, 0), sh, vpy(-1, 1, size, 7), sh),
data: d, shape: shape, cplx: true,
}
}

// Square matrices of side n for linalg. Dominant: G + n·I (well conditioned,
// invertible). SPD: G + Gᵀ + 2n·I.
func dgen(n int) ds { return dmat(0, 1, n, n, 0) }

func ddom(n int) ds {
g := dgen(n)
d := slices.Clone(g.data)
for i := 0; i < n; i++ {
d[i*n+i] += float64(n)
}
return ds{py: fmt.Sprintf("(%s + %d * np.eye(%d))", g.py, n, n), data: d, shape: g.shape}
}

func dspd(n int) ds {
g := dgen(n)
d := make([]float64, n*n)
for i := 0; i < n; i++ {
for j := 0; j < n; j++ {
d[i*n+j] = g.data[i*n+j] + g.data[j*n+i]
if i == j {
d[i*n+j] += float64(2 * n)
}
}
}
return ds{py: fmt.Sprintf("(%s + %s.T + %d * np.eye(%d))", g.py, g.py, 2*n, n), data: d, shape: g.shape}
}

func (d ds) arr() *ndarray.Array {
if d.cplx {
c := make([]complex128, len(d.data)/2)
for i := range c {
c[i] = complex(d.data[2*i], d.data[2*i+1])
}
a, err := ndarray.FromSlice(c, d.shape...)
if err != nil {
panic(err)
}
return a
}
a, err := ndarray.FromData(slices.Clone(d.data), d.shape...)
if err != nil {
panic(err)
}
return a
}
17 changes: 17 additions & 0 deletions bench/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module github.com/go-ndarray/ndarray/bench

go 1.27.3

require (
github.com/go-ndarray/ndarray v0.0.0
github.com/go-scikit/refbench v0.1.2
)

require (
github.com/go-fft/fft v0.28.11 // indirect
github.com/go-fleettools/fleettools v0.81.0 // indirect
github.com/go-simd/floats v0.11.0 // indirect
golang.org/x/sys v0.49.0 // indirect
)

replace github.com/go-ndarray/ndarray => ../
10 changes: 10 additions & 0 deletions bench/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
github.com/go-fft/fft v0.28.11 h1:ZfT78fSmPjZU2E9PgkSI/0f0E3sesYpl13GHmIFzRGc=
github.com/go-fft/fft v0.28.11/go.mod h1:GN5V4TWCjV05ECqH1ONfjFM6Z1m2shqCdssxKGqQ41s=
github.com/go-fleettools/fleettools v0.81.0 h1:UnMflmNw5b82Z+7Ums7UVaW5RrGuACCVmSwytwT1hxw=
github.com/go-fleettools/fleettools v0.81.0/go.mod h1:y/o3p2HZAvto9J5G5gtPxhN45zgvaYFAXCY9RCK/lQw=
github.com/go-scikit/refbench v0.1.2 h1:bzD0LhsC1UA6HzG1FK8TvQdIYnPib4rEibdUkgVeOfA=
github.com/go-scikit/refbench v0.1.2/go.mod h1:PhJxvLwXiQeKLP2f5ndcb9k5k4PTvU1rSFJ/MKRCrU0=
github.com/go-simd/floats v0.11.0 h1:w0fmevmbISZQGlI7FNs5TI8Qk5pDD6iLfl2BMN4NUTI=
github.com/go-simd/floats v0.11.0/go.mod h1:Aq5a6oY5pUqm9Hpi+iV2nrNmaaUb0rwHvUHIerXWSvA=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
Loading
Loading