diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6d6d2f..fa852b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 — diff --git a/BENCHMARKS.md b/BENCHMARKS.md index 263d90e..90c9b0a 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -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 diff --git a/README.md b/README.md index c3da110..ee153ac 100644 --- a/README.md +++ b/README.md @@ -123,26 +123,16 @@ 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 @@ -150,7 +140,7 @@ 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. diff --git a/bench/data_test.go b/bench/data_test.go new file mode 100644 index 0000000..783e75f --- /dev/null +++ b/bench/data_test.go @@ -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 +} diff --git a/bench/go.mod b/bench/go.mod new file mode 100644 index 0000000..c675b1c --- /dev/null +++ b/bench/go.mod @@ -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 => ../ diff --git a/bench/go.sum b/bench/go.sum new file mode 100644 index 0000000..5b642c5 --- /dev/null +++ b/bench/go.sum @@ -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= diff --git a/bench/perf_test.go b/bench/perf_test.go new file mode 100644 index 0000000..5d5417d --- /dev/null +++ b/bench/perf_test.go @@ -0,0 +1,181 @@ +package bench + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "flag" + "fmt" + "math" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/go-ndarray/ndarray" + "github.com/go-scikit/refbench" + "github.com/go-scikit/refbench/rbtest" +) + +// The speed comparison with NumPy (docs/perf.md). +// +// cd bench +// GOMAXPROCS=1 go test -run TestPerf -cpu 1 . CI mode: allocations, table, denominator +// GOMAXPROCS=1 go test -run TestPerf -cpu 1 -perf.run . measure (refuses at a 1-minute load of 8 or more) +// GOMAXPROCS=1 go test -run TestPerf -perf.record . allocation counts only (a deferred report) +// go test -run 'TestDataParity|TestRefSmoke' -perf.parity . same data on both sides; every NumPy call runs (needs uv) +// +// GOMAXPROCS=1 must be in the environment: ndarray/fft rests on go-fft/fft, +// which reads GOMAXPROCS once at init (go-fft/fft#107), so the GOMAXPROCS=1 that +// refbench sets around a run comes too late for it. + +func allRows() []row { + var rs []row + rs = append(rs, arrayRows()...) + rs = append(rs, ctorRows()...) + rs = append(rs, fftRows()...) + rs = append(rs, linalgRows()...) + rs = append(rs, genRows()...) + rs = append(rs, legacyRows()...) + rs = append(rs, packageRows()...) + return rs +} + +func setupOf(r row) string { + s := "[" + for i, d := range r.args { + if i > 0 { + s += ", " + } + s += d.py + } + return s + "]" +} + +func cases(w *refbench.Worker) []refbench.Case { + var out []refbench.Case + for _, r := range allRows() { + arrs := make([]*ndarray.Array, len(r.args)) + for i, d := range r.args { + arrs[i] = d.arr() + } + run := r.body(arrs) + var ref refbench.Timer + if w != nil { + ref = w.Timer(setupOf(r), r.call, 1) + } + out = append(out, refbench.Case{ + Name: r.name, + Func: r.fn, + Reference: r.ref, + Parity: refbench.ParityNone, // value parity is each package's own NumPy fixtures + Go: func(iters int) { + for ; iters > 0; iters-- { + run() + } + }, + Ref: ref, + }) + } + return out +} + +func startWorker(t testing.TB) *refbench.Worker { + t.Helper() + uv, err := exec.LookPath("uv") + if err != nil { + home, _ := os.UserHomeDir() + uv = filepath.Join(home, ".local", "bin", "uv") + } + refs, err := filepath.Abs("refs.txt") + if err != nil { + t.Fatal(err) + } + w, err := refbench.StartPython(context.Background(), uv, refs) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { w.Close() }) + return w +} + +var parityFlag = flag.Bool("perf.parity", false, "TestDataParity, TestRefSmoke: start the NumPy worker through uv") + +func running() bool { + f := flag.Lookup("perf.run") + return f != nil && f.Value.String() == "true" +} + +func TestPerf(t *testing.T) { + var w *refbench.Worker + if running() { + if os.Getenv("GOMAXPROCS") != "1" { + t.Fatal("GOMAXPROCS=1 must be in the environment (go-fft/fft reads it once at init)") + } + w = startWorker(t) + } + opt := refbench.Options{Repo: "go-ndarray/ndarray"} + if w != nil { + v, err := w.RefVersions() + if err != nil { + t.Fatal(err) + } + opt.RefVersions = v + } + rbtest.Perf(t, refbench.CIConfig{Root: "..", Cases: cases(w)}, opt) +} + +// TestDataParity checks, for every row with arguments, that the Go values and +// NumPy's are the same bytes: a ratio between two computations on different +// data means nothing. +func TestDataParity(t *testing.T) { + if !*parityFlag { + t.Skip("set -perf.parity (starts the NumPy worker through uv)") + } + w := startWorker(t) + seen := map[string]bool{} + for _, r := range allRows() { + if len(r.args) == 0 { + continue + } + setup := setupOf(r) + if seen[setup] { + continue + } + seen[setup] = true + h := sha256.New() + for _, d := range r.args { + for _, v := range d.data { + var b [8]byte + binary.LittleEndian.PutUint64(b[:], math.Float64bits(v)) + h.Write(b[:]) + } + } + got, err := w.DataHash(setup, 1) + if err != nil { + t.Errorf("%s: %v", r.name, err) + continue + } + if want := hex.EncodeToString(h.Sum(nil)); got != want { + t.Errorf("%s: NumPy data %s, Go data %s", r.name, got, want) + } + } + t.Logf("%d distinct argument sets compared", len(seen)) +} + +// TestRefSmoke runs every NumPy call once: a call that raises would otherwise +// show only in a timed run. +func TestRefSmoke(t *testing.T) { + if !*parityFlag { + t.Skip("set -perf.parity (starts the NumPy worker through uv)") + } + w := startWorker(t) + rows := allRows() + for _, r := range rows { + if _, err := w.Timer(setupOf(r), r.call, 1)(1); err != nil { + t.Errorf("%s: %v\n call: %s", r.name, err, r.call) + } + } + fmt.Println("rows:", len(rows)) +} diff --git a/bench/refs.in b/bench/refs.in new file mode 100644 index 0000000..20a580c --- /dev/null +++ b/bench/refs.in @@ -0,0 +1,4 @@ +numpy==2.5.4 +scipy==1.18.1 +statsmodels +mpmath diff --git a/bench/refs.txt b/bench/refs.txt new file mode 100644 index 0000000..b22c117 --- /dev/null +++ b/bench/refs.txt @@ -0,0 +1,400 @@ +formulaic==1.2.2 \ + --hash=sha256:0f84ff49e3fc9dc0e68ab08a0a9427874021aa6c558e66b44dc634a35739b09b \ + --hash=sha256:c99e8f11ff7d327eaecaf63855ca69b7fa0da100ad6c0041ef80912fbac667e6 + # via statsmodels +interface-meta==2.0.1 \ + --hash=sha256:902bd9a95a12f195f15753a1080075d4eca7a2cb934fac7ac03c9e362b50796a \ + --hash=sha256:f38016bef9a4429b6d0792d809be7b65e9781820c674bf7f463999086b6e6323 + # via formulaic +mpmath==1.4.1 \ + --hash=sha256:dc4f0ea2304480d4a9a48a94c1020571558ade522b44a6912efac63a586e140f \ + --hash=sha256:efd6d1b75f09d69524a67609949812668b28e81ecbfe0ab449ced8c13e92642e + # via -r refs.in +narwhals==2.28.0 \ + --hash=sha256:1f69d6385ec30e2fa6e1fdeb37c562ecabd0f713f6f3e3b58a668c5c9df9567f \ + --hash=sha256:2d9860c80d6610820826e94c6e42ce4b1d92d89cdd479586e652a8d211dab345 + # via formulaic +numpy==2.5.4 \ + --hash=sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb \ + --hash=sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5 \ + --hash=sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab \ + --hash=sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988 \ + --hash=sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162 \ + --hash=sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1 \ + --hash=sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5 \ + --hash=sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53 \ + --hash=sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508 \ + --hash=sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255 \ + --hash=sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3 \ + --hash=sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34 \ + --hash=sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266 \ + --hash=sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592 \ + --hash=sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f \ + --hash=sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf \ + --hash=sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee \ + --hash=sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617 \ + --hash=sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e \ + --hash=sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37 \ + --hash=sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c \ + --hash=sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d \ + --hash=sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3 \ + --hash=sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71 \ + --hash=sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647 \ + --hash=sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365 \ + --hash=sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd \ + --hash=sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2 \ + --hash=sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0 \ + --hash=sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d \ + --hash=sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac \ + --hash=sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f \ + --hash=sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d \ + --hash=sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad \ + --hash=sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00 \ + --hash=sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129 \ + --hash=sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179 \ + --hash=sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d \ + --hash=sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53 \ + --hash=sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380 \ + --hash=sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c \ + --hash=sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a \ + --hash=sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8 \ + --hash=sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a \ + --hash=sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551 \ + --hash=sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3 \ + --hash=sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788 \ + --hash=sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a \ + --hash=sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877 \ + --hash=sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17 \ + --hash=sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454 \ + --hash=sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b \ + --hash=sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645 \ + --hash=sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf \ + --hash=sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f \ + --hash=sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356 \ + --hash=sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18 \ + --hash=sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73 \ + --hash=sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23 \ + --hash=sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05 \ + --hash=sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3 \ + --hash=sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959 \ + --hash=sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394 \ + --hash=sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a \ + --hash=sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2 \ + --hash=sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076 + # via + # -r refs.in + # formulaic + # pandas + # patsy + # scipy + # statsmodels +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via + # patsy + # statsmodels +pandas==3.1.0 \ + --hash=sha256:04ca88727dfa86837030c493d7512bb1c008aa4f5f14149c5f8d3ff446791340 \ + --hash=sha256:07807035ad66de0d833d68f9bee05801dbf08b88d56a233b0e9fd189372c3261 \ + --hash=sha256:0a28602573f50453b57802d282f9f8cb7663aa10201127ecbbe95ed4a41c64ce \ + --hash=sha256:0d2ca3b380f0f6cc788780430eb23762619730c11d53989f3982ec96835bfb9e \ + --hash=sha256:1340f823a1d189fae8365de314c4d3c2ea0173c61bca66cc76a91385c64d9d3d \ + --hash=sha256:224a9267e5fa70c16bf4f8a317435eef98387940ce6c8c611f1f68f2c3415e10 \ + --hash=sha256:2a8dc7780720b9e2182799ee86279d4cc7d1fd30b230ea1b202da5b6f7e14c3a \ + --hash=sha256:2bb3f8cc42fae0c0de5186778f4a4957656b9b3955aca48383abfdac44a83598 \ + --hash=sha256:37f88576f3449bfd86c1052f6b42274b61d7250c8d9dcc55f9dc4de7fe9883ae \ + --hash=sha256:388fad0c683ad061baabe23a76f43bf43c1d2c2fd46572b687082cfa195b7275 \ + --hash=sha256:39c67ed9e56ad3ac0727dc2ae6a1eef766988c762d5ce2e2a162613d81c8990b \ + --hash=sha256:3cba20fd269808e224d6212273008173978033a1dee0f3f217bfdbd58775a72c \ + --hash=sha256:3fe234bfb9999552dc54c45851752e56e163218d7663897dca950b4a7c32af39 \ + --hash=sha256:461b79db058639e446ddf90dd7f4804fae385acf0669f6e31321333e14cb6bc5 \ + --hash=sha256:4b5ce18612ca955e7a95d0bacc20120aedf97e2fbbebbe4de029fc24479e79b8 \ + --hash=sha256:54fed4a50b8321ff8d413b46b7e31a023cecefd06c7210c02aa980a9836428b2 \ + --hash=sha256:5611344f9f0ed51619ac7acdbf72b86f5043801a66e3f8730c8971257082e8ad \ + --hash=sha256:684ef3add5a92abb99c1622f2bd04b54b8843075a266e8240106ca94ce1d1237 \ + --hash=sha256:6c51273b89784c9d54020ed2f60e20524169aef349bddf6cba9f5e83b46077a7 \ + --hash=sha256:73b6cea22090b7123c78ee662a5108ff36616b0bec4fe47026f86fb04a49bce7 \ + --hash=sha256:743554ed96755ad1963397ccadbe9965d92db8777ca2ae8c1fe58bd9549d7f28 \ + --hash=sha256:756fd67995f3efb6b25f8eaea95c0e893f84e0068e2c53c5fdbb65275041d4db \ + --hash=sha256:84377fa82d0b40165379a0058d8a1a267ceb09e65160de6fcc75475d4d595884 \ + --hash=sha256:849d5019dead65944875ce21fbf71a2ca18e982164cd7367623cbda8567c3122 \ + --hash=sha256:84d73e0814a38711e4f2a24b272f748a0b4335169d2f9752407ae26bde22990f \ + --hash=sha256:89b66823678cdd53cf97187e7cd6861402bdd147d11a383706093136a4e83f45 \ + --hash=sha256:8e4de3fe8017e3445f1174707cd977e4e1154a704af2c060a1f648862f81acb9 \ + --hash=sha256:9433b5dc2f26ef9d9b56daaf4d41d078bf4372c5d88c42ed5b397a6eea80de16 \ + --hash=sha256:9b4e696ff70ed06e986feeec09356c491ce878d0df69a36c8e591b6a38afa29b \ + --hash=sha256:9ea2983fe096a3d997c8c4b9f26775fa51e7fb251b10634bd7f87e6fe3542d52 \ + --hash=sha256:a13199463b129363701a540c7b79586581b246b10b6a7ad682c41b268e7991d5 \ + --hash=sha256:a1d61e0eae5219cd024fcbc21304de759964b5642ddcdbb28d9a8ba442ab6901 \ + --hash=sha256:a7704e3d7367cfae71d3f8c95ee81e178c7a90bef8d6eac30df9815010722259 \ + --hash=sha256:a8c44d40cbb6d00128bf2d8f8bb66c8d561744b6d44686b5a1a01f3b3fd1245f \ + --hash=sha256:ac2f833557874a79fc0b5fac4c86e402ea6498beba8753dc29ca23d08481689a \ + --hash=sha256:b8c75dbb27c7d170b25c13dd35515da4bb92d40fe8bb3bd2c69a95de2218889b \ + --hash=sha256:bbb121205af7bc5e587b94a07b5f67b5ba687a918665b9e8748f21388a88b044 \ + --hash=sha256:bbfb087db83eb79ca348047961d367bdd4bf9820d90a5945f3652c7060da7363 \ + --hash=sha256:bfac077874a396b7ae61a307b27aa6f0256cf31c143b7f96ac622765754cc300 \ + --hash=sha256:c0bfec4d188fb0c05bba47b9f9def87624b8c545913aa3585a28f19f27ce847e \ + --hash=sha256:c1009f5b6819b81319cf177b4f01a2823ed54c81db8bd11cdf2375f42d064c02 \ + --hash=sha256:c1e084760f3b275eed5863407d025b933d25f3247515fcc84bf664405e8bb3d2 \ + --hash=sha256:c261c7a808982557c2917d40ffd27916769f26aacb4e6775c01e4467bcd34c3b \ + --hash=sha256:c4d9252b57bded194d09cd5ebf18d9b4edac238b16f56984ea8281d2ecfe39cd \ + --hash=sha256:cc5e4e12d287ea77bbf4c4d4bf47ed152d82a990d3a6794dee7c350f887532b5 \ + --hash=sha256:d3920d9b4de89fbef76ba6b863d58a330b5bfb720c9f8f58eb9b7da9b711d034 \ + --hash=sha256:d3e660af30a6683fa57a5a2969555df117e873eaaeb418ad3b2e59249405ee70 \ + --hash=sha256:d53387f8bbff4e7aa567619d4034ffdb1836db1e768a482851b904bbeeed8ef9 \ + --hash=sha256:dde9a55ecd3c4f9100ac50ab15e852e4c54fcf372d800e4495e0b2159a0122ab \ + --hash=sha256:de6d0fc96e8551b83f400bf212611a0b3a2b389594d20a2540dacc61b21e7bce \ + --hash=sha256:e4455cdcc03e42c17233184e8e4ea65c3ed2925d8b1d9d7dd8e6fced3f6e43c1 \ + --hash=sha256:e6f83e44846f9386e3593965bac017a54a5b5c611ea7b836ceab7f465bb4cb24 \ + --hash=sha256:e77112cfcabb3bef7281a72170670db9a5b31409a1cd3199939abb1fba67a7ad \ + --hash=sha256:ebbc0e652844e52e06d1525e5084ec1609158c086c8646aa6ccb34802102e533 \ + --hash=sha256:ecb9b2d8a817b1e1ba1fdb90c0abe9eab31b4fdf2562b7a56b86ac66ace17e49 \ + --hash=sha256:eee72bba8a72f0bf9522132e5c87cd10fce9d77440b50f65326b093ec9fb8d43 \ + --hash=sha256:f1a93c24175129bfc9922ef748ddae1d8137c743ed835e2615be329f606fed4d \ + --hash=sha256:f4772320b7735d96dbbef437129f840dec4175f8a18a2391bd1222ed8943825c + # via + # formulaic + # statsmodels +patsy==1.0.3 \ + --hash=sha256:79ebf4c93ff4d296e58a9d5be2b2ee31bd49d737cf11d70ffbd8a44b2de42e65 \ + --hash=sha256:d3dbebe8fd5f46e29912d030b63c6268647b59bf788a99e2af28a30234cf357c + # via statsmodels +python-dateutil==4.0.0 \ + --hash=sha256:a5e246f79eb80475e691bf93862cead98decd8a498cf53ba0516fcdc0f50dc1c \ + --hash=sha256:d7295ff14fdc0f92f662100bfb6c551d160ca090f2939f1ec5b9794265c03f06 + # via pandas +scipy==1.18.1 \ + --hash=sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc \ + --hash=sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5 \ + --hash=sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123 \ + --hash=sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7 \ + --hash=sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd \ + --hash=sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239 \ + --hash=sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0 \ + --hash=sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb \ + --hash=sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35 \ + --hash=sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d \ + --hash=sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89 \ + --hash=sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5 \ + --hash=sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe \ + --hash=sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3 \ + --hash=sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89 \ + --hash=sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1 \ + --hash=sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305 \ + --hash=sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307 \ + --hash=sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28 \ + --hash=sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230 \ + --hash=sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2 \ + --hash=sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174 \ + --hash=sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba \ + --hash=sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66 \ + --hash=sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12 \ + --hash=sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d \ + --hash=sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0 \ + --hash=sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7 \ + --hash=sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82 \ + --hash=sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487 \ + --hash=sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168 \ + --hash=sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0 \ + --hash=sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f \ + --hash=sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729 \ + --hash=sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9 \ + --hash=sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3 \ + --hash=sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad \ + --hash=sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443 \ + --hash=sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d \ + --hash=sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314 \ + --hash=sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899 \ + --hash=sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23 \ + --hash=sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09 \ + --hash=sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf \ + --hash=sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa \ + --hash=sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87 \ + --hash=sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1 \ + --hash=sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315 \ + --hash=sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12 \ + --hash=sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4 \ + --hash=sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f \ + --hash=sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07 \ + --hash=sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298 \ + --hash=sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93 \ + --hash=sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265 \ + --hash=sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6 \ + --hash=sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331 \ + --hash=sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a \ + --hash=sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7 \ + --hash=sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218 \ + --hash=sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9 + # via + # -r refs.in + # formulaic + # statsmodels +statsmodels==0.15.0 \ + --hash=sha256:04e65ee05475aeceb9cb954cf96c0f9361b9cdfd497b8aae47afbab0c7c786dd \ + --hash=sha256:081adf7e5f2da63f63cbd90491447de5d4c4c2921f19814f235bcf3e736edd3c \ + --hash=sha256:09fe34ac80e147cd76cb702ba672559ee3c5dd3ba15c25a232922b46e5ffe70e \ + --hash=sha256:0a42d58efadd47352f805fcf7eeb000f3399fe9cda260055a81105a922443bdd \ + --hash=sha256:0ca5d27711f171ee478d547d5a8c5424d1820a1b73c2a3d6bf63f71ad4525f71 \ + --hash=sha256:0cb6228713fe47046b606daee63ed4473a7c246fa97cf0a6d6f20d3b0c96c051 \ + --hash=sha256:0e108365c5062ba1cf568a183cfc8da551ab8b2e84dbb00e1e87a6a714dd4cb3 \ + --hash=sha256:0ffe08752f420f12a0b03e8f7d8f3a45417615309b1f37f10561f9a53f044737 \ + --hash=sha256:103a83723f998897fbb5d5d0ff728cbe3755a90d9b1f22406bf58f305faff2e7 \ + --hash=sha256:1f1443cc1ff7683f3d7a9054e6dccd92040fbd4324616a0b6dbb8af1ce7889fb \ + --hash=sha256:24b9917a09bbe0227047e512a40652c4b9117c2cfbf19c4a5b9971a8944a8606 \ + --hash=sha256:27e9467fae83ff327c9d16ae5ea7e771bcdd66a682f49aa324eeefea5584f2d5 \ + --hash=sha256:333d0c70f269ff98b647f157d2ae7fbcf0617e6f9a5b1817fc6609766502292c \ + --hash=sha256:38038823fbe86f11e09433b4a0f4af5f102327c3137a1604b002ce3cf722a31f \ + --hash=sha256:3e5f870037ca154a4d177aeac68704a784b24cf2c45d86b046fc28e37a2a5584 \ + --hash=sha256:40b2737456e75d96866943e33017a1dffc9166025ee33b7eb373fbe8c7a85e09 \ + --hash=sha256:4f90dc4989bd82837c80059fdce132976d4e5a80100119d16e1b00537eb55867 \ + --hash=sha256:511a4f9cadbf91a690bfa863bb3e25dce583d8140046886fdf35fbfabfbce401 \ + --hash=sha256:5538a734dd28efd450bb38a5d5fc94d13ee709bc497e3e4c0a24b3803e3eac39 \ + --hash=sha256:583eb2a46f7c7a5c34a26715c8b6d92199bfb4f04623db5fff9902a3c7c832d3 \ + --hash=sha256:5891ad077b36d6401e44967739df3d2cdd380a0e6e9e46dadec7e3d3a828af55 \ + --hash=sha256:5a7ab98634d47048c6b7c168b3f8f71369e256d7314bc386168526f34c7699d7 \ + --hash=sha256:5d257fe58d0772bc46a557880ca78e2a8e07fec7bfd9d11074aef8e33e1aecbc \ + --hash=sha256:5e1214312d6362cebe0b5c53b4f478d9072559709000369dfc528211cb993e8a \ + --hash=sha256:6197b4cca69fd7bc23ab9430abf1c479764cfe74767467f51964a633dfade7a5 \ + --hash=sha256:65e64afc2655a486ea44a5ffe4e2b05ee3abbcb10ea61d1c02d3e9e519338d9b \ + --hash=sha256:733887a09ff878a2e29504eda684488b26bd134ba3539ac58d2136a58914c65a \ + --hash=sha256:74c0b63448f03020cc27a8ee698fbcf2ba91e61a9f9eee7978c0c4ed33a9b17f \ + --hash=sha256:7b5561f454d6b629bb45cb505df6fdb498f960d0de111bd81f215fa47b15a72d \ + --hash=sha256:7ef37437f740620a8da03437a29b1fc21bd9a70eeb231cd0d223cc4d766adb78 \ + --hash=sha256:80b946d7c922f46f045f25e50daf13439f79c05e403ba50081cad509892c8b26 \ + --hash=sha256:88dbe345ecf317862481b3c86305f42133082e5b366a3a8cf240a6caac404daf \ + --hash=sha256:8e8deb1e3d4ab89fec2a52815f1ec924920c4e0cdb307421a4e47cfed87c03ff \ + --hash=sha256:90df413d7e09474f7e7cbf8807440c1bf76298cdd9daabf9141f76d7627c9151 \ + --hash=sha256:90e75e726be2c9fc1d1d4c43337503a35864bdf5b4693773a08c6ee92d04a153 \ + --hash=sha256:972c33d9fe6bf907b853ba0eccb2f7cf3da90fdddbee002b8478bcaccf637e2a \ + --hash=sha256:a45026596c71afa18543ec2af40b895609c181fc3649433c1cded5ce23ec5cd4 \ + --hash=sha256:ac9bce461d0c8f529f8ed331a469eaa5dd7c3213c06b62623a2e5438a6383093 \ + --hash=sha256:af80f9658c48792947fc754a026ffbebca61a8b483253781973f604a67cf1f8c \ + --hash=sha256:b67886b66d9c7ca118526accedb5c6de7ffd74c015dc0492ea0b1690192b65da \ + --hash=sha256:ba89eb3ccb1c9c1e400d5081157305495b09350b3a65f2d66d3424ad1d3c4209 \ + --hash=sha256:bcaa1e4499697c4e023e5fa6fb04459a34e4f4a449c45ae441992e933bde24db \ + --hash=sha256:c3c1138b4d0e5b0c2387b17dafd2a0fd137c67bd869f70d4ee56feecc918cb0d \ + --hash=sha256:c6e20788141df134b83ffb43b8cccf135a876188f84124835c86f011f138adee \ + --hash=sha256:c6f2bd48915cc9b3b95c3c7f6ac363b6f8aaa38172962ba5f7f6885f0dfe57f2 \ + --hash=sha256:cc37bb71f4b1e8c9b07e9ce0d32e01fbabe231d323484d35b58e93683a5123e1 \ + --hash=sha256:cf897abce5616e2763ab7d7a7fa914677df87a649971f877b83a358c98f6e291 \ + --hash=sha256:d37d574412a47a71561c0b28c5f876137a86f26fa91e889a879950e22b4e9b4f \ + --hash=sha256:d48ba61db0d13d3033330e6bd1f864dfa9b48c0b952b3b0f7570206b35d552e5 \ + --hash=sha256:dd184e911a1a8c52e5c1c27b43ccd10c24faec08053271ca1a35276cae0a3ada \ + --hash=sha256:dde0657d18e322eedb2540a84c0582e97afd0f78c370b45eebb0d4ac228a7a83 \ + --hash=sha256:eb7fc1147cd8d17132190db7a8e63cd76a06145dec502816d8f3c20619923f2b \ + --hash=sha256:ecf84918dcd410ab34d3d85d34e0b16ebb10319c9fc77dbf9a1f1a49324c59f8 \ + --hash=sha256:edd803f4606061af8c7a83e2326ea294cbfa802a908dfbda4a88347301fb6820 \ + --hash=sha256:edee066ac9b171d95c3de925225331a21de1deb5b6452f252bc853e44566e72b + # via -r refs.in +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via formulaic +tzdata==2026.6 \ + --hash=sha256:0a10eec6e13ef16b627fabed9a5f2ba61a62b1c67f327305f4e7e412a70b4aec \ + --hash=sha256:85d2cd9c09f81f0bb29db27759fc83af4f1e620efb814f8c5bf8b4cc4feece6d + # via python-dateutil +wrapt==2.5.2 \ + --hash=sha256:01ace38a377198e0b981b027a46cbdb5502d0123bd39c1a0f60a83a5eab7698e \ + --hash=sha256:035cd71e2aadfc0ad366ab4785b49e62093370885ea4b527b61b308b7a92ff14 \ + --hash=sha256:04703a2a4eea122c3a6b3af89e72bbbaa20779e5c7199c3d06e3fcb6601bc7d4 \ + --hash=sha256:0743950d3dda9940e56f81f5334ba051c4fe2e1421e4e4a4ef8eb8fb86a90894 \ + --hash=sha256:08d1d7bcb7dd277b7b976fd914455c3c1088f5c101ac7469ae5a5b8c03be0a0a \ + --hash=sha256:092472b7b1494deef40dac5e9f6295013a9d51b112b5fbc4350f64dc2574e46a \ + --hash=sha256:0d7639c3287d4efcdc2c8837f1854d1ecf27aae761714ba3d95e61fe8eb48db3 \ + --hash=sha256:0fa61d2f1033039aa47f8f1b4e076ca3c9d0fc08e448c4b1bd06d8b59da89cbf \ + --hash=sha256:11655df8e17658a67628cdc97fe95d6b263e023213cf18067f21e5ec0b0285f3 \ + --hash=sha256:1433ddff7786f2d0d4096646530a7aa04de0e776715014b80bc289e874fd8538 \ + --hash=sha256:143a3f8b33984e35950ae5d0159be2bac93356b6f79b509af9941a4be64c4368 \ + --hash=sha256:18819c05409a161999be68da8e451636470c35f77e25c8201825a33a7d88c89c \ + --hash=sha256:1ad706a42b139ad7f03f32aed57da5e10fe8ed707e29849cfa94f1d49cba1405 \ + --hash=sha256:1c17ca5ce83c7145eddaa20073398ec2192dad65c8d8ff4d9b78fd2917c46a75 \ + --hash=sha256:217798fb0cfb31479a264f841b0242bc1bed1ffc70d233e8444a876cec57928c \ + --hash=sha256:229859aebfc909626ea6b5a3ae9523aba43c63fd828f876f2a813f4a5767726f \ + --hash=sha256:244d57e361833389278820e5242f4a364305f633572d9500d3a85f40417aefee \ + --hash=sha256:2594e0be4ee09b2f19828265a39df90e591def44215fe8c5b8d6b83aa4cc18b5 \ + --hash=sha256:269321b17467a10997cc6e14793bf1816e6ce488f9711c74b72552466d206739 \ + --hash=sha256:2861d50a4f330fa140ddc2a9ddee33f7e4f9bdb649a23dc9be8ed6310ffa47fc \ + --hash=sha256:2bf23cde96e0f771a6a2c68973c1ded45c9707f8bd519ede30ecf075f3c720f6 \ + --hash=sha256:2c17ec4310cc06f864799a4c37c1c716d5dcf05ceead2e63ca992a9466903b73 \ + --hash=sha256:32c0387872e67b9dfdd2b599bf31fe127e597ed8526c5f192cdaca57a491e9dc \ + --hash=sha256:36021ef761856bdbc10d5f3da9a95af93ef60d6acf9c72c8f849937ba2b902aa \ + --hash=sha256:3b6885250b809532a634acd56e360d57c201c1cdc75cbf2b5e106241ece6cbc3 \ + --hash=sha256:40e03506f09dbf4854fd69848e843dadd9b7fbbdd217837cb27b37fb8f0b5bc3 \ + --hash=sha256:40f8ae3eb1388763fd2e60b55e1d569f66e80552e02a333f3d2fa9139a509d1e \ + --hash=sha256:430070776fb30d925a7a1e13d68086e4731b63eedf4c43ee62fc4c8108920cce \ + --hash=sha256:450cc93f5a2d58d4a74d384d52db4b41d5728065c0057c9bb4666c6115f2a2c9 \ + --hash=sha256:459bb66cd4bc43e35cba1f1ab2bdfb9b507e0d8dc0ec40c93a060c9cb0c5415e \ + --hash=sha256:46cee4400437c9f33e755522be9e7ce6ebd097273f464eb6f57bee09787f0c59 \ + --hash=sha256:48fd5026977adb3e2fda292a6a796967b511935d23f97dc2ae1f127a3a8639d5 \ + --hash=sha256:4d5faf5c3e435207b3a51294ed5a00e5e17e3842442ccbbd864ee9bc0efc63fb \ + --hash=sha256:4e86f3543c67d72b4d4c08c3e351523c2d69cd05932df65937d79bc472e9d62f \ + --hash=sha256:51465dcbab69d9c02e8ca943befa1eabc37ccb07f9335c240d78a78330285a2d \ + --hash=sha256:53cf804402d49fa2c920a90dfab971a82bca67fb43e54adc7c3b24c054485b4d \ + --hash=sha256:550a597a9c0510fb8bd79864025225f4fc51717adb617191561295565337f068 \ + --hash=sha256:551cb49e88b3722faad780303833bc4fa70685fb1773270fb98b40d39d9fddbf \ + --hash=sha256:5726faa7342ba473041bc1a276486d232b42e3927d35427729b266b1b5ecaa42 \ + --hash=sha256:594c6d10b9cfb951a09f7338531a41d31194e7eee9dee206c0a8992421682719 \ + --hash=sha256:59b7e6d1dea7c3f38bab21954f587499c27c16ea537058eee4add6e4da1780ea \ + --hash=sha256:5ab31d929ca8b2b7bf9320ad2a927edfb1841609951a869a9b98723b285ffbc6 \ + --hash=sha256:61eb54e9540ea522c2c4752a57549a83d3c67e6704ed708403d28e067aaf90f0 \ + --hash=sha256:62b339565cfdfcc0ba71c527b4ad13ac9de25504d474fa4ae5db95d1c7e4c6a2 \ + --hash=sha256:73d432588d1e0a8fcb88d46ef2733e848ff0c3e14111a0420c77199fd2a8cd30 \ + --hash=sha256:7a7efde469ad904b8e05f98c76d63b437f4756c30ec7b9136bef02bdb5d1b14c \ + --hash=sha256:7f89423965b23aa62a90bf21c20353056a829ca9a60c01fde9aec9876c99392e \ + --hash=sha256:7ffa0be31f651da63ee51e5b74701339feac1860b561a985c7b4869d4600e800 \ + --hash=sha256:80eec154f8ce6d26a553481772f111a064091b7e37700c690e0024921d57c569 \ + --hash=sha256:81bdf0bd141d9b1706a0751b2ee068c7afe6be4042712d427b32b3072d5c40b9 \ + --hash=sha256:81e685c7662fab9958a852b9c86f3b5b8eee45d047a2766d6c23b63ad1eee423 \ + --hash=sha256:84619ca6b6f67a7dd53754ad402cafceb4ddc9d8981f144f62718b783f05786e \ + --hash=sha256:8581eab7cb0a3bc6e8c8f64f853a1a814664d71f7ca74efb3011b4df2a40314b \ + --hash=sha256:858511731d6ede4217960c5fecdb5ecbf90e306dfd93b837395658d61374a498 \ + --hash=sha256:88f982c2bb306732271aa15cded4002ce28b5d9023de9d11e927da4dcf65e441 \ + --hash=sha256:8b039b3a296762db1908d6108365225393c4fb33c1dbc5f00aa36f8302eaeaa0 \ + --hash=sha256:8b240a879405591da10e2d20c2d1ad29b978d40ec37e24801296cfee1d4c04f9 \ + --hash=sha256:90dad90c8a74293d2cb3ed225760a8c8f20009522efe519d6380f5de7f5caece \ + --hash=sha256:99978d196df45c115273c6119e4d0dacbc67bc267a196506625bf7084b741f0b \ + --hash=sha256:9c0e0342eba94829491985516e951d1b6a2de402ae4a44d1ea0debbd929cb7a9 \ + --hash=sha256:9cb33f293e3aa04b36f7d165dc05b42db85e0a88e20a8fd81f60426d1f17d535 \ + --hash=sha256:9d4e1cdf2efdc7181c8177f6090886e22278e70f7257df12863d2f52a9dcbbd7 \ + --hash=sha256:a0541e62f7b9c0d42130e3469d6ac3b1767f019314ae795646a2850a75c64412 \ + --hash=sha256:a60863d874cc6ac917bbf44324e3f195564064dee15c84e83b19e1fbbccd3169 \ + --hash=sha256:a829b0febab0828f5d47903b11474f9f23711da5ac55e15d0d177e907d3c15e7 \ + --hash=sha256:a8626135ecbb0b892ca79c388581ab0ef57e087f09dc0e51a2f34185d3682055 \ + --hash=sha256:ac00ba7d22d55d3c8d085005ce4942ff3bb118d480f889b5984f1d3454052917 \ + --hash=sha256:ad9daa883bb795b8cbe960570590878280a444173fece51f7bbfb7532a9538e2 \ + --hash=sha256:ae84013e89ec1874415549a46cfea373e28ddecbb72e1f5456cddfa2b76a28e6 \ + --hash=sha256:af9cecb68173b0945a53e7aaabc65ef6ef458f6dd201a9ebb23139e804f1f5e7 \ + --hash=sha256:afd99530994449aaac9a58e1e268eb080bb1ffd758be43ab2f8a249ce8970c9c \ + --hash=sha256:b3ebc07822649eda442382c1d20983bc6ec5aa2bf79a600da11b29ecb62a0fb2 \ + --hash=sha256:b528eb5dbf54cf9c3e9b9c5b1680e0f4965eb8750ae738f43dd14d1bc332fa42 \ + --hash=sha256:b632d90ac0d04e3ba8821b19954d6c36ea8f714b75db53c58015025fefccb1ac \ + --hash=sha256:b680bef5200899524667f813fa34dcfde9313fef9f9fa432985d4bc7b4195ba3 \ + --hash=sha256:b9ce67ec8803e6c56e78c9ebf726f61116516212c28dfa3683d0962e15cf9a62 \ + --hash=sha256:b9f41029f9bc6ba80755b9c07f5e77e9e7aafb71c625b9f67614e4ebfb24302a \ + --hash=sha256:bbb6e959c1e2c32bbad291f83af6320c8c46bb64e256801e85f55d88a09522d5 \ + --hash=sha256:bec3b6b1ae2f7ed763fb0ba1776b9b5f11283e6ff40c9cbe3a855ea652c326cf \ + --hash=sha256:c1ea16d7dfe7d235d29435307b1539495b4925e88b135d532892973a04569245 \ + --hash=sha256:c60a5cd8b7b510056f2643d94f1c144533a568ade2891a9dd35733f5e0055777 \ + --hash=sha256:c6b4bd8c662c333518a9aedb35bb9f4f7d1584fbef4bc1507a6c6a47357c628e \ + --hash=sha256:c954a582cd96faa9a7598b634efdc92c9fa7710538dae93fbd9222c3a1e13beb \ + --hash=sha256:cfd508866c421687a99bed9ce5b7b4f26fd9b93d3753685d81947ab37d54c496 \ + --hash=sha256:d108bd98ab2c5ccbf55cf2154e0a73341df930030df2e01ee35d71a44a99d3ad \ + --hash=sha256:d41c45517912610de55546188695e2bd0fbcd70b585087cd759f85a96f47be7e \ + --hash=sha256:d5322e78248f6079f6525f0a9476750112750ea0a3f3fca13fd5f18a1c864d3b \ + --hash=sha256:d8dc92a832446351ce3bf5876aa558e4f6b456ca0613d02969ce0db4762394b2 \ + --hash=sha256:da51f5ac290f2e024ee842799c6592f68cd880c20f6b3a54305d1b6aeab459a4 \ + --hash=sha256:ded0118adcb43b080cfcbe2a96dcf27171b8fd73d0b418603d899f4e6309ac50 \ + --hash=sha256:e1af6136ac5bcfab17190a70b25d8437eb8d8f9ca2b821a0655d39275a8cb7e7 \ + --hash=sha256:e1fb4af8aac3573770ed3e64efc39d953342b12bcb6e6479512a945eb5557523 \ + --hash=sha256:e33578755e07edf3a70df3241f4cf376dad507e2cc73e737311bb00b5067f4d7 \ + --hash=sha256:e4362b3b4e4b050b8f1f518e0c71a09f2e938a9f62ee559ea1743a13b3c9e8f1 \ + --hash=sha256:e57d79e3338b3cecee1dd80d6ef8f945f8ea7b92f0d8d5591f840ffa0afc14dc \ + --hash=sha256:eeaa40251c40e18c5c97ef11d5dbbad28a7b7b99fdd7a7469662799100d74dc7 \ + --hash=sha256:eff91af619ad5d587f8855f6c02164e12d33d205c702eb73d487deb499520265 \ + --hash=sha256:f00c726586871c22f542df6d5ce57a81e60e9339e4d40bcc7d7d5c6130db3cce \ + --hash=sha256:f01f56a6ddffd89179bf0424c3b5bbd187690cfa2f972472aa2d563a4e0a7fdb \ + --hash=sha256:fa642d3781c061579cb138773add4a55b16f99de9ebc41603e89a756cf9e62c6 \ + --hash=sha256:fabc60720d1e4a36bbe8aaf1c32024f04c6674991313f5acaca5eeadb564c600 + # via formulaic diff --git a/bench/rows_array_test.go b/bench/rows_array_test.go new file mode 100644 index 0000000..303da53 --- /dev/null +++ b/bench/rows_array_test.go @@ -0,0 +1,292 @@ +package bench + +import ( + "fmt" + + "github.com/go-ndarray/ndarray" +) + +// row is one measured case: a Go body, and the NumPy expression it is compared +// with. call is a Python expression whose value is called as call(*args). +type row struct { + name, fn string + ref string + args []ds + call string + body func(a []*ndarray.Array) func() +} + +var ( + sinkA *ndarray.Array + sinkF float64 + sinkI int + sinkB bool + sinkAs []*ndarray.Array +) + +func must(a *ndarray.Array, err error) *ndarray.Array { + if err != nil { + panic(err) + } + return a +} + +func chk(err error) { + if err != nil { + panic(err) + } +} + +const ( + nvec = 100_000 // elements of a vector + mrow = 1000 // a matrix of 1000 x 100 elements for the axis reductions + mcol = 100 + mm = 128 // side of the square matrices of the matrix products +) + +var ( + dX = dvec(0.5, 4, nvec, 0) // positive, away from 0 + dY = dvec(1, 2, nvec, 3) // a divisor + dP = dvec(0.999, 1.001, nvec, 5) // a product that stays near 1 + dXM = dreshape(dX, mrow, mcol) + dPM = dreshape(dP, mrow, mcol) + dX1 = dvec(0.5, 4, mrow, 0) // 1000 points for Outer + dY1 = dvec(1, 2, mrow, 3) + dA = dmat(0, 1, mm, mm, 0) + dB = dmat(0, 1, mm, mm, 11) +) + +// maskPy is the mask x > 2 on dX, built in the factory so it is outside the timed call. +var maskPy = vpy(0.5, 4, nvec, 0) + " > 2.0" + +func un(name, py string, g func(a *ndarray.Array) *ndarray.Array) row { + return row{name: "Array." + name, fn: "Array." + name, ref: "numpy " + py, args: []ds{dX}, + call: py, body: func(a []*ndarray.Array) func() { return func() { sinkA = g(a[0]) } }} +} + +func bin(name, py string, g func(a, b *ndarray.Array) (*ndarray.Array, error)) row { + return row{name: "Array." + name, fn: "Array." + name, ref: "numpy " + py, args: []ds{dX, dY}, + call: py, body: func(a []*ndarray.Array) func() { return func() { sinkA = must(g(a[0], a[1])) } }} +} + +func into(name, py string, g func(a, out, b *ndarray.Array) error) row { + return row{name: "Array." + name, fn: "Array." + name, ref: "numpy " + py + " (out= preallocated)", args: []ds{dX, dY}, + call: fmt.Sprintf("(lambda o: lambda x, y: %s(x, y, out=o))(np.empty(%d))", py, nvec), + body: func(a []*ndarray.Array) func() { + out := must(ndarray.New(nvec)) + return func() { chk(g(a[0], out, a[1])) } + }} +} + +func scal(name, op string, g func(a *ndarray.Array, v float64) *ndarray.Array) row { + return row{name: "Array." + name, fn: "Array." + name, ref: "numpy x " + op + " 1.5", args: []ds{dX}, + call: "lambda x: x " + op + " 1.5", body: func(a []*ndarray.Array) func() { return func() { sinkA = g(a[0], 1.5) } }} +} + +// axisRows gives a reduction over axis 0 and over axis 1 of a 1000 x 100 matrix. +func axisRows(name, py string, d ds, g func(a *ndarray.Array, axis int, keep bool) (*ndarray.Array, error)) []row { + var out []row + for ax := 0; ax < 2; ax++ { + out = append(out, row{name: fmt.Sprintf("Array.%s/axis%d", name, ax), fn: "Array." + name, + ref: fmt.Sprintf("numpy %s(axis=%d)", py, ax), args: []ds{d}, + call: fmt.Sprintf("lambda x: %s(x, axis=%d)", py, ax), + body: func(a []*ndarray.Array) func() { return func() { sinkA = must(g(a[0], ax, false)) } }}) + } + return out +} + +func arrayRows() []row { + rs := []row{ + un("Abs", "np.abs", (*ndarray.Array).Abs), + un("Neg", "np.negative", (*ndarray.Array).Neg), + un("Sqrt", "np.sqrt", (*ndarray.Array).Sqrt), + un("Square", "np.square", (*ndarray.Array).Square), + un("Exp", "np.exp", (*ndarray.Array).Exp), + un("Log", "np.log", (*ndarray.Array).Log), + un("Log2", "np.log2", (*ndarray.Array).Log2), + un("Log10", "np.log10", (*ndarray.Array).Log10), + un("Sin", "np.sin", (*ndarray.Array).Sin), + un("Cos", "np.cos", (*ndarray.Array).Cos), + un("Tan", "np.tan", (*ndarray.Array).Tan), + un("Ceil", "np.ceil", (*ndarray.Array).Ceil), + un("Floor", "np.floor", (*ndarray.Array).Floor), + un("Round", "np.round", (*ndarray.Array).Round), + un("Conj", "np.conj", (*ndarray.Array).Conj), + un("Real", "np.real", (*ndarray.Array).Real), + un("Imag", "np.imag", (*ndarray.Array).Imag), + un("Angle", "np.angle", (*ndarray.Array).Angle), + un("LogicalNot", "np.logical_not", (*ndarray.Array).LogicalNot), + un("Copy", "lambda x: x.copy()", (*ndarray.Array).Copy), + un("Flatten", "lambda x: x.flatten()", (*ndarray.Array).Flatten), + un("Ravel", "lambda x: x.ravel()", (*ndarray.Array).Ravel), + un("Power", "lambda x: np.power(x, 2.5)", func(a *ndarray.Array) *ndarray.Array { return a.Power(2.5) }), + un("CumSumFlat", "np.cumsum", (*ndarray.Array).CumSumFlat), + un("Nonzero", "np.flatnonzero", (*ndarray.Array).Nonzero), + un("Repr", "repr", func(a *ndarray.Array) *ndarray.Array { sinkF = float64(len(a.Repr())); return a }), + un("AsType", "lambda x: x.astype(np.float32)", func(a *ndarray.Array) *ndarray.Array { return a.AsType(ndarray.Float32) }), + bin("Add", "np.add", (*ndarray.Array).Add), + bin("Sub", "np.subtract", (*ndarray.Array).Sub), + bin("Mul", "np.multiply", (*ndarray.Array).Mul), + bin("Div", "np.divide", (*ndarray.Array).Div), + bin("Maximum", "np.maximum", (*ndarray.Array).Maximum), + bin("Minimum", "np.minimum", (*ndarray.Array).Minimum), + bin("Equal", "np.equal", (*ndarray.Array).Equal), + bin("NotEqual", "np.not_equal", (*ndarray.Array).NotEqual), + bin("Less", "np.less", (*ndarray.Array).Less), + bin("LessEqual", "np.less_equal", (*ndarray.Array).LessEqual), + bin("Greater", "np.greater", (*ndarray.Array).Greater), + bin("GreaterEqual", "np.greater_equal", (*ndarray.Array).GreaterEqual), + bin("LogicalAnd", "np.logical_and", (*ndarray.Array).LogicalAnd), + bin("LogicalOr", "np.logical_or", (*ndarray.Array).LogicalOr), + bin("LogicalXor", "np.logical_xor", (*ndarray.Array).LogicalXor), + bin("Inner", "np.inner", (*ndarray.Array).Inner), + into("AddInto", "np.add", func(a, o, b *ndarray.Array) error { return a.AddInto(o, b) }), + into("SubInto", "np.subtract", func(a, o, b *ndarray.Array) error { return a.SubInto(o, b) }), + into("MulInto", "np.multiply", func(a, o, b *ndarray.Array) error { return a.MulInto(o, b) }), + into("DivInto", "np.divide", func(a, o, b *ndarray.Array) error { return a.DivInto(o, b) }), + scal("AddScalar", "+", (*ndarray.Array).AddScalar), + scal("SubScalar", "-", (*ndarray.Array).SubScalar), + scal("MulScalar", "*", (*ndarray.Array).MulScalar), + scal("DivScalar", "/", (*ndarray.Array).DivScalar), + {name: "Array.SqrtInto", fn: "Array.SqrtInto", ref: "numpy np.sqrt (out= preallocated)", args: []ds{dX}, + call: fmt.Sprintf("(lambda o: lambda x: np.sqrt(x, out=o))(np.empty(%d))", nvec), + body: func(a []*ndarray.Array) func() { + out := must(ndarray.New(nvec)) + return func() { chk(a[0].SqrtInto(out)) } + }}, + {name: "Array.Clip", fn: "Array.Clip", ref: "numpy np.clip", args: []ds{dX}, call: "lambda x: np.clip(x, 1.0, 3.0)", + body: func(a []*ndarray.Array) func() { return func() { sinkA = must(a[0].Clip(1, 3)) } }}, + {name: "Array.Dot", fn: "Array.Dot", ref: "numpy np.dot, 128 x 128 (OpenBLAS, one thread)", args: []ds{dA, dB}, call: "np.dot", + body: func(a []*ndarray.Array) func() { return func() { sinkA = must(a[0].Dot(a[1])) } }}, + {name: "Array.MatMul", fn: "Array.MatMul", ref: "numpy np.matmul, 128 x 128 (OpenBLAS, one thread)", args: []ds{dA, dB}, call: "np.matmul", + body: func(a []*ndarray.Array) func() { return func() { sinkA = must(a[0].MatMul(a[1])) } }}, + {name: "Array.Outer", fn: "Array.Outer", ref: "numpy np.outer, 1000 x 1000", args: []ds{dX1, dY1}, call: "np.outer", + body: func(a []*ndarray.Array) func() { return func() { sinkA = a[0].Outer(a[1]) } }}, + {name: "Array.Transpose", fn: "Array.Transpose", ref: "numpy x.T (a view; Go may copy)", args: []ds{dA}, call: "lambda x: x.T", + body: func(a []*ndarray.Array) func() { return func() { sinkA = a[0].Transpose() } }}, + {name: "Array.Reshape", fn: "Array.Reshape", ref: "numpy x.reshape (a view)", args: []ds{dX}, call: "lambda x: x.reshape(100, 1000)", + body: func(a []*ndarray.Array) func() { return func() { sinkA = must(a[0].Reshape(100, 1000)) } }}, + {name: "Array.ExpandDims", fn: "Array.ExpandDims", ref: "numpy np.expand_dims", args: []ds{dX}, call: "lambda x: np.expand_dims(x, 0)", + body: func(a []*ndarray.Array) func() { return func() { sinkA = must(a[0].ExpandDims(0)) } }}, + {name: "Array.Squeeze", fn: "Array.Squeeze", ref: "numpy np.squeeze", args: []ds{dreshape(dX, 1, nvec)}, call: "np.squeeze", + body: func(a []*ndarray.Array) func() { return func() { sinkA = must(a[0].Squeeze()) } }}, + {name: "Array.Slice", fn: "Array.Slice", ref: "numpy x[0:n:2] (a view)", args: []ds{dX}, call: "lambda x: x[0:100000:2]", + body: func(a []*ndarray.Array) func() { + return func() { sinkA = must(a[0].Slice(ndarray.Rng(0, nvec, 2))) } + }}, + {name: "Array.Take", fn: "Array.Take", ref: "numpy np.take, 1000 indices", args: []ds{dX}, + call: "(lambda idx: lambda x: np.take(x, idx))((np.arange(1000) * 7919) % 100000)", + body: func(a []*ndarray.Array) func() { + idx := make([]int, 1000) + for i := range idx { + idx[i] = (i * 7919) % nvec + } + return func() { sinkA = must(a[0].Take(idx...)) } + }}, + {name: "Array.MaskSelect", fn: "Array.MaskSelect", ref: "numpy x[mask] (mask x > 2 built outside the call)", args: []ds{dX}, + call: "(lambda mk: lambda x: x[mk])(" + maskPy + ")", + body: func(a []*ndarray.Array) func() { + mask := must(a[0].Greater(ndarray.Scalar(2.0))) + return func() { sinkA = must(a[0].MaskSelect(mask)) } + }}, + {name: "Where", fn: "Where", ref: "numpy np.where (mask x > 2 built outside the call)", args: []ds{dX, dY}, + call: "(lambda mk: lambda x, y: np.where(mk, x, y))(" + maskPy + ")", + body: func(a []*ndarray.Array) func() { + mask := must(a[0].Greater(ndarray.Scalar(2.0))) + return func() { sinkA = must(ndarray.Where(mask, a[0], a[1])) } + }}, + {name: "Concatenate", fn: "Concatenate", ref: "numpy np.concatenate", args: []ds{dX, dY}, call: "lambda x, y: np.concatenate((x, y))", + body: func(a []*ndarray.Array) func() { return func() { sinkA = must(ndarray.Concatenate(a, 0)) } }}, + {name: "Stack", fn: "Stack", ref: "numpy np.stack", args: []ds{dX, dY}, call: "lambda x, y: np.stack((x, y))", + body: func(a []*ndarray.Array) func() { return func() { sinkA = must(ndarray.Stack(a, 0)) } }}, + {name: "HStack", fn: "HStack", ref: "numpy np.hstack", args: []ds{dX, dY}, call: "lambda x, y: np.hstack((x, y))", + body: func(a []*ndarray.Array) func() { return func() { sinkA = must(ndarray.HStack(a)) } }}, + {name: "VStack", fn: "VStack", ref: "numpy np.vstack", args: []ds{dX, dY}, call: "lambda x, y: np.vstack((x, y))", + body: func(a []*ndarray.Array) func() { return func() { sinkA = must(ndarray.VStack(a)) } }}, + // Reductions to a scalar. + {name: "Array.Sum", fn: "Array.Sum", ref: "numpy np.sum", args: []ds{dX}, call: "np.sum", + body: func(a []*ndarray.Array) func() { return func() { sinkF = a[0].Sum() } }}, + {name: "Array.SumAll", fn: "Array.SumAll", ref: "numpy np.sum", args: []ds{dX}, call: "np.sum", + body: func(a []*ndarray.Array) func() { return func() { sinkA = a[0].SumAll() } }}, + {name: "Array.Prod", fn: "Array.Prod", ref: "numpy np.prod", args: []ds{dP}, call: "np.prod", + body: func(a []*ndarray.Array) func() { return func() { sinkF = a[0].Prod() } }}, + {name: "Array.ProdAll", fn: "Array.ProdAll", ref: "numpy np.prod", args: []ds{dP}, call: "np.prod", + body: func(a []*ndarray.Array) func() { return func() { sinkA = a[0].ProdAll() } }}, + {name: "Array.Mean", fn: "Array.Mean", ref: "numpy np.mean", args: []ds{dX}, call: "np.mean", + body: func(a []*ndarray.Array) func() { + return func() { v, err := a[0].Mean(); chk(err); sinkF = v } + }}, + {name: "Array.Max", fn: "Array.Max", ref: "numpy np.max", args: []ds{dX}, call: "np.max", + body: func(a []*ndarray.Array) func() { + return func() { v, err := a[0].Max(); chk(err); sinkF = v } + }}, + {name: "Array.Min", fn: "Array.Min", ref: "numpy np.min", args: []ds{dX}, call: "np.min", + body: func(a []*ndarray.Array) func() { + return func() { v, err := a[0].Min(); chk(err); sinkF = v } + }}, + {name: "Array.ArgMax", fn: "Array.ArgMax", ref: "numpy np.argmax", args: []ds{dX}, call: "np.argmax", + body: func(a []*ndarray.Array) func() { + return func() { v, err := a[0].ArgMax(); chk(err); sinkI = v } + }}, + {name: "Array.ArgMin", fn: "Array.ArgMin", ref: "numpy np.argmin", args: []ds{dX}, call: "np.argmin", + body: func(a []*ndarray.Array) func() { + return func() { v, err := a[0].ArgMin(); chk(err); sinkI = v } + }}, + {name: "Array.All", fn: "Array.All", ref: "numpy np.all", args: []ds{dX}, call: "np.all", + body: func(a []*ndarray.Array) func() { return func() { sinkB = a[0].All() } }}, + {name: "Array.Any", fn: "Array.Any", ref: "numpy np.any", args: []ds{dX}, call: "np.any", + body: func(a []*ndarray.Array) func() { return func() { sinkB = a[0].Any() } }}, + {name: "Array.CumProdFlat", fn: "Array.CumProdFlat", ref: "numpy np.cumprod", args: []ds{dP}, call: "np.cumprod", + body: func(a []*ndarray.Array) func() { return func() { sinkA = a[0].CumProdFlat() } }}, + } + rs = append(rs, axisRows("SumAxis", "np.sum", dXM, (*ndarray.Array).SumAxis)...) + rs = append(rs, axisRows("ProdAxis", "np.prod", dPM, (*ndarray.Array).ProdAxis)...) + rs = append(rs, axisRows("MeanAxis", "np.mean", dXM, (*ndarray.Array).MeanAxis)...) + rs = append(rs, axisRows("MaxAxis", "np.max", dXM, (*ndarray.Array).MaxAxis)...) + rs = append(rs, axisRows("MinAxis", "np.min", dXM, (*ndarray.Array).MinAxis)...) + rs = append(rs, axisRows("ArgMaxAxis", "np.argmax", dXM, (*ndarray.Array).ArgMaxAxis)...) + rs = append(rs, axisRows("ArgMinAxis", "np.argmin", dXM, (*ndarray.Array).ArgMinAxis)...) + for ax := 0; ax < 2; ax++ { + rs = append(rs, + row{name: fmt.Sprintf("Array.CumSum/axis%d", ax), fn: "Array.CumSum", ref: fmt.Sprintf("numpy np.cumsum(axis=%d)", ax), + args: []ds{dXM}, call: fmt.Sprintf("lambda x: np.cumsum(x, axis=%d)", ax), + body: func(a []*ndarray.Array) func() { return func() { sinkA = must(a[0].CumSum(ax)) } }}, + row{name: fmt.Sprintf("Array.CumProd/axis%d", ax), fn: "Array.CumProd", ref: fmt.Sprintf("numpy np.cumprod(axis=%d)", ax), + args: []ds{dPM}, call: fmt.Sprintf("lambda x: np.cumprod(x, axis=%d)", ax), + body: func(a []*ndarray.Array) func() { return func() { sinkA = must(a[0].CumProd(ax)) } }}) + } + return rs +} + +// ctorRows are the constructors: nothing to read, the reference builds the same array. +func ctorRows() []row { + f := func(name, py string, g func() *ndarray.Array, args ...ds) row { + return row{name: name, fn: name, ref: "numpy " + py, args: args, call: "lambda *a: " + py, + body: func(a []*ndarray.Array) func() { return func() { sinkA = g() } }} + } + const eye = 512 + return []row{ + f("Arange", "np.arange(0.0, 100000.0, 1.0)", func() *ndarray.Array { return must(ndarray.Arange(0, nvec, 1)) }), + f("Eye", "np.eye(512)", func() *ndarray.Array { return must(ndarray.Eye(eye, eye, 0)) }), + f("Identity", "np.identity(512)", func() *ndarray.Array { return must(ndarray.Identity(eye)) }), + f("Full", "np.full(100000, 1.5)", func() *ndarray.Array { return must(ndarray.Full(1.5, nvec)) }), + f("FullOf", "np.full(100000, 1.5, dtype=np.float32)", func() *ndarray.Array { return must(ndarray.FullOf(ndarray.Float32, 1.5, nvec)) }), + f("Linspace", "np.linspace(0.0, 1.0, 100000)", func() *ndarray.Array { return must(ndarray.Linspace(0, 1, nvec)) }), + f("New", "np.zeros(100000)", func() *ndarray.Array { return must(ndarray.New(nvec)) }), + f("Zeros", "np.zeros(100000)", func() *ndarray.Array { return must(ndarray.Zeros(nvec)) }), + f("Ones", "np.ones(100000)", func() *ndarray.Array { return must(ndarray.Ones(nvec)) }), + f("OnesOf", "np.ones(100000, dtype=np.float32)", func() *ndarray.Array { return must(ndarray.OnesOf(ndarray.Float32, nvec)) }), + f("ZerosOf", "np.zeros(100000, dtype=np.float32)", func() *ndarray.Array { return must(ndarray.ZerosOf(ndarray.Float32, nvec)) }), + f("Scalar", "np.array(1.5)", func() *ndarray.Array { return ndarray.Scalar(1.5) }), + {name: "FromData", fn: "FromData", ref: "numpy np.asarray (wraps, no copy)", args: []ds{dX}, call: "np.asarray", + body: func(a []*ndarray.Array) func() { + d, _ := ndarray.Data[float64](a[0]) + return func() { sinkA = must(ndarray.FromData(d, nvec)) } + }}, + {name: "FromSlice", fn: "FromSlice", ref: "numpy np.asarray (wraps, no copy)", args: []ds{dX}, call: "np.asarray", + body: func(a []*ndarray.Array) func() { + d, _ := ndarray.Data[float64](a[0]) + return func() { sinkA = must(ndarray.FromSlice(d, nvec)) } + }}, + } +} diff --git a/bench/rows_fft_test.go b/bench/rows_fft_test.go new file mode 100644 index 0000000..f97b8b9 --- /dev/null +++ b/bench/rows_fft_test.go @@ -0,0 +1,70 @@ +package bench + +import ( + "github.com/go-ndarray/ndarray" + "github.com/go-ndarray/ndarray/fft" +) + +// FFT rows. The reference is numpy.fft (pocketfft), which is what ndarray/fft +// documents itself against; go-fft/fft carries the scipy.fft comparison. The +// plan cost is inside both sides: ndarray/fft builds or fetches a plan per call, +// numpy.fft keeps its own plan cache warm after calibration. go-fft reads +// GOMAXPROCS once at init (go-fft/fft#107): run with GOMAXPROCS=1 in the +// environment. + +const ( + f1 = 65_536 // 1-D length + f2 = 256 // 2-D side + f3 = 32 // 3-D side +) + +var ( + fReal1 = dvec(-1, 1, f1, 0) + fCplx1 = dcplx(f1) + fCplxH1 = dcplx(f1/2 + 1) // the half spectrum IRFFT and HFFT take + fReal2 = dmat(-1, 1, f2, f2, 0) + fCplx2 = dcplx(f2, f2) + fCplxH2 = dcplx(f2, f2/2+1) + fReal3 = dreshape(dvec(-1, 1, f3*f3*f3, 0), f3, f3, f3) + fCplx3 = dcplx(f3, f3, f3) + fCplxH3 = dcplx(f3, f3, f3/2+1) +) + +func fftRows() []row { + var rs []row + add := func(name, py string, in ds, g func(a *ndarray.Array) (*ndarray.Array, error), gw func(a *ndarray.Array, o fft.Options) (*ndarray.Array, error)) { + rs = append(rs, row{name: "fft." + name, fn: "fft." + name, ref: "numpy np.fft." + py, args: []ds{in}, + call: "np.fft." + py, + body: func(a []*ndarray.Array) func() { return func() { sinkA = must(g(a[0])) } }}) + rs = append(rs, row{name: "fft." + name + "With", fn: "fft." + name + "With", ref: "numpy np.fft." + py + "(norm='ortho')", args: []ds{in}, + call: "lambda x: np.fft." + py + "(x, norm='ortho')", + body: func(a []*ndarray.Array) func() { + return func() { sinkA = must(gw(a[0], fft.Options{Norm: fft.Ortho})) } + }}) + } + add("FFT", "fft", fCplx1, fft.FFT, fft.FFTWith) + add("IFFT", "ifft", fCplx1, fft.IFFT, fft.IFFTWith) + add("RFFT", "rfft", fReal1, fft.RFFT, fft.RFFTWith) + add("IRFFT", "irfft", fCplxH1, fft.IRFFT, fft.IRFFTWith) + add("HFFT", "hfft", fCplxH1, fft.HFFT, fft.HFFTWith) + add("IHFFT", "ihfft", fReal1, fft.IHFFT, fft.IHFFTWith) + add("FFT2", "fft2", fCplx2, fft.FFT2, fft.FFT2With) + add("IFFT2", "ifft2", fCplx2, fft.IFFT2, fft.IFFT2With) + add("RFFT2", "rfft2", fReal2, fft.RFFT2, fft.RFFT2With) + add("IRFFT2", "irfft2", fCplxH2, fft.IRFFT2, fft.IRFFT2With) + add("FFTN", "fftn", fCplx3, fft.FFTN, fft.FFTNWith) + add("IFFTN", "ifftn", fCplx3, fft.IFFTN, fft.IFFTNWith) + add("RFFTN", "rfftn", fReal3, fft.RFFTN, fft.RFFTNWith) + add("IRFFTN", "irfftn", fCplxH3, fft.IRFFTN, fft.IRFFTNWith) + rs = append(rs, + row{name: "fft.FFTShift", fn: "fft.FFTShift", ref: "numpy np.fft.fftshift", args: []ds{fReal2}, call: "np.fft.fftshift", + body: func(a []*ndarray.Array) func() { return func() { sinkA = must(fft.FFTShift(a[0])) } }}, + row{name: "fft.IFFTShift", fn: "fft.IFFTShift", ref: "numpy np.fft.ifftshift", args: []ds{fReal2}, call: "np.fft.ifftshift", + body: func(a []*ndarray.Array) func() { return func() { sinkA = must(fft.IFFTShift(a[0])) } }}, + row{name: "fft.FFTFreq", fn: "fft.FFTFreq", ref: "numpy np.fft.fftfreq", call: "lambda: np.fft.fftfreq(65536, 0.1)", + body: func([]*ndarray.Array) func() { return func() { sinkA = must(fft.FFTFreq(f1, 0.1)) } }}, + row{name: "fft.RFFTFreq", fn: "fft.RFFTFreq", ref: "numpy np.fft.rfftfreq", call: "lambda: np.fft.rfftfreq(65536, 0.1)", + body: func([]*ndarray.Array) func() { return func() { sinkA = must(fft.RFFTFreq(f1, 0.1)) } }}, + ) + return rs +} diff --git a/bench/rows_linalg_test.go b/bench/rows_linalg_test.go new file mode 100644 index 0000000..fb6d783 --- /dev/null +++ b/bench/rows_linalg_test.go @@ -0,0 +1,56 @@ +package bench + +import ( + "github.com/go-ndarray/ndarray" + "github.com/go-ndarray/ndarray/linalg" +) + +// Linear algebra on 64 x 64 matrices; the reference is numpy.linalg on +// OpenBLAS/LAPACK with one thread. +const lm = 64 + +var ( + lGen = dgen(lm) + lDom = ddom(lm) + lSPD = dspd(lm) + lRHS = dmat(0, 1, lm, lm/8, 13) +) + +// lin is one row: expr is the numpy.linalg call over the parameters params. +func lin(name, params, expr string, args []ds, body func(a []*ndarray.Array)) row { + return row{name: "linalg." + name, fn: "linalg." + name, ref: "numpy np.linalg." + expr, args: args, + call: "lambda " + params + ": np.linalg." + expr, + body: func(a []*ndarray.Array) func() { return func() { body(a) } }} +} + +func lin1(name, expr string, in ds, h func(a *ndarray.Array) (*ndarray.Array, error)) row { + return lin(name, "a", expr, []ds{in}, func(a []*ndarray.Array) { sinkA = must(h(a[0])) }) +} + +func linalgRows() []row { + two := func(x ...*ndarray.Array) { sinkAs = x } + return []row{ + lin1("Det", "det(a)", lDom, linalg.Det), + lin1("Inv", "inv(a)", lDom, linalg.Inv), + lin1("Eigvals", "eigvals(a)", lGen, linalg.Eigvals), + lin1("Pinv", "pinv(a)", lDom, linalg.Pinv), + lin1("SVDVals", "svd(a, compute_uv=False)", lDom, linalg.SVDVals), + lin1("MatrixRank", "matrix_rank(a)", lDom, linalg.MatrixRank), + lin1("Cholesky", "cholesky(a)", lSPD, func(a *ndarray.Array) (*ndarray.Array, error) { return linalg.Cholesky(a, linalg.Lower) }), + lin1("Eigvalsh", "eigvalsh(a)", lSPD, func(a *ndarray.Array) (*ndarray.Array, error) { return linalg.Eigvalsh(a, linalg.Lower) }), + lin1("Cond", "cond(a)", lDom, func(a *ndarray.Array) (*ndarray.Array, error) { return linalg.Cond(a, linalg.Ord{}) }), + lin1("Norm", "norm(a)", lDom, func(a *ndarray.Array) (*ndarray.Array, error) { return linalg.Norm(a, linalg.Ord{}, nil, false) }), + lin1("MatrixRankTol", "matrix_rank(a, tol=1e-8)", lDom, func(a *ndarray.Array) (*ndarray.Array, error) { return linalg.MatrixRankTol(a, 1e-8) }), + lin1("PinvRcond", "pinv(a, rcond=1e-10)", lDom, func(a *ndarray.Array) (*ndarray.Array, error) { return linalg.PinvRcond(a, 1e-10) }), + lin1("MatrixPower", "matrix_power(a, 5)", lDom, func(a *ndarray.Array) (*ndarray.Array, error) { return linalg.MatrixPower(a, 5) }), + lin("Eig", "a", "eig(a)", []ds{lGen}, func(a []*ndarray.Array) { w, v, err := linalg.Eig(a[0]); chk(err); two(w, v) }), + lin("Eigh", "a", "eigh(a)", []ds{lSPD}, func(a []*ndarray.Array) { w, v, err := linalg.Eigh(a[0], linalg.Lower); chk(err); two(w, v) }), + lin("QR", "a", "qr(a)", []ds{lDom}, func(a []*ndarray.Array) { q, r, err := linalg.QR(a[0], linalg.ModeReduced); chk(err); two(q, r) }), + lin("SVD", "a", "svd(a, full_matrices=False)", []ds{lDom}, func(a []*ndarray.Array) { u, s, vh, err := linalg.SVD(a[0], false); chk(err); two(u, s, vh) }), + lin("Slogdet", "a", "slogdet(a)", []ds{lDom}, func(a []*ndarray.Array) { s, l, err := linalg.Slogdet(a[0]); chk(err); two(s, l) }), + lin("Solve", "a, b", "solve(a, b)", []ds{lDom, lRHS}, func(a []*ndarray.Array) { sinkA = must(linalg.Solve(a[0], a[1])) }), + lin("Lstsq", "a, b", "lstsq(a, b, rcond=None)", []ds{lDom, lRHS}, func(a []*ndarray.Array) { r, err := linalg.Lstsq(a[0], a[1]); chk(err); sinkA = r.X }), + lin("LstsqRcond", "a, b", "lstsq(a, b, rcond=1e-10)", []ds{lDom, lRHS}, func(a []*ndarray.Array) { r, err := linalg.LstsqRcond(a[0], a[1], 1e-10); chk(err); sinkA = r.X }), + lin("MultiDot", "a, b, c", "multi_dot([a, b, c])", []ds{lDom, lSPD, lGen}, func(a []*ndarray.Array) { sinkA = must(linalg.MultiDot(a...)) }), + } +} diff --git a/bench/rows_random_test.go b/bench/rows_random_test.go new file mode 100644 index 0000000..d5661e1 --- /dev/null +++ b/bench/rows_random_test.go @@ -0,0 +1,279 @@ +package bench + +import ( + "fmt" + + "github.com/go-ndarray/ndarray" + "github.com/go-ndarray/ndarray/random" +) + +// Random rows. Each draws 100 000 values (20 000 vectors for Dirichlet and +// Multinomial) from a generator built once outside the timed loop, on both +// sides: numpy.random.default_rng(1) / random.DefaultRNG(1) for Generator, and +// np.random.RandomState(1) / random.NewRandomState(1) for the legacy +// RandomState, np.random.seed(1) for the package-level functions. The streams +// are numpy's own, bit for bit (random/numpy_test.go); only speed is compared. + +const ( + rn = 100_000 + rv = 20_000 +) + +var dChoice = dvec(0, 1, 1000, 0) + +// gen is a Generator row: py is the draw as a method call on g. +func gen(name, py string, body func(g *random.Generator) *ndarray.Array) row { + return row{name: "random.Generator." + name, fn: "random.Generator." + name, ref: "numpy Generator." + py, + call: "(lambda g: lambda: g." + py + ")(np.random.default_rng(1))", + body: func([]*ndarray.Array) func() { + g := random.DefaultRNG(1) + return func() { sinkA = body(g) } + }} +} + +func gv(a *ndarray.Array, err error) *ndarray.Array { return must(a, err) } + +func genRows() []row { + rs := []row{ + gen("Beta", "beta(2.0, 3.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Beta(2, 3, rn)) }), + gen("Binomial", "binomial(100, 0.3, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Binomial(100, 0.3, rn)) }), + gen("ChiSquare", "chisquare(3.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.ChiSquare(3, rn)) }), + gen("ChoiceN", "choice(1000, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.ChoiceN(1000, random.ChoiceOptions{}, rn)) }), + gen("Dirichlet", "dirichlet([1.0, 2.0, 3.0], 20000)", func(g *random.Generator) *ndarray.Array { return gv(g.Dirichlet([]float64{1, 2, 3}, rv)) }), + gen("Exponential", "exponential(1.5, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Exponential(1.5, rn)) }), + gen("F", "f(5.0, 10.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.F(5, 10, rn)) }), + gen("Gamma", "gamma(2.0, 1.5, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Gamma(2, 1.5, rn)) }), + gen("Geometric", "geometric(0.2, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Geometric(0.2, rn)) }), + gen("Gumbel", "gumbel(0.0, 1.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Gumbel(0, 1, rn)) }), + gen("Integers", "integers(0, 1000, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Integers(0, 1000, rn)) }), + gen("IntegersOf", "integers(0, 1000, 100000, dtype=np.int32)", func(g *random.Generator) *ndarray.Array { + return gv(g.IntegersOf(ndarray.Int32, 0, 1000, false, rn)) + }), + gen("IntegersUint64", "integers(0, 1000, 100000, dtype=np.uint64)", func(g *random.Generator) *ndarray.Array { + return gv(g.IntegersUint64(0, 1000, false, rn)) + }), + gen("Laplace", "laplace(0.0, 1.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Laplace(0, 1, rn)) }), + gen("Logistic", "logistic(0.0, 1.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Logistic(0, 1, rn)) }), + gen("Lognormal", "lognormal(0.0, 1.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Lognormal(0, 1, rn)) }), + gen("Logseries", "logseries(0.6, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Logseries(0.6, rn)) }), + gen("Multinomial", "multinomial(100, [0.2, 0.3, 0.5], 20000)", func(g *random.Generator) *ndarray.Array { + return gv(g.Multinomial(100, []float64{0.2, 0.3, 0.5}, rv)) + }), + gen("NegativeBinomial", "negative_binomial(5.0, 0.5, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.NegativeBinomial(5, 0.5, rn)) }), + gen("NoncentralChiSquare", "noncentral_chisquare(3.0, 2.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.NoncentralChiSquare(3, 2, rn)) }), + gen("NoncentralF", "noncentral_f(5.0, 10.0, 2.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.NoncentralF(5, 10, 2, rn)) }), + gen("Normal", "normal(0.0, 1.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Normal(0, 1, rn)) }), + gen("Pareto", "pareto(3.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Pareto(3, rn)) }), + gen("Permutation", "permutation(100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Permutation(rn)) }), + gen("Poisson", "poisson(5.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Poisson(5, rn)) }), + gen("Power", "power(3.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Power(3, rn)) }), + gen("Random", "random(100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Random(rn)) }), + gen("RandomOf", "random(100000, dtype=np.float32)", func(g *random.Generator) *ndarray.Array { return gv(g.RandomOf(ndarray.Float32, rn)) }), + gen("Rayleigh", "rayleigh(1.5, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Rayleigh(1.5, rn)) }), + gen("StandardCauchy", "standard_cauchy(100000)", func(g *random.Generator) *ndarray.Array { return gv(g.StandardCauchy(rn)) }), + gen("StandardExponential", "standard_exponential(100000)", func(g *random.Generator) *ndarray.Array { return gv(g.StandardExponential(rn)) }), + gen("StandardExponentialOf", "standard_exponential(100000, dtype=np.float32, method='zig')", func(g *random.Generator) *ndarray.Array { + return gv(g.StandardExponentialOf(ndarray.Float32, "zig", rn)) + }), + gen("StandardGamma", "standard_gamma(2.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.StandardGamma(2, rn)) }), + gen("StandardGammaOf", "standard_gamma(2.0, 100000, dtype=np.float32)", func(g *random.Generator) *ndarray.Array { + return gv(g.StandardGammaOf(ndarray.Float32, 2, rn)) + }), + gen("StandardNormal", "standard_normal(100000)", func(g *random.Generator) *ndarray.Array { return gv(g.StandardNormal(rn)) }), + gen("StandardNormalOf", "standard_normal(100000, dtype=np.float32)", func(g *random.Generator) *ndarray.Array { + return gv(g.StandardNormalOf(ndarray.Float32, rn)) + }), + gen("StandardT", "standard_t(5.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.StandardT(5, rn)) }), + gen("Triangular", "triangular(0.0, 1.0, 3.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Triangular(0, 1, 3, rn)) }), + gen("Uniform", "uniform(0.0, 1.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Uniform(0, 1, rn)) }), + gen("VonMises", "vonmises(0.0, 2.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.VonMises(0, 2, rn)) }), + gen("Wald", "wald(1.0, 2.0, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Wald(1, 2, rn)) }), + gen("Weibull", "weibull(1.5, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Weibull(1.5, rn)) }), + gen("Zipf", "zipf(2.5, 100000)", func(g *random.Generator) *ndarray.Array { return gv(g.Zipf(2.5, rn)) }), + } + // Rows that take an array. + withArr := func(name, py string, body func(g *random.Generator, a *ndarray.Array) *ndarray.Array) row { + return row{name: "random.Generator." + name, fn: "random.Generator." + name, ref: "numpy Generator." + py, args: []ds{dChoice}, + call: "(lambda g: lambda x: g." + py + ")(np.random.default_rng(1))", + body: func(a []*ndarray.Array) func() { + g := random.DefaultRNG(1) + return func() { sinkA = body(g, a[0]) } + }} + } + rs = append(rs, + withArr("Choice", "choice(x, 100000)", func(g *random.Generator, a *ndarray.Array) *ndarray.Array { + return gv(g.Choice(a, random.ChoiceOptions{}, rn)) + }), + withArr("PermutationOf", "permutation(x, axis=0)", func(g *random.Generator, a *ndarray.Array) *ndarray.Array { return gv(g.PermutationOf(a, 0)) }), + withArr("Permuted", "permuted(x)", func(g *random.Generator, a *ndarray.Array) *ndarray.Array { return gv(g.Permuted(a)) }), + withArr("PermutedAxis", "permuted(x, axis=0)", func(g *random.Generator, a *ndarray.Array) *ndarray.Array { return gv(g.PermutedAxis(a, 0)) }), + // Shuffle is in place, on a copy private to each side. + row{name: "random.Generator.Shuffle", fn: "random.Generator.Shuffle", ref: "numpy Generator.shuffle (in place)", args: []ds{dChoice}, + call: "(lambda g: lambda x: g.shuffle(x.copy()))(np.random.default_rng(1))", + body: func(a []*ndarray.Array) func() { + g := random.DefaultRNG(1) + return func() { chk(g.Shuffle(a[0].Copy(), 0)) } + }}, + row{name: "random.Generator.ShuffleInt64", fn: "random.Generator.ShuffleInt64", ref: "numpy Generator.shuffle of 100000 int64 (in place)", + call: "(lambda g, x: lambda: g.shuffle(x))(np.random.default_rng(1), np.arange(100000, dtype=np.int64))", + body: func([]*ndarray.Array) func() { + g := random.DefaultRNG(1) + d := make([]int64, rn) + for i := range d { + d[i] = int64(i) + } + return func() { g.ShuffleInt64(d) } + }}, + row{name: "random.Generator.FillIntN", fn: "random.Generator.FillIntN", ref: "numpy Generator.integers(0, 1000, 100000) (allocates; Go fills dst)", + call: "(lambda g: lambda: g.integers(0, 1000, 100000))(np.random.default_rng(1))", + body: func([]*ndarray.Array) func() { + g := random.DefaultRNG(1) + d := make([]int64, rn) + return func() { chk(g.FillIntN(d, 1000)) } + }}, + row{name: "random.Generator.FillUniform", fn: "random.Generator.FillUniform", ref: "numpy Generator.uniform(0, 1, 100000) (allocates; Go fills dst)", + call: "(lambda g: lambda: g.uniform(0.0, 1.0, 100000))(np.random.default_rng(1))", + body: func([]*ndarray.Array) func() { + g := random.DefaultRNG(1) + d := make([]float64, rn) + return func() { chk(g.FillUniform(d, 0, 1)) } + }}, + row{name: "random.Generator.Bytes", fn: "random.Generator.Bytes", ref: "numpy Generator.bytes(1000000)", + call: "(lambda g: lambda: g.bytes(1000000))(np.random.default_rng(1))", + body: func([]*ndarray.Array) func() { + g := random.DefaultRNG(1) + return func() { sinkI = len(g.Bytes(1_000_000)) } + }}, + ) + return rs +} + +// rs is a RandomState row: np.random.RandomState(1).. +func rst(name, py string, body func(r *random.RandomState) *ndarray.Array) row { + return row{name: "random.RandomState." + name, fn: "random.RandomState." + name, ref: "numpy RandomState." + py, + call: "(lambda r: lambda: r." + py + ")(np.random.RandomState(1))", + body: func([]*ndarray.Array) func() { + r := random.NewRandomState(1) + return func() { sinkA = body(r) } + }} +} + +func legacyRows() []row { + rs := []row{ + rst("Binomial", "binomial(100, 0.3, 100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.Binomial(100, 0.3, rn)) }), + rst("ChiSquare", "chisquare(3.0, 100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.ChiSquare(3, rn)) }), + rst("ChoiceN", "choice(1000, 100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.ChoiceN(1000, random.ChoiceOptions{}, rn)) }), + rst("Exponential", "exponential(1.5, 100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.Exponential(1.5, rn)) }), + rst("Gamma", "gamma(2.0, 1.5, 100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.Gamma(2, 1.5, rn)) }), + rst("Lognormal", "lognormal(0.0, 1.0, 100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.Lognormal(0, 1, rn)) }), + rst("Multinomial", "multinomial(100, [0.2, 0.3, 0.5], 20000)", func(r *random.RandomState) *ndarray.Array { + return gv(r.Multinomial(100, []float64{0.2, 0.3, 0.5}, rv)) + }), + rst("Normal", "normal(0.0, 1.0, 100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.Normal(0, 1, rn)) }), + rst("Permutation", "permutation(100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.Permutation(rn)) }), + rst("Poisson", "poisson(5.0, 100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.Poisson(5, rn)) }), + rst("Rand", "rand(100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.Rand(rn)) }), + rst("Randint", "randint(0, 1000, 100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.Randint(0, 1000, rn)) }), + rst("RandintOf", "randint(0, 1000, 100000, dtype=np.int32)", func(r *random.RandomState) *ndarray.Array { + return gv(r.RandintOf(ndarray.Int32, 0, 1000, rn)) + }), + rst("Randn", "randn(100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.Randn(rn)) }), + rst("RandomSample", "random_sample(100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.RandomSample(rn)) }), + rst("StandardExponential", "standard_exponential(100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.StandardExponential(rn)) }), + rst("StandardGamma", "standard_gamma(2.0, 100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.StandardGamma(2, rn)) }), + rst("StandardNormal", "standard_normal(100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.StandardNormal(rn)) }), + rst("Uniform", "uniform(0.0, 1.0, 100000)", func(r *random.RandomState) *ndarray.Array { return gv(r.Uniform(0, 1, rn)) }), + } + rs = append(rs, + row{name: "random.RandomState.Choice", fn: "random.RandomState.Choice", ref: "numpy RandomState.choice(x, 100000)", args: []ds{dChoice}, + call: "(lambda r: lambda x: r.choice(x, 100000))(np.random.RandomState(1))", + body: func(a []*ndarray.Array) func() { + r := random.NewRandomState(1) + return func() { sinkA = gv(r.Choice(a[0], random.ChoiceOptions{}, rn)) } + }}, + row{name: "random.RandomState.PermutationOf", fn: "random.RandomState.PermutationOf", ref: "numpy RandomState.permutation(x)", args: []ds{dChoice}, + call: "(lambda r: lambda x: r.permutation(x))(np.random.RandomState(1))", + body: func(a []*ndarray.Array) func() { + r := random.NewRandomState(1) + return func() { sinkA = gv(r.PermutationOf(a[0])) } + }}, + row{name: "random.RandomState.Shuffle", fn: "random.RandomState.Shuffle", ref: "numpy RandomState.shuffle (in place, on a copy)", args: []ds{dChoice}, + call: "(lambda r: lambda x: r.shuffle(x.copy()))(np.random.RandomState(1))", + body: func(a []*ndarray.Array) func() { + r := random.NewRandomState(1) + return func() { chk(r.Shuffle(a[0].Copy())) } + }}, + row{name: "random.RandomState.ShuffleInt64", fn: "random.RandomState.ShuffleInt64", ref: "numpy RandomState.shuffle of 100000 int64 (in place)", + call: "(lambda r, x: lambda: r.shuffle(x))(np.random.RandomState(1), np.arange(100000, dtype=np.int64))", + body: func([]*ndarray.Array) func() { + r := random.NewRandomState(1) + d := make([]int64, rn) + return func() { r.ShuffleInt64(d) } + }}, + row{name: "random.RandomState.FillIntN", fn: "random.RandomState.FillIntN", ref: "numpy RandomState.randint(0, 1000, 100000) (allocates; Go fills dst)", + call: "(lambda r: lambda: r.randint(0, 1000, 100000))(np.random.RandomState(1))", + body: func([]*ndarray.Array) func() { + r := random.NewRandomState(1) + d := make([]int64, rn) + return func() { chk(r.FillIntN(d, 1000)) } + }}, + row{name: "random.RandomState.FillUniform", fn: "random.RandomState.FillUniform", ref: "numpy RandomState.uniform(0, 1, 100000) (allocates; Go fills dst)", + call: "(lambda r: lambda: r.uniform(0.0, 1.0, 100000))(np.random.RandomState(1))", + body: func([]*ndarray.Array) func() { + r := random.NewRandomState(1) + d := make([]float64, rn) + return func() { chk(r.FillUniform(d, 0, 1)) } + }}, + row{name: "random.RandomState.Bytes", fn: "random.RandomState.Bytes", ref: "numpy RandomState.bytes(1000000)", + call: "(lambda r: lambda: r.bytes(1000000))(np.random.RandomState(1))", + body: func([]*ndarray.Array) func() { + r := random.NewRandomState(1) + return func() { sinkI = len(r.Bytes(1_000_000)) } + }}, + ) + return rs +} + +// pkg is a package-level function: np.random. on the shared legacy state. +func pkg(name, py string, body func() *ndarray.Array) row { + return row{name: "random." + name, fn: "random." + name, ref: "numpy np.random." + py, + call: "(lambda _: lambda: np.random." + py + ")(np.random.seed(1))", + body: func([]*ndarray.Array) func() { + random.Seed(1) + return func() { sinkA = body() } + }} +} + +func packageRows() []row { + return []row{ + pkg("Binomial", "binomial(100, 0.3, 100000)", func() *ndarray.Array { return gv(random.Binomial(100, 0.3, rn)) }), + pkg("Normal", "normal(0.0, 1.0, 100000)", func() *ndarray.Array { return gv(random.Normal(0, 1, rn)) }), + pkg("Permutation", "permutation(100000)", func() *ndarray.Array { return gv(random.Permutation(rn)) }), + pkg("Poisson", "poisson(5.0, 100000)", func() *ndarray.Array { return gv(random.Poisson(5, rn)) }), + pkg("Rand", "rand(100000)", func() *ndarray.Array { return gv(random.Rand(rn)) }), + pkg("Randint", "randint(0, 1000, 100000)", func() *ndarray.Array { return gv(random.Randint(0, 1000, rn)) }), + pkg("Randn", "randn(100000)", func() *ndarray.Array { return gv(random.Randn(rn)) }), + pkg("RandomSample", "random_sample(100000)", func() *ndarray.Array { return gv(random.RandomSample(rn)) }), + pkg("Uniform", "uniform(0.0, 1.0, 100000)", func() *ndarray.Array { return gv(random.Uniform(0, 1, rn)) }), + {name: "random.Choice", fn: "random.Choice", ref: "numpy np.random.choice(x, 100000)", args: []ds{dChoice}, + call: "(lambda _: lambda x: np.random.choice(x, 100000))(np.random.seed(1))", + body: func(a []*ndarray.Array) func() { + random.Seed(1) + return func() { sinkA = gv(random.Choice(a[0], random.ChoiceOptions{}, rn)) } + }}, + {name: "random.Shuffle", fn: "random.Shuffle", ref: "numpy np.random.shuffle (in place, on a copy)", args: []ds{dChoice}, + call: "(lambda _: lambda x: np.random.shuffle(x.copy()))(np.random.seed(1))", + body: func(a []*ndarray.Array) func() { + random.Seed(1) + return func() { chk(random.Shuffle(a[0].Copy())) } + }}, + {name: "random.RandomRaw", fn: "random.RandomRaw", ref: "numpy PCG64.random_raw(100000)", + call: "(lambda b: lambda: b.random_raw(100000))(np.random.PCG64(1))", + body: func([]*ndarray.Array) func() { + bg := random.NewPCG64(1) + return func() { sinkI = len(random.RandomRaw(bg, rn)) } + }}, + } +} + +var _ = fmt.Sprint diff --git a/docs/perf-exempt.md b/docs/perf-exempt.md new file mode 100644 index 0000000..36a194c --- /dev/null +++ b/docs/perf-exempt.md @@ -0,0 +1,121 @@ +# Public functions without a refbench case + +Each is named with the reason it has no case (refbench's coverage rule). Every other public function has a row in `perf-table.md`. + +- `A`: builds an Index value (a few words); NumPy has no counterpart +- `All`: builds an Index value (a few words); NumPy has no counterpart +- `Array.AtComplex`: one scalar element access, a few ns; the NumPy counterpart is dominated by the Python interpreter, not a kernel +- `Array.At`: one scalar element access, a few ns; the NumPy counterpart is dominated by the Python interpreter, not a kernel +- `Array.DType`: attribute read +- `Array.Detach`: leaves a Workspace; NumPy has no counterpart +- `Array.Display`: builds a Jupyter MIME bundle; NumPy has no counterpart +- `Array.Map`: takes a Go callback per element; the NumPy counterpart (np.vectorize) is a Python loop, not a kernel +- `Array.Ndim`: attribute read +- `Array.SetComplex`: one scalar element access, a few ns; the NumPy counterpart is dominated by the Python interpreter, not a kernel +- `Array.Set`: one scalar element access, a few ns; the NumPy counterpart is dominated by the Python interpreter, not a kernel +- `Array.Shape`: attribute read +- `Array.Size`: attribute read +- `DType.IsBool`: a one-instruction property of a DType; no NumPy call to compare +- `DType.IsComplex`: a one-instruction property of a DType; no NumPy call to compare +- `DType.IsFloat`: a one-instruction property of a DType; no NumPy call to compare +- `DType.IsInteger`: a one-instruction property of a DType; no NumPy call to compare +- `DType.IsSigned`: a one-instruction property of a DType; no NumPy call to compare +- `DType.Size`: a one-instruction property of a DType; no NumPy call to compare +- `Data`: accessor or dtype lookup, no array work (ResultType is checked cell by cell against numpy.result_type in the tests) +- `From`: builds an Index value (a few words); NumPy has no counterpart +- `Item`: accessor or dtype lookup, no array work (ResultType is checked cell by cell against numpy.result_type in the tests) +- `NewWorkspace`: the Go arena for results (no NumPy counterpart); its effect shows in the allocation counts of the other rows +- `ParseDType`: accessor or dtype lookup, no array work (ResultType is checked cell by cell against numpy.result_type in the tests) +- `R`: builds an Index value (a few words); NumPy has no counterpart +- `ResultType`: accessor or dtype lookup, no array work (ResultType is checked cell by cell against numpy.result_type in the tests) +- `Rng`: builds an Index value (a few words); NumPy has no counterpart +- `Step`: builds an Index value (a few words); NumPy has no counterpart +- `To`: builds an Index value (a few words); NumPy has no counterpart +- `Workspace.Reset`: the Go arena for results (no NumPy counterpart); its effect shows in the allocation counts of the other rows +- `Workspace.Use`: the Go arena for results (no NumPy counterpart); its effect shows in the allocation counts of the other rows +- `linalg.P`: builds an Ord value +- `random.DefaultRNG`: constructor, one-off cost paid once per generator, not per draw +- `random.Generator.BitGenerator`: accessor +- `random.Generator.Float64`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.Generator.IntN`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.Generator.Spawn`: derives a new generator, once per stream; no draw loop +- `random.MT19937.Float64`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.MT19937.Jumped`: derives a new generator, once per stream; no draw loop +- `random.MT19937.Raw`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.MT19937.SeedLegacyArray`: seeding or state transfer, once per generator; no draw loop +- `random.MT19937.SeedLegacy`: seeding or state transfer, once per generator; no draw loop +- `random.MT19937.SeedSeq`: seeding or state transfer, once per generator; no draw loop +- `random.MT19937.SetState`: seeding or state transfer, once per generator; no draw loop +- `random.MT19937.Spawn`: derives a new generator, once per stream; no draw loop +- `random.MT19937.State`: seeding or state transfer, once per generator; no draw loop +- `random.MT19937.Uint32`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.MT19937.Uint64`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.NewGenerator`: constructor, one-off cost paid once per generator, not per draw +- `random.NewMT19937FromSeedSequence`: constructor, one-off cost paid once per generator, not per draw +- `random.NewMT19937Legacy`: constructor, one-off cost paid once per generator, not per draw +- `random.NewMT19937`: constructor, one-off cost paid once per generator, not per draw +- `random.NewPCG64DXSMFromSeedSequence`: constructor, one-off cost paid once per generator, not per draw +- `random.NewPCG64DXSM`: constructor, one-off cost paid once per generator, not per draw +- `random.NewPCG64FromSeedSequence`: constructor, one-off cost paid once per generator, not per draw +- `random.NewPCG64`: constructor, one-off cost paid once per generator, not per draw +- `random.NewPhiloxFromSeedSequence`: constructor, one-off cost paid once per generator, not per draw +- `random.NewPhiloxKey`: constructor, one-off cost paid once per generator, not per draw +- `random.NewPhilox`: constructor, one-off cost paid once per generator, not per draw +- `random.NewRandomStateArray`: constructor, one-off cost paid once per generator, not per draw +- `random.NewRandomStateEntropy`: constructor, one-off cost paid once per generator, not per draw +- `random.NewRandomStateFrom`: constructor, one-off cost paid once per generator, not per draw +- `random.NewRandomState`: constructor, one-off cost paid once per generator, not per draw +- `random.NewSFC64FromSeedSequence`: constructor, one-off cost paid once per generator, not per draw +- `random.NewSFC64`: constructor, one-off cost paid once per generator, not per draw +- `random.NewSeedSequenceBig`: constructor, one-off cost paid once per generator, not per draw +- `random.NewSeedSequenceWith`: constructor, one-off cost paid once per generator, not per draw +- `random.NewSeedSequence`: constructor, one-off cost paid once per generator, not per draw +- `random.PCG64.Advance`: derives a new generator, once per stream; no draw loop +- `random.PCG64.Float64`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.PCG64.Jumped`: derives a new generator, once per stream; no draw loop +- `random.PCG64.Raw`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.PCG64.Spawn`: derives a new generator, once per stream; no draw loop +- `random.PCG64.Uint32`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.PCG64.Uint64`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.PCG64DXSM.Advance`: derives a new generator, once per stream; no draw loop +- `random.PCG64DXSM.Float64`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.PCG64DXSM.Jumped`: derives a new generator, once per stream; no draw loop +- `random.PCG64DXSM.Raw`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.PCG64DXSM.Spawn`: derives a new generator, once per stream; no draw loop +- `random.PCG64DXSM.Uint32`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.PCG64DXSM.Uint64`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.Philox.Advance`: derives a new generator, once per stream; no draw loop +- `random.Philox.Float64`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.Philox.Jumped`: derives a new generator, once per stream; no draw loop +- `random.Philox.Raw`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.Philox.SeedSeq`: seeding or state transfer, once per generator; no draw loop +- `random.Philox.SetState`: seeding or state transfer, once per generator; no draw loop +- `random.Philox.Spawn`: derives a new generator, once per stream; no draw loop +- `random.Philox.State`: seeding or state transfer, once per generator; no draw loop +- `random.Philox.Uint32`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.Philox.Uint64`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.RandomState.BitGenerator`: seeding or state transfer, once per generator; no draw loop +- `random.RandomState.Float64`: seeding or state transfer, once per generator; no draw loop +- `random.RandomState.GetState`: seeding or state transfer, once per generator; no draw loop +- `random.RandomState.IntN`: seeding or state transfer, once per generator; no draw loop +- `random.RandomState.SeedArray`: seeding or state transfer, once per generator; no draw loop +- `random.RandomState.Seed`: seeding or state transfer, once per generator; no draw loop +- `random.RandomState.SetState`: seeding or state transfer, once per generator; no draw loop +- `random.SFC64.Float64`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.SFC64.Raw`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.SFC64.SeedSeq`: seeding or state transfer, once per generator; no draw loop +- `random.SFC64.SetState`: seeding or state transfer, once per generator; no draw loop +- `random.SFC64.Spawn`: derives a new generator, once per stream; no draw loop +- `random.SFC64.State`: seeding or state transfer, once per generator; no draw loop +- `random.SFC64.Uint32`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.SFC64.Uint64`: one draw per call, a few ns; the bulk rows (random.RandomRaw, Generator.Random, ...) measure the same generator over 100 000 draws +- `random.SeedSequence.EntropyInt`: seeding or state transfer, once per generator; no draw loop +- `random.SeedSequence.Entropy`: seeding or state transfer, once per generator; no draw loop +- `random.SeedSequence.GenerateState64`: seeding or state transfer, once per generator; no draw loop +- `random.SeedSequence.GenerateState`: seeding or state transfer, once per generator; no draw loop +- `random.SeedSequence.NChildrenSpawned`: seeding or state transfer, once per generator; no draw loop +- `random.SeedSequence.PoolSize`: seeding or state transfer, once per generator; no draw loop +- `random.SeedSequence.Pool`: seeding or state transfer, once per generator; no draw loop +- `random.SeedSequence.SpawnKey`: seeding or state transfer, once per generator; no draw loop +- `random.SeedSequence.Spawn`: seeding or state transfer, once per generator; no draw loop +- `random.Seed`: seeding or state transfer, once per generator; no draw loop diff --git a/docs/perf-history.md b/docs/perf-history.md new file mode 100644 index 0000000..3e18816 --- /dev/null +++ b/docs/perf-history.md @@ -0,0 +1,1821 @@ +# Performance — go-ndarray vs NumPy (history, not a verdict) + +> **This page is kept for its kernel-level analysis only.** It compares +> multicore Go with single-threaded NumPy 2.2.4, which the go-scikit one-core +> rule does not allow (go-ndarray/ndarray#93). Its ratios are not a verdict. +> The measurement is now [perf.md](perf.md) and [perf-table.md](perf-table.md). + +Honest, reproducible head-to-head of `go-ndarray/ndarray` against **NumPy +2.2.4** on identical hardware. "On n'a pas le droit de se tromper": every number +here is measured, every win is real, and where NumPy still leads it says so. + +## How a pure-Go library can beat NumPy + +NumPy's elementwise and reduction ufuncs (`+`, `*`, `sqrt`, `sum`, `max`, …) are +**single-threaded SIMD C**. So a pure-Go library wins them by combining two +levers NumPy does not use for these ops: + +1. **Multicore** — fan the contiguous loop across `GOMAXPROCS` goroutines above a + size threshold (small arrays stay serial so scheduling never dominates). +2. **SIMD via [go-asmgen](https://github.com/go-asmgen)** — for the sum + reduction, whose scalar `s += v` loop the Go compiler will not auto-vectorize + (it would change FP grouping), a hand-written NEON/SSE2 kernel with four + independent accumulators recovers the lane parallelism NumPy's C has. + +A third lever — **eliminating allocation** — turned out to be the single biggest +factor: the old elementwise path materialised two full broadcast copies *and* a +destination per op (3×N). The same-shape contiguous fast path now passes the +operands' live backing slices to the kernel and allocates only the result. + +NumPy's `matmul` is BLAS, a different fight: go-ndarray answers it with its own +**panel-packed, cache-blocked GEMM** + go-asmgen SIMD-FMA micro-kernel (the +OpenBLAS/BLIS structure). With the by-lane-FMLA micro-kernel it reaches **parity +with tuned multi-threaded OpenBLAS 0.3.29 at n=1024 (≈0.99×, ~203 GFLOP/s)** and +~0.97× at n=512; only small n=256 still trails (per-call overhead). See the +matmul section below. + +## Test bench + +- **Machine**: debian arm64 (Apple-silicon Tart VM), 4 vCPU. +- **NumPy**: 2.2.4. Elementwise/reduction rows use Debian **reference BLAS + 3.12.1** (irrelevant to those ufuncs). The matmul rows are measured against + **OpenBLAS 0.3.29 (pthread, multi-threaded)** — the tuned BLAS — selected via + `update-alternatives --set libblas.so.3 …/openblas-pthread/libblas.so.3` and + confirmed loaded by reading `/proc/self/maps` (libopenblasp-r0.3.29.so). This + is the meaningful bar: the earlier reference-BLAS matmul comparison is kept only + as a footnote. +- **Go**: 1.26.4, `CGO_ENABLED=0`, `GOWORK=off`. +- **go-ndarray bench**: `go test -bench=. -benchtime=1s` (`bench_test.go`), + ns/op. Multi-core = default `GOMAXPROCS=4`; single-core = `GOMAXPROCS=1`. +- **NumPy bench**: `docs/bench_numpy.py` (matched ops/sizes), min-of-7 ns/op. + +Reproduce: rsync the repo to the VM, then +`go test -bench=. -benchtime=1s .` and `python3 docs/bench_numpy.py`. + +## Elementwise and reductions (ns/op; lower is better) + +`go ×` is NumPy ÷ go-ndarray: **> 1 means go-ndarray wins.** Two go forms are +shown for the binary/sqrt ops: + +- **alloc** — `a + b` / `np.sqrt(a)`: returns a fresh array (NumPy `x + y`). +- **into** — `a.AddInto(out, b)` / `a.SqrtInto(out)`: writes a caller-supplied + buffer, the no-allocation form (NumPy `np.add(x, y, out=z)` / `np.sqrt(x, out=z)`). + This is the apples-to-apples small-N comparison: it removes the per-op result + allocation, which is the *only* thing NumPy beats us on at small n (see below). + +| op | n | NumPy 2.2.4 | go alloc | go into | go × alloc | go × into | +|------|------:|------:|------:|------:|:--:|:--:| +| Add | 1 024 | 432 | 1 049 | **148** | 0.41 (numpy) | **2.41** | +| Add | 4 194 304 | 1 600 974 | **768 722** | **429 410** | **2.08** | **2.10** | +| Mul | 1 024 | 424 | 978 | **168** | 0.43 (numpy) | **2.08** | +| Mul | 4 194 304 | 1 572 361 | **763 339** | **435 109** | **2.06** | **1.81** | +| Sqrt | 1 024 | 496 | 1 299 | **263** | 0.38 (numpy) | **1.80** | +| Sqrt | 4 194 304 | 1 669 901 | **605 561** | **333 289** | **2.76** | **3.22** | +| Sum | 1 024 | 637 | **130** | — | **4.90** | — | +| Sum | 4 194 304 | 496 000 | **193 679** | — | **2.56** | — | +| Max | 1 024 | 611 | **160** | — | **3.83** | — | +| Max | 4 194 304 | 326 466 | **231 626** | — | **1.41** | — | + +(All numbers measured back-to-back in one VM session, 2026-06; go is +`-benchtime=1s`, NumPy `min`-of-runs in `docs/bench_numpy.py`. Add/Sub/Mul/Div +now run a SIMD kernel — packed SSE2 `ADD/SUB/MUL/DIVPD` on amd64, NEON `VFMLA` +on arm64 — so even the *alloc* form wins ~2× at large n. Sum/Max are reductions +with no result array, so they have no separate *into* row.) + +### Reading the table + +- **Add / Mul on large arrays: go-ndarray wins ~2×.** Allocation elimination + + multicore. Note even single-core large Mul edges out NumPy now (the kernel is + memory-bandwidth bound and the old copies were the bottleneck). +- **Sum: go-ndarray wins everywhere — up to ~5× on small, 2.4× on large.** The + SIMD 4-accumulator kernel already beats NumPy single-core (493 µs vs 506 µs at + 4 M); multicore then roughly halves it again. Small sums are dominated by + NumPy's per-call Python/dispatch overhead, which Go does not pay. +- **Sqrt: go-ndarray now WINS 1.75× at 4 M (was a ~1.3× loss).** The fix was to + stop routing `Sqrt` through `Map`'s `func(float64) float64`: that indirection + blocked *both* the Go compiler's `FSQRTD` intrinsic (arm64) and a packed SSE2 + `SQRTPD` (amd64), making the old path pay a real function call *per element* + (~2.7× slower single-core). A dedicated `sqrt` kernel behind the kernels seam — + packed `SQRTPD` on amd64, the intrinsic-lowered scalar `FSQRTD` loop on + arm64/others — plus the existing multicore fan-out is what turns the loss into + a win. The kernel is **bit-identical** to a scalar `math.Sqrt` loop (validated + per-arch and bit-for-bit against NumPy's `np.sqrt`). arm64 now also has a + **packed NEON `FSQRT V.2D`** kernel (two doubles per instruction; emitted by + `WORD` until v0.5.2, `VFSQRT` since Go 1.27 names it), replacing the prior + one-lane-at-a-time scalar `FSQRTD` loop. Small `n=1 024`: the *alloc* form loses + to NumPy on allocation cost (as Add/Mul), but `a.SqrtInto(out)` wins 1.8×. +- **Max: go-ndarray now WINS — 4.0× small, 1.34× at 4 M (was 0.62 / 0.31 + losses).** Two changes. (1) The scalar oracle was switched from `if v > m` + (which silently *ignored* NaNs, diverging from NumPy) to the **builtin + `max`/`min`**, which is NaN-propagating *and* lowers to the hardware `FMAXD`/ + `FMIND` intrinsic — `math.Max` would also propagate NaN but is an + un-intrinsified call ~12× slower in this hot loop. (2) The SIMD max/min + reduction uses **four independent accumulators** to break the dependency chain + (~3.6× over one), exactly like the sum kernel: packed `MAXPD`/`MINPD` + a NaN + scan on amd64, register-unrolled builtin `max`/`min` on arm64/others. See the + NaN convention note below. +- **Small arrays (n≈1 024) for Add/Mul/Sqrt — the *alloc* form: NumPy wins; the + *into* form: go-ndarray wins ~2×.** The entire gap is the result allocation. + `make([]float64, 1024)` measures ~900–1 080 ns on this VM's allocator (it zeroes + the 8 KiB the kernel then fully overwrites), while NumPy serves the temp from a + cached free-list for ~150 ns. So `a + b` at n=1024 is allocator-bound (≈1 µs, + of which the SIMD compute is only ~150 ns) and loses 0.4×; **`a.AddInto(out, b)` + — the no-allocation form, NumPy's `np.add(x, y, out=z)` — removes that cost and + wins 1.8–2.4× at the same size.** This is a Go-allocator ceiling, not a kernel + one: Go's `make` always zeroes and offers no unsafe "give me raw bytes" escape, + and returning pooled memory would break the caller-owns-the-result contract, so + the allocating small-N form cannot match NumPy's free-list — the `*Into` parity + path is the supported way to hit/beat NumPy there (and at large n even the alloc + form wins, because the kernel time then dwarfs the one allocation). (Sum and Max are + the exceptions — their reduction kernels need no result allocation and win even + serially.) **Superseded for loops by `Workspace`, see below.** + +### NaN convention for Max / Min (and the SIMD max kernel) + +`Max`/`Min` are **NaN-propagating**: if any element is NaN the result is NaN. +This matches `numpy.max`/`numpy.min`, Go's builtin `max`/`min`, and IEEE-754 +*maximum* (not the C `fmax` / IEEE `maxNum` "ignore NaN" rule); on signed zeros +it returns `+0` for `max(-0,+0)` and `-0` for `min(-0,+0)`, also matching NumPy. +The previous `if v > m` scalar form silently skipped NaNs (and a *leading* NaN +poisoned the scan), so it disagreed with NumPy; this is the corrected, documented +semantics, and the entire stack — scalar oracle, the four-accumulator reducer, +the amd64 `MAXPD`/`MINPD` kernel, and the multicore fold — is held **bit-for-bit +identical** to it (NaN at any position → NaN; exact extreme otherwise), validated +across every length residue and NaN placement in CI. The amd64 kernel gets this +despite `MAXPD`'s own non-propagating NaN rule by carrying a parallel +`CMPPD`-unordered OR-mask and forcing the result to NaN if any lane ever held +one — so the value `MAXPD` leaves in a NaN lane is irrelevant. + +## Matrix multiply vs TUNED BLAS (OpenBLAS) — the hard frontier + +This is the honest fight: go-ndarray's pure-Go (+ generated asm) GEMM against +**OpenBLAS 0.3.29 pthread**, decades-tuned multi-threaded assembly. Both run on +the same 4-vCPU Apple-silicon VM, back-to-back. GFLOP/s = `2·n³ / time`. + +All rows measured **back-to-back, interleaved, in the same VM session** (the only +fair way on a shared host whose load drifts), 4 samples each, `go ÷ OB` reported +as the per-sample ratio (robust to the absolute time drifting between sessions). + +| n (N×N) | go-ndarray (4 core) | OpenBLAS-mt | go GFLOP/s | OB GFLOP/s | go ÷ OB | +|------:|------:|------:|------:|------:|:--:| +| 256 | 261 000 | 174 000 | 102.8 | 154.2 | 0.67× | +| 512 | 1 530 000 | 1 500 000 | 175.4 | 178.9 | 0.97× | +| 1024 | 10 600 000 | 10 580 000 | 202.6 | 203.0 | **≈0.99× (parity; samples 0.96–1.00)** | + +**Verdict: at n=1024 the pure-Go (+ generated asm) GEMM reaches PARITY with tuned +OpenBLAS** (~0.99×, individual samples straddling 1.00×), sustaining **~203 +GFLOP/s** — up from the prior kernel's ~23 GFLOP/s and from this kernel's own 0.76× +before the by-lane-FMLA fix below. n=512 is ~0.97×; only small n=256, where fixed +per-call overhead (panel packing + the 4-way goroutine fan-out) dominates the +~250 µs of compute, still trails at 0.67× — a small-matrix overhead ceiling, not a +kernel-throughput one (the same kernel hits parity once the matrix is large enough +to amortise the setup). + +### The fix that reached parity: the by-lane FMLA + +The single change that took n=1024 from **0.76× to ≈0.99×** was the arm64 +micro-kernel's inner FMA. The kernel broadcasts one packed A value into every +column of its row; the prior version did this with `MOVD` (A double → GP register) ++ `VDUP` (GP → all lanes of a vector) before a plain vector `VFMLA` — an extra +instruction *and* a GP↔SIMD register-file round-trip per A value, every k-step. +The fix uses the indexed-element double FMLA `FMLA Vd.2D, Vn.2D, Vm.D[i]` that +every tuned arm64 dgemm uses: load the four packed A doubles of a k-step as two +`D2` vectors and have each row's FMA read its A scalar straight from a lane — no +GP detour, no `VDUP`. Go's arm64 assembler cannot *name* this indexed form +("illegal combination … ELEM"), so it is emitted by its raw 32-bit `WORD` +encoding (see `fmlaElem` in `asmgen/arm64/gen.go`, cross-checked against +`objdump`); this is still pure Go assembly, CGO=0. + +Measured on this Apple-silicon core: the L1-resident micro-kernel went **~44 → +~58 GFLOP/s/core (~1.28×)**, and the full GEMM tracked it to parity. (An earlier +note on this page claimed the indexed form was *slower* on Apple silicon — that +was wrong; it had never actually been measured against a correct encoding.) + +Two further levers were prototyped and **measured to NOT help on this core**, so +they were not shipped (documented here so they are not re-attempted blindly): +- **Software pipelining** (prefetch + double-buffer the next k-step's A/B into + shadow registers, rotate after the FMAs): ~2% on the L1 micro-kernel but **1–9% + *slower* on the full GEMM** — the M-series out-of-order window already hides the + L1/L2 load latency, and the extra `VMOV` rotations + register pressure cost more + than they save. +- **Wider 6×8 tile** (24 accumulators vs 16): same GFLOP/s as the 4×8 — the loop + is FMA-latency-bound, not register-pressure-bound, so a wider tile adds no + throughput. +- **MC/KC/NC retuning**: a 12-point sweep confirmed the shipped 256/256/512 is + already the optimum on this core. + +### What shipped: a panel-packed, cache-blocked GEMM + +The earlier register-blocked attempt read A and B straight from the source +matrices; on power-of-two row strides its destination rows collided in L1 and it +lost to the autovectorized ikj kernel at large n, so it was capped to `n ≤ 224` +and **packing was prototyped but not shipped** (the previous note here). This +round implements the full OpenBLAS/BLIS structure and ships it for **all** sizes: + +- **Packing** — A is copied into MR-tall row panels and B into NR-wide column + panels in contiguous, unit-stride scratch (pooled, allocation-free per call). + The micro-kernel then streams conflict-free memory **regardless of the source + stride** — this is the precise fix for the power-of-two L1 set-conflicts that + defeated the unpacked attempt. Edge tiles are zero-padded so the kernel always + sees a full MR×NR block; a ragged right/bottom tile runs the same micro-kernel + into a scratch tile, and only its valid corner is added to C. +- **Cache blocking** — an `NC` (columns) → `KC` (contraction) → `MC` (rows) loop + nest keeps the packed B panel L2-resident and each packed A panel in L1. + Defaults `MC=256, KC=256, NC=512` (tuned on this VM). +- **SIMD-FMA micro-kernel** (go-asmgen) — **NEON 4×8 on arm64** (16 D2 + accumulators, the source of the win), **AVX2/FMA 6×8 on amd64** (12 YMM + accumulators, picked at run time by a CPUID probe; an SSE2 6×4 pair without + FMA is the fallback), **scalar 4×4** on the + four arches without vector-double asm — which still gain from contiguous packed + data + blocking + multicore. +- **Parallelism** — the M rows are split into MR-aligned bands, one per core, + each writing a disjoint region of C. + +The arithmetic is the ikj order `dst[i][j] += a[i][p]·b[p][j]`, summed per KC +block; for k ≤ KC that is the scalar oracle's order exactly, and above it the +block partials regroup the sum (a valid reordering, as in every blocked BLAS). +At 128×128 the result matched NumPy's `A@B` against OpenBLAS with max abs diff +**0.0**. + +### Packed GEMM vs the PRIOR kernel (why it ships) + +Measured back-to-back in one binary under identical load (so the ratio is robust +to the noisy VM); the prior kernel is the register-blocked/ikj path this replaces: + +| n | prior kernel | packed | speedup | +|------:|------:|------:|:--:| +| 64 | 6–9 GF | 20–21 GF | **2.2–3.4×** | +| 128 | 16 GF | 32–52 GF | **3.1–4.2×** | +| 256 | 10–19 GF| 53–119 GF| **5.0–6.2×** | +| 512 | 13–21 GF| 80–156 GF| **6.0–7.3×** | +| 1024 | 16–22 GF| 99–156 GF| **6.2–7.2×** | + +The packed kernel wins at **every** size, so it is shipped uniformly — no +size-routing — and the old `n ≤ 224` blocked/ikj split is removed. + +### Where the remaining gap is (small n only) + +At n=1024 there is effectively **no** kernel-throughput gap left: the by-lane +FMLA inner loop runs ~58 GFLOP/s/core, the full GEMM hits ~203 GFLOP/s, and +go ÷ OB straddles 1.00×. The only sizes still short are **small** matrices, and +there the gap is *fixed per-call overhead*, not compute: + +- At n=256 the matmul is ~250 µs of real work, but every call still pays the full + panel-pack pass and a 4-way goroutine fan-out. OpenBLAS amortises that setup + far better (smaller pack cost, a thread pool that is already warm), so it leads + 0.67× here even though the per-core FMA rate is the same. As n grows the setup + is amortised away — n=512 is already 0.97×. + +This is a small-matrix overhead ceiling. Closing it further would mean a +serial-vs-parallel crossover tuned per size and a cheaper pack for tiny panels; +it is not a Go-assembler or algorithm limit, and it does not affect the +large-matrix parity result. (amd64 now has its AVX2/FMA kernel too; see the +real-hardware section below.) + +### Footnote: vs reference BLAS + +Against Debian's single-threaded **reference BLAS 3.12.1** (the prior comparison +on this page), the packed GEMM wins by a wide margin at every size — but that is +not a meaningful bar for a matmul claim, so the table above uses OpenBLAS. + +## Correctness + +Every result is validated **bit-for-bit against NumPy** (shapes and values), not +just timed — see the cross-check in the work log: `Sqrt` (bit-identical to +`np.sqrt` via a `uint64` view), `Max`, `Min` (exact, incl. NaN propagation), +`Add`, `Mul` and the full `MatMul` (zero max-abs-diff against `A @ A`) match +NumPy at n = 100 000 / 80×80. + +The SIMD kernels are also validated against the scalar oracle per-arch in CI: + +- **Sqrt** — `SQRTPD` (amd64) is held **bit-identical** to a scalar `math.Sqrt` + loop across every length residue mod 8 and the IEEE edge cases (negatives → + NaN, ±Inf, signed zeros, explicit NaN); sqrt is a single correctly-rounded + operation, so bit-identity (not mere closeness) is the contract. +- **Max / Min** — the four-accumulator reducer and the amd64 `MAXPD`/`MINPD` + kernel are held **bit-identical** to the builtin-`max`/`min` oracle across + every length and with a NaN injected at the start, middle, end, and as the sole + element (all must return NaN); ±Inf and signed zeros bit-match too. +- **Add / Sub / Mul / Div** — the elementwise SIMD kernels are held + **bit-identical** to the scalar oracle across every length residue mod 8 and the + IEEE edge inputs (signed zeros, ±Inf, NaN, extremes): each lane is a single + independent correctly-rounded IEEE op (no reduction grouping), and on arm64 the + FMA-against-an-exact-constant form (`b+a·1`, `a−b·1`, `0+a·b`) rounds identically + to the plain op, so bit-identity is the contract. +- **Sum** — validated to a tight *relative tolerance* (not bit-identity): its + lane-parallel grouping is a valid reordering, the same kind NumPy's pairwise + summation uses, so closeness — not bit-identity — is the contract for a + floating-point reduction. + +## Where go-ndarray still loses, and the plan + +| op | status | note | +|----|--------|-----| +| `Add`/`Sub`/`Mul`/`Div`/`Sqrt`, **alloc** form, small n (≈1 K) | NumPy wins (Go-allocator ceiling) | the result `make` (~900 ns zeroing 8 KiB) is the whole cost; Go has no unzeroed-alloc escape and pooling would break result ownership. The `*Into` no-alloc form **wins 1.8–2.4×** — the supported parity path. Large-n alloc form already wins ~2×. | +| `Add`/`Sub`/`Mul`/`Div` | **FIXED — go wins ~2× large n, *Into* wins all n** | new SIMD kernels: packed SSE2 `ADD/SUB/MUL/DIVPD` (amd64), NEON `VFMLA`/`VFMLS` (arm64); bit-identical to the scalar oracle | +| `Sqrt` | **FIXED — go wins 2.8× at 4 M, *Into* 1.8× small** | packed `SQRTPD` (amd64) / packed NEON `FSQRT V.2D` (arm64) / intrinsic `FSQRTD` (others), off a non-`func`-pointer seam | +| `Max` / `Min` | **FIXED — go wins 1.4× at 4 M, ~4× small** | builtin-`max` NaN-propagating oracle + 4-accumulator reducer + amd64 `MAXPD`+NaN-scan; NEON `FMAX`/`FMIN` on arm64 since v0.5.2 | +| `MatMul` vs tuned BLAS (OpenBLAS) | **FIXED — parity at n=1024 (~0.99×, ~203 GFLOP/s); 0.97× at n=512** | by-lane FMLA micro-kernel (`FMLA Vd.2D,Vn.2D,Vm.D[i]` via `WORD`) closed the prior 0.76× gap. Only small n=256 trails (0.67×) on per-call overhead, not throughput — see above | +| `Exp`, `Log`, `Log10` | **FIXED in v0.2.0** — own kernels | ports of Arm's optimized-routines, 2–3.5× `math.Exp`/`math.Log`; see the Exp and Log sections below | +| `Sin`, `Cos`, `Tan`, … (through `Map`) | ~parity with NumPy | per element on one Zen 3 core: Go `math.Sin` 10.5 ns vs NumPy 10.4, `math.Cos` 11.1 vs 10.7, `math.Tanh` 16.5 vs 13.5 (2026-10-04); `Map`'s call per element adds 15–20% | + +## Allocation: `Workspace` (2026-10-04) + +On real amd64 hardware the allocating forms were far worse than on the M4: on +a Zen 3 (16 cores), `x + y` lost to NumPy 0.13–0.25× at every size up to 256 Ki +elements, while `AddInto` won. Slicing copies, `Concatenate` and broadcasting +adds (whose results also allocate) lost 5–9×. Measured causes, on M4: + +- **The garbage collector.** A benchmark's live heap is tiny, so a 128 KiB + result per call triggered a GC cycle every ~22 calls (912 cycles in 20 000). + With 256 MiB–2 GiB of live heap, which is closer to a real program, the + overhead halves: 8.3 → 3.6 µs at 16 Ki, against 2.4 µs for `AddInto`. +- **The rest:** `make` zeroes memory the kernel overwrites anyway, and fresh + memory is cold or page-faulted. `GOGC=off` does not help: it trades the + cycles for page faults (9.4 µs). + +A library must not set `GOGC`, and Go has no unzeroed allocation. What NumPy's +reference counting gets for free is scoped reuse. ND4J, the Java ndarray +library, has the same problem and answers it with *workspaces*: an arena that +results of one pass come from, recycled at the end of the pass. `Workspace` is +that. `ws.Use(x)` binds an input, every result computed from a bound array is +carved from the arena (64-byte aligned, capped so an `append` cannot spill), +results stay bound so a chain stays in it, and `Reset` recycles everything, +folding a pass that needed several blocks into one block. Sites whose kernel +writes every element (elementwise, ufuncs, copies, broadcasts, concatenation, +`Where`, `Clip`, scans, `Take`, `Outer`, mat·vec) skip the zeroing. Those that +accumulate (GEMM, vec·mat, axis reductions) clear their slot. + +Zen 3, 16 cores pinned, NumPy 2.5.3 single-threaded, best of two runs: + +| op | n | heap | **Workspace** | `*Into` | NumPy | Workspace vs NumPy | +|----|--:|--:|--:|--:|--:|:--:| +| Add | 1 024 | 2.6 µs | **0.38 µs** | 0.31 µs | 0.82 µs | 2.1× | +| Add | 16 Ki | 35 µs | **4.6 µs** | 4.3 µs | 5.1 µs | 1.1× | +| Add | 256 Ki | 498 µs | **67 µs** | 75 µs | 150 µs | 2.2× | +| Add | 4 Mi | 8.0 ms | **2.2 ms** | 2.2 ms | 6.3 ms | 2.9× | +| sqrt(x·y+x) | 1 024 | 12.2 µs | **2.6 µs** | — | 3.6 µs | 1.4× | +| sqrt(x·y+x) | 16 Ki | 112 µs | **33 µs** | — | 34 µs | 1.0× | +| sqrt(x·y+x) | 256 Ki | 1.18 ms | **0.39 ms** | — | 2.97 ms | 7.5× | +| sqrt(x·y+x) | 4 Mi | 23.4 ms | **9.1 ms** | — | 19.2 ms | 2.1× | + +On M4 the same chain gains ×5 at 1 024 and ×1.6 at 4 Mi. Correctness: every +operation is checked bit for bit against its heap result, including on passes +whose recycled memory was first filled with NaN (a site that skipped zeroing it +needed would leak the NaN; planting that bug in the GEMM made the test fail). + +## amd64 on real hardware (2026-10-04) + +Every figure above was taken on Apple silicon. This round measured amd64 for +the first time, on an AMD EPYC 7773X (Zen 3, GCC Compile Farm cfarm421), with +go-ndarray and NumPy both pinned to the same 16 cores of one NUMA node +(`taskset -c 16-31`; Go's GOMAXPROCS follows the affinity mask). NumPy 2.5.3 +with its bundled OpenBLAS 0.3.34 (DYNAMIC_ARCH, Haswell kernels on Zen 3), +once with `OPENBLAS_NUM_THREADS=1` and once with 16. go rows are the minimum +of three runs interleaved with the previous version, so drift on a shared host +hits both alike. + +| op | before | after | gain | vs OpenBLAS 1 thread | vs OpenBLAS 16 threads | +|----|------:|------:|:--:|:--:|:--:| +| MatMul 1024² | 21.87 ms | **8.05 ms** | ×2.72 | 5.93× | 0.60× | +| MatMul 512² | 3.69 ms | 1.56 ms | ×2.36 | 3.89× | 0.66× | +| MatMul 256² | 0.661 ms | 0.558 ms | ×1.19 | 1.36× | 0.30× | +| MatMul 128² | 0.210 ms | 0.141 ms | ×1.49 | 0.73× | 0.36× | +| MatMul 1024×256·256×1024 | 5.72 ms | 2.71 ms | ×2.11 | 4.55× | 0.54× | +| Inner 512² (a·bᵀ) | 5.87 ms | **1.47 ms** | ×4.00 | 4.19× | **0.96×** | + +Four changes, each measured on its own before the next: + +1. **AVX2/FMA 6×8 micro-kernel** (the BLIS Haswell shape). In L1 it runs at + 43 GFLOP/s on one Cascade Lake core (16 flops per cycle at about 2.7 GHz, + if the virtual machine's unknown clock is near that); the SSE2 4×4 it + replaces had no FMA and half the vector width. +2. **Edge tiles through the micro-kernel.** With MR=6, every 256-row MC block + ends in a 4-row edge panel, and the scalar edge loop took **40%** of one + profile covering serial 64² and 512² products. A partial tile now runs the + kernel into a scratch tile. +3. **Prefetch** of the C tile at kernel entry and of the A panel eight steps + ahead: serial 768² went from 27.5 to 34.7 GFLOP/s (+26%). 90% of the time was + already in the kernel, which ran at 28 GFLOP/s there against 43 with its + panels in L1, so it was waiting on memory. A sweep of MC (48–252) and KC + (128–512) moved nothing (±3%), so the blocking is unchanged. +4. **B packed by all workers.** MatMulP packs the shared B block once per + (jc, pc) round, and one goroutine did it while the others waited: +30–70% + in parallel. Below 64 Ki elements the launch costs more than the copy (M4: + −8–15% at 96³–128²), so small blocks are still packed by one. + +`Inner` gained the most because it used to materialise bᵀ before multiplying. +The GEMM now reads any 2-D view through its strides while packing (the copy it +makes anyway), so a transposed, sliced, reversed or broadcast operand costs no +extra pass. + +**Still behind OpenBLAS with 16 threads** (0.54–0.66× on large products, 0.3× +below 256²). Where the parallel loss is, measured on the Zen 3: + +- It is not the hardware: 16 *independent* serial 512² GEMMs, one per core, + each ran at 41 GFLOP/s (656 in total), while the parallel 1024² GEMM ran its + kernel at 15–17 GFLOP/s per core. Per-core efficiency already drops from 40 + to ~30 GFLOP/s at 2 cores. +- ~~It is not scheduling overhead: 83% of the parallel profile is the + micro-kernel itself, so the kernel runs slower when cores share work.~~ + **Wrong, corrected the same day:** a profile of cycles samples only the cores + that run, never the ones that wait. `perf stat` shows 7.1 of 16 CPUs busy + during a 1024² product: the loss was mostly waiting, and the fix is in + [Parallel GEMM: start once, wait on work](#parallel-gemm-start-once-wait-on-work-2026-10-04). + Why the kernel itself also runs slower in parallel is still **not established**. The shared packed B panel is the suspect (on this part + the last-level cache is split between core complexes, so the panel is partly + remote), but the drop already shows at 2 cores of one complex, which that + explanation does not cover. +- Taller row bands (more reuse of each B micro-panel) do not help: interleaved + over 5 runs they were worse at 1024² and equal at 2048². +- **A 2-D tiling of C with private packing** (no barrier, nothing shared) was + built and measured, and is **not shipped**: ×1.24 at 1024² on the Zen 3, but + ×0.62 at 512² and Inner, and ×0.70–0.93 at every size on M4, whose cluster + L2 holds the shared panel well. A tile repacks (rows + cols)·k elements for + rows·cols·k multiply-adds, and a strided pack costs far more per element than + an FMA, so tiles under ~256×256 lose. + +The hardware counters were read the same day; see the next-but-one +section. An AVX-512 kernel for the hosts that have it remains open. + +Correctness was checked on real hardware for five of the six 64-bit targets: +amd64 (Zen 3, FMA path), arm64 (cfarm185), ppc64le (cfarm120), riscv64 (cfarm94) +and loong64 (cfarm401). The s390x host (LinuxONE) did not answer; s390x stays +covered by the qemu lane in CI. + +## Axis reductions (2026-10-04) + +On the Zen 3 the axis reductions of a 1024×1024 matrix lost to NumPy 0.23–0.43×. +Two causes, both measured: + +- **An axis-0 reduction ran on one core.** It has a single outer slab, and the + driver only split outer slabs; the comment justifying that said a column split + would need a strided gather. It does not: a band of columns is a contiguous run + of every row. `RunAxisP` now splits the columns into bands of at least 512 + when there are fewer slabs than workers, and each row run is added with the + elementwise SIMD kernel (exact per element, so the sum is still in axis order). +- **A row reduction (axis 1, `inner` = 1) was a sequential scalar loop**, + latency-bound at one add per 4 cycles. Each row now goes through the SIMD + sum/max/min. Max and min are exact; the sum is regrouped lane-parallel, as + NumPy's own pairwise sum is. + +Zen 3, 16 cores pinned, minimum of three interleaved runs: + +| op (1024×1024) | before | after | NumPy 1 thread | after vs NumPy | +|----|--:|--:|--:|:--:| +| SumAxis(0) | 696 µs | **186 µs** | 180 µs | 0.97× | +| SumAxis(1) | 531 µs | **92 µs** | 224 µs | 2.4× | +| MaxAxis(1) | 601 µs | **93 µs** | 189 µs | 2.0× | + +On M4: MaxAxis(1) ×6–8, SumAxis(0) ×1.9. +## Broadcasting a row without materialising it (2026-10-04) + +`M + row` (1024×1024 plus 1×1024) lost to NumPy 0.26× on the Zen 3 even with a +`Workspace`: the broadcast path copied the row out to a full 8 MiB matrix +before adding. When one operand has the full shape and the other, leading 1s +dropped, is a suffix of it (a row, a 2-D mean subtracted from a 3-D stack), the +second simply repeats in blocks of its own length, so `binOp` now streams the +live slices block by block through the SIMD kernel (`kernels.RepeatP`), split +across cores. Below 64 elements per block the old path is kept. + +| BroadcastAdd 1024²+row, Zen 3 | heap | `Workspace` | NumPy 1 thread | +|----|--:|--:|--:| +| before | 3.1 ms | 1.76 ms | 460 µs | +| after | **0.84 ms** | **114–196 µs** | 460 µs | +## Exp (2026-10-04) + +`Exp` went through `Map(math.Exp)`, and `math.Exp` itself was the cost: 13.2 ns +per element on one Zen 3 core, 3.9 ns on M4 (the func-pointer call added only +15–20%). It is now a port of the double-precision exp of Arm's +optimized-routines (Szabolcs Nagy; glibc's exp since 2.28; MIT licence): x = +k·ln2/128 + r, 2^(k/128) from a 128-entry table as scale·(1+tail), exp(r)−1 from +a degree-5 polynomial. The common case is written out in the loop, because the +function is too large to inline and a call per element cost a third of the +time. + +- **Accuracy:** worst 0.504 ULP over 80 000 inputs across the whole finite + range, the region near 0, the overflow edge and the subnormal range, + measured against a 300-bit reference (`math/big`, ln 2 from its atanh + series), against Arm's documented 0.509; the committed test samples 20 000 + of them and finds 0.501. `math.Exp` measured 0.879 ULP on the 80 000 inputs + on arm64 (0.841 on the test's 20 000). The table was not trusted as copied: + `TestExpTable` re-derives all 256 entries from 2^(k/128) at 300 bits. +- **A Go bug it removes**, reported as + [golang/go#81995](https://github.com/golang/go/issues/81995): on amd64, + `math.Exp` returns **+Inf for 709.436139303104 ≤ x < 709.782712893384**, + although the result is finite there (NumPy, glibc and mpmath: + `exp(709.5)` = 1.3549863193146328e+308). Its assembly rounds k = x·log2(e) to + 1024 from x = 1023.5·ln 2, and the biased-exponent check treats that as + overflow although the reduced factor is below 1. Reproduced with Go 1.26.8 and + 1.27.1 on AMD Zen 3, Intel Cascade Lake and Intel Haswell, with and without + the FMA path; the assembly is unchanged on master. go-ndarray's `Exp` + inherited it until v0.2.0; `TestExpTopOfRange` pins the fix. +- Just above ln(2^-1075), exp rounds up to the smallest subnormal, as glibc + and NumPy do. Go's arm64 `math.Exp` returns 0 there (amd64 does not); that is + one unit of the last subnormal place, not a contract violation. + +Speed, kernel only, 16 Ki elements: M4 30 µs (`math.Exp` 65 µs); one Zen 3 core +62 µs (`math.Exp` 215 µs, NumPy single-threaded 78 µs). Whole `Exp` on Zen 3, +16 cores: 1 Ki ×2.7, 16 Ki ×3, 256 Ki ×1.6, 4 Mi ×1.65; from 256 Ki on it is +2–5× NumPy. A SIMD version (AVX2 gathers for the table) is the next step. +## Dot and mat·vec (2026-10-04) + +`dotRange`, under `Dot` (1-D), `MatVec` and `Dot1DP`, was a four-chain scalar +loop (gc does not vectorise it): on one Zen 3 core 2^20 elements took 464 µs, +2× NumPy's single thread. Two generated kernels replace it: `dotFMA` on amd64 +(four YMM accumulators, b read straight from memory by VFMADD231PD; two loads +per FMA make it load-bound, which four chains cover), gated on the FMA probe, +and `dotNEON` on arm64 (eight D2 accumulators for the four FP pipes of an Apple +core). The other targets keep `dotRange`. The lane-parallel sum is a regrouping, +held to the n·ε·Σ|aᵢbᵢ| bound and exact on integer data. + +| Zen 3 | before | after | NumPy 1 thread | +|----|--:|--:|--:| +| Dot 2^20, 1 core | 464 µs | **229 µs** | 231 µs | +| MatVec 1024², 1 core | 482 µs | **134 µs** | 153 µs | +| Dot 2^20, 16 cores | 159 µs | 99 µs | — | +| MatVec 1024², 16 cores | 165 µs | 89 µs | — | + +NumPy's multi-threaded dot still wins on this part (24 µs). The go-ndarray +version stops scaling at about 90 GB/s from 4 cores on. ~~A likely cause, not +verified, is cache affinity: OpenMP keeps each thread on one core, so it +rereads its own chunk from its own L3 slice on every repetition, while +goroutines move between core complexes.~~ + +**Measured 2026-10-07 (POWER9 bare metal, cfarm29, one thread per core):** +the cache-affinity guess does not hold. The pattern is the fork/join of +`parallelFor`, the same as the GEMM's (see *Parallel GEMM: start once, wait on +work*). A 2^20 dot took 212 µs on 4 cores and 553 µs on 8, close to the 688 µs +of one core. With 8 workers, only 2.4 CPUs were busy (`perf stat`). Three +replacements for `parallelFor` were built and measured against it +(8–10 interleaved rounds); **none is shipped**: + +| scheduler | 8 workers | 4 workers | +|---|---|---| +| shared queue of 4w blocks, caller works | Dot 1.55×, MatVec 2.0×, SumAxis(1) 2.0× | Sum 4 Mi 0.51×, Dot 0.55× | +| owned runs (the static chunks) + stealing, caller works | Dot 2.18×, MatVec 2.0× | Sum 4 Mi 0.63×, Dot 0.68× | +| owned runs + stealing, caller blocks, last block wakes it | mostly 1.0–1.4×, Exp 1.87× | Sum and AddInto at 256 Ki 0.51–0.55× | + +The second row keeps exactly the static chunks of the original and still +loses at 4 workers, so affinity is not the cause. `perf stat` places the loss +in scheduling instead: with the caller working, 2.1 of 4 CPUs were busy +against 3.4 for the original. Its goroutines wait in the run queue of the +caller's processor until another one is woken to steal them. With the caller +blocking, the 4-worker case recovers, but the 8-worker gains go with it. The +code is on the `experiment/parallel-for-scheduling` branch. + +**Solved in v0.6.0 with a persistent helper pool.** The cost being paid was +waking threads, so the fix is to stop putting them to sleep between back-to-back +operations, as OpenBLAS and OpenMP do. Up to `GOMAXPROCS-1` helper goroutines +are started once. After an operation they poll for the next one for 200 µs, +then park. The work keeps owned runs with stealing (row two of the table +above). Reductions now use four partials per worker: with one each, a helper +that saw the job late had its only block taken by the caller, who then summed +two in a row (Sum of 4 Mi on 4 cores: 2.8 of 4 CPUs busy). On POWER9 bare metal, +each case in a **fresh process**, 6 interleaved rounds, against v0.5.2 +(median): + +| | 2 workers | 4 workers | 8 workers | 8 workers, before → after | +|---|---|---|---|---| +| Dot 2^20 | 3.29× | 2.41× | **10.8×** | 625 → 58 µs | +| MatVec 1024² | 2.01× | 1.65× | 5.78× | 323 → 56 µs | +| SumAxis(1) | 2.06× | 1.80× | 7.09× | 310 → 44 µs | +| Sum 256 Ki | 2.33× | 3.23× | 5.60× | 61 → 11 µs | +| Max 256 Ki | 1.75× | 1.34× | 6.78× | 757 → 112 µs | +| AddInto 256 Ki | 2.22× | 1.91× | 5.57× | 102 → 18 µs | +| Exp 256 Ki | 1.18× | 1.49× | 1.97× | 1013 → 513 µs | +| Sum 4 Mi | 1.33× | 1.17× | 1.41× | 431 → 305 µs | +| Exp 4 Mi | 1.00× | 1.10× (p25 0.84×) | 1.29× | | +| BroadcastAdd | 0.95× | 1.01× | 1.67× | | +| Chain (Mul, Add, Sqrt) 4 Mi | | 0.91× | 1.04× | | +| MatMul 256², 512² (own driver) | | 1.00× | 0.96–0.99× | | + +A first combined run, with every benchmark in one process, also showed serial +operations speeding up (Sqrt of 1 Ki, 1.5–2.2×). Run alone, those are equal. +That gain belonged to whatever state the earlier benchmarks had left, not to +this change, so only fresh-process figures are reported here. The cost: after +an operation, the helpers spend up to 200 µs of CPU polling. They are never +stopped (see the README, *Goroutines*). + +**Zen 3 (cfarm421, back up on 2026-10-07)**, each case in a fresh process, +6 interleaved rounds, v0.6.1 against v0.5.2 (median): + +| | 2 workers | 8 workers | 16 workers | 16: before → after | +|---|---|---|---|---| +| Dot 2^20 | 1.24× | 2.06× | 2.08× | 120 → 57 µs | +| MatVec 1024² | 1.41× | 2.26× | 1.92× | 94 → 49 µs | +| Sum 256 Ki | 1.61× | 2.12× | 2.31× | 37 → 16 µs | +| Sum 4 Mi | 1.10× | 1.95× | 2.07× | 184 → 89 µs | +| Max 256 Ki | 1.47× | 2.38× | 2.36× | 41 → 18 µs | +| SumAxis(1) | 1.25× | 2.20× | 1.63× | 93 → 57 µs | +| AddInto 256 Ki | 1.39× | 1.22× | 1.29× | 74 → 57 µs | +| Exp 256 Ki | 1.05× | **0.85×** | 1.13× | | +| Exp 4 Mi | 1.28× | 1.09× | **0.94×** | | +| Chain 4 Mi | 1.74× | 1.04× | **0.88×** | | + +In the same run, NumPy 2.5.3 with 16 OpenBLAS threads took 39 µs for the 2^20 +dot and 25 µs for the 1024² mat·vec, so go-ndarray is at **0.69×** and **0.52×** +of it (0.16× before the pool). The losses at the top of each table are long, +memory-bound chains, where all workers stream at once; not investigated further. +Apple M not measured (shared machine). + +## Log and Log10 (2026-10-04) + +`Log` went through `Map(math.Log)`: 11.6 ns per element on a Zen 3 core +against NumPy's 4.2. It is now a port of the double-precision log of Arm's +optimized-routines (the companion of `Exp`, also glibc's since 2.28; MIT +licence): x = 2^k·z, z split into 128 subintervals, log(x) = k·ln2 + log(c) + +log1p(z/c − 1) with 1/c and log(c) from a table and a degree-6 polynomial; +inputs within 2⁻⁴ of 1 take a degree-12 polynomial. z/c − 1 is one +`math.FMA` (exact on every target), so the second table the non-FMA variant +needs is not used. `Log10` is log(x)·(1/ln10), the formula of `math.Log10`, on +this log. + +- **Accuracy:** worst 0.508 ULP over the test's 10 000 inputs (whole range, + near 1, subnormals), against a 300-bit reference (log m = + 2·atanh((m−1)/(m+1))); `math.Log` measured 0.724 on the same inputs on + arm64. `TestLogTable` checks each of the 128 + entries against the properties `log_data.c` documents (1/invc inside its + subinterval, logc = log(c) within the rounding of invc, 0x1.8p9 + logc + exact). +- **A second Go bug it removes**, known since 2022 as + [golang/go#56600](https://github.com/golang/go/issues/56600) (fix pending in + CL 448216): on amd64, `math.Log` is wrong for every subnormal input. + `math.Log(5e-324)` returns −709.09 instead of −744.44, and `math.Log(1e-310)` + −709.09 instead of −713.80 (NumPy, glibc: −744.44, −713.80). Its assembly + inlines `Frexp` with bit masks that assume a normal input. Reproduced with Go + 1.26.8 and 1.27.1 on three x86 CPUs. `Log` and `Log10` inherited it; `TestLogSubnormal` pins the fix. + (`Log2` already normalised through `math.Frexp` and was right.) + +Speed, 16 Ki elements, one core: M4 37 µs (`math.Log` 72), Zen 3 73 µs +(`math.Log` 175, NumPy 69). + +## ppc64le: VSX kernels (2026-10-04) + +Go's ppc64 assembler has the VSX loads, stores and permutes but no vector +double arithmetic, so ppc64le ran the scalar code. go-asmgen v0.13.0 encodes +`xvadddp`, `xvsubdp`, `xvmuldp`, `xvdivdp`, `xvmaddadp`, `xvmaxdp`, `xvmindp` +and `xvsqrtdp` as `WORD`s, pinned against GNU as 2.44 and run bit-identical to +Go's arithmetic and `math.FMA` on POWER8 and POWER9. With them, go-ndarray has +VSX kernels for sum, dot, sqrt, add/sub/mul/div and the GEMM. + +The GEMM micro-kernel (8×8, 32 accumulators) follows OpenBLAS's POWER9 dgemm: +no splats. Each A pair and its swap multiply each B column pair, so one +accumulator holds a diagonal (C[r][c], C[r+1][c+1]) and another the +anti-diagonal; the store rebuilds the rows with `xxpermdi`. On one POWER9 core, +measured in one binary: 13.3 GFLOP/s with `lxvdsx` splats, 14.8 with `xxpermdi` +splats, 15.4 with those unrolled by two, **16.9** with the diagonal scheme +unrolled by two (shipped). + +POWER9 (cfarm29, 8 cores × SMT4), against the scalar code (best of 3–4 +interleaved runs): + +| op | before | after | gain | +|----|--:|--:|:--:| +| micro-kernel, 1 core | 8.3 GFLOP/s | 16.2 GFLOP/s | ×1.96 | +| serial GEMM 512², 1 core | 7.9 GFLOP/s | 15.8 GFLOP/s | ×2.0 | +| MatMul 1024², 32 threads | 39.2 ms | 23.6 ms | ×1.66 | +| MatMul 128² / 512² | | | ×1.8 / ×1.5 | +| MatVec 1024² | 823 µs | 267 µs | ×3.1 | +| Sum 1 Ki / 16 Ki / 256 Ki | | | ×6.6 / ×7.3 / ×3.2 | +| AddInto 1 Ki–256 Ki | | | ×2.7–4.1 | +| Sqrt, 1 core | | | ×1.05–1.18 | + +At 4 Mi elements sum and add are memory-bound (×1.1). **Against OpenBLAS** +0.3.34 (its POWER9 kernel, loaded through `scipy-openblas64` on the same host; +the ppc64le NumPy wheel there has no BLAS): one thread 22.8 GFLOP/s at 512², +so the serial GEMM is at 0.69×; 32 threads 13.1 ms at 1024², so MatMul is at +0.56×. OpenBLAS unrolls further and uses POWER9-only `lxv` loads; Go's ppc64le +baseline is POWER8. + +## loong64: LASX kernels (2026-10-04) + +Go's loong64 assembler has LASX vector float64 add/sub/mul/div, square root, +256-bit loads and the broadcast load (spelled as an arrangement, +`XVMOVQ off(R), X.V4`, which assembles to `xvldrepl.d`), but no fused +multiply-add; go-asmgen v0.14.0 encodes `xvfmadd.d` as a `WORD` (pinned +against GNU as 2.43, bit-identical to `math.FMA` on a Loongson 3C5000L; +transitional until cmd/asm names it). go-ndarray uses them for sum, dot, sqrt, +add/sub/mul/div and an 8×8 GEMM micro-kernel (16 four-lane accumulators, A +broadcast by `xvldrepl.d`). (v0.2.5 said Go had no broadcast load and emitted +it as a WORD too; it has one, and v0.2.6 emits the mnemonic, the same machine +code.) LASX is not on every LoongArch CPU, so the kernels run only when +the kernel reports it in AT_HWCAP (`/proc/self/auxv`), with the scalar code as +the fallback; the parsing is a pure function tested on every target. + +Loongson 3C5000L (cfarm401, 32 cores), against the scalar code, best of 3 +interleaved runs (the host's load average of ~146 is processes stuck in +uninterruptible sleep; its CPUs measured 100% idle): + +| op | before | after | gain | +|----|--:|--:|:--:| +| micro-kernel, 1 core | 7.0 GFLOP/s | 27.5 GFLOP/s | ×3.95 | +| serial GEMM 512², 1 core | 5.3 GFLOP/s | 20.0 GFLOP/s | ×3.75 | +| MatMul 64² / 512² / 1024², 32 threads | | | ×1.9 / ×1.3 / ×1.1 | +| Sum 256 Ki / 4 Mi, 1 core | 658 / 11 750 µs | 65 / 4 615 µs | ×10.2 / ×2.5 | +| Sum 1 Ki / 16 Ki (below the parallel threshold: one core) | | | ×16 / ×13 | +| Sqrt 1 Ki–16 Ki | | | ×3.8 | +| MatVec 1024² | 364 µs | 224 µs | ×1.6 | + +At 4 Mi elements in parallel, sum and sqrt are memory-bound (parity); the +parallel 4 Mi sum is bimodal on this host (1.6 ms or 7 ms in either version), +so only the minima are compared. No NumPy or BLAS is installable there (the +host has no route to package mirrors), so there is no external reference. + +## Parallel GEMM: start once, wait on work (2026-10-04) + +**Diagnosis, from hardware counters** (`perf stat`, Zen 3 guest cfarm421, +steady state, 1024², 3 s per configuration): + +| workers | CPUs busy | IPC | instructions | +|---|---|---|---| +| 1 | 1.00 | 3.35 | 37.5 G | +| 2 | 1.91 | 2.00 | 37.6 G | +| 8 | 5.58 | 2.16 | 172 G (192 products) | +| 16 | 7.08 | 2.28 | 193 G (226 products) | + +The instruction count does not grow, so nobody busy-waits. L1 misses do not +grow either. What changes is that at 16 workers the CPUs are busy only 44% of +the time. The driver forked and joined goroutines twice per (jc, pc) block of +the product, once to pack B and once for the row bands, 16 times in a 1024² +product. A fork/join wakes parked threads one after another. A micro-benchmark +on the same guest (16 goroutines, 700 µs of work each) measures its cost at +**26 µs with 2 workers, 165 µs with 8, 330 µs with 16**. 16 × 330 µs is ~40% +of a 12.5 ms product. + +Two hypotheses were tested and **refuted** first: + +- SMT siblings. The guest has 128 vCPUs, as many as the host has threads. + Two serial GEMMs pinned on vCPU 16 and any other vCPU slow each other by + 0–10%, never by half. +- A block of A larger than L2. With MC = 256, the A block is 512 KiB, all of + Zen 3's L2, against BLIS's MC = 72. Sweeping MC over {72, 120, 144, 192, 256} + changed nothing beyond the noise at 2 workers, and gave +17% at 16 workers + for 1024² but −8% for 512². + +**The change.** The workers start once per product. They synchronise on +**work done**, never on workers arrived: a block's bands start once all of its +B panels are packed and all bands of the previous block are done, and the +packing is shared in claims of four NR-wide panels. A first version used a +barrier that waited for every worker, and it put the wake-up chain straight +back on the critical path for small products: 96³ on 8 POWER9 cores ran at +0.57×. B is double-buffered, so block i+1 is packed while block i is still +being multiplied. Each C tile is still accumulated in pc order, so the result +is bit-identical to the serial product. The pack buffers moved to two pools, +one for A and one for B: a pair per worker had held 16 MiB of unused B buffers +at 16 workers, which the collector empties and the next call zeroes again +(`memclr` page faults, 20% of the cycles in a KVM spinlock in one profile). + +**Measured.** Every figure is the median of interleaved runs against the +previous driver. + +| | 2 workers | 8 workers | 16 workers | +|---|---|---|---| +| Zen 3 guest, 1024² | 1.06× | 1.33× | 1.39× | +| Zen 3 guest, 512² | 1.20× | 1.20× | 1.33× | +| Zen 3 guest, 256² | 0.92× | 1.19× | 1.24× | +| Zen 3 guest, 128² | **0.89×** | 1.50× | 1.16× | +| Zen 3 guest, (1024×256)·(256×1024) | **0.82×** | 1.07× | 0.89× (p25 1.00×) | +| POWER9 bare metal, 512² | 1.34× | 1.37× | | +| POWER9 bare metal, 256² | 1.61× | 1.78× | | +| POWER9 bare metal, (1024×256)·(256×1024) | 1.13× | 1.18× | | + +The Zen 3 runs were 12 rounds and the POWER9 runs 6, except 24 for 128² and +256² at 8 workers. Those two are bimodal on that machine, in both drivers, and +6 rounds had shown 256² at 0.72×. POWER9 used one thread per core (CPUs 0, 4, +8, …). + +**Still behind.** On the Zen 3 guest, two workers lose 8–18% on mid-size +products, where a fork/join costs only 26 µs. Bare-metal POWER9 gains at two +workers, so this is not a property of the scheme, and no second code path was +added for it. Against OpenBLAS with 16 threads, measured three ways in one run: + +| n | OpenBLAS-16 | previous driver | new driver | +|---|---|---|---| +| 256 | 708 µs | 1.06× | **1.44×** | +| 512 | 789 µs | 0.29× | 0.42× | +| 1024 | 4.60 ms | 0.43× | 0.51× | + +(× = OpenBLAS time / ours, so higher is faster). On this guest, absolute times +drift through the day: the previous driver measured 0.54–0.66× of OpenBLAS in +the morning. Only ratios taken in the same run are comparable. Not measured on +Apple M-series, whose machine was shared with other jobs at the time. The bands +stay dynamic, so its slower efficiency cores are handled as before. + +## Sums of the other dtypes (2026-10-18) + +Measured first, because nothing had: `BenchmarkDTypeSum` and +`BenchmarkDTypeAdd` (`benchmarks/dtype_bench_test.go`) run the same operation +on float64, float32, int64 and int32. On POWER9 (cfarm29, one thread pinned to +the idlest CPU by `hostload -pick 1`, the host 3% busy before and after), a +float32 `Sum` of 2²⁰ elements took **6.85 ms against 0.23 ms for float64**, +6.8 ns an element, while `Add` was only 1.5× slower than float64. A missing +SIMD kernel explains a factor of 4 to 8, not 30. + +The cause was the reduction loop. Every non-float64 reduction walked the +`[outer][axisLen][inner]` layout with the reduced axis outside and the kept +elements inside, so a whole-array sum (`inner` = 1) took a slice and ran a +one-iteration loop per element. A contiguous run is now folded directly, as +NumPy's add and multiply reductions do: the first element, then the rest +added to it, **pairwise for float32 and the complex dtypes** (NumPy's +`FLOAT/CFLOAT_pairwise_sum`, `loops_utils.h.src` at v2.5.3: eight +accumulators, blocks of 128, halved above), in order for the integers (exact +in any order) and for products (NumPy multiplies in order). + +| POWER9, 1 thread, n = 2²⁰ | before | after | | +|---|---:|---:|---:| +| float32 `Sum` | 6.850 ms | 1.637 ms | −76% | +| int64 `Sum` | 5.195 ms | 1.870 ms | −64% | +| int32 `Sum` | 6.170 ms | 2.868 ms | −54% | +| float64 `Sum` (its own kernels; the control) | 225.7 µs | 225.7 µs | ~ | + +Six runs each, old and new binaries alternating, `benchstat` p = 0.002. The +float32 sum is also more accurate: on 2¹⁶ values of mixed sign and magnitude +its error against the exact sum (`math/big`) fell from 75 to 0.87, and 2²⁴ +followed by 1000 ones no longer saturates at 2²⁴ (`sum_pairwise_test.go`). + +The narrow integer sums still converted the whole array to int64 (NumPy's +accumulator) before adding: an extra pass and 8 bytes an element. Since +v0.14.0 a sum or product of bools or integers narrower than 64 bits along a +contiguous axis widens each element as it is read (`foldWidened`, same +arithmetic, wrap-around included): **int32 `Sum` of 2²⁰ elements 2.872 → +1.548 ms (−46%)**, 2¹⁴ −51%, with float64, float32 and int64 unchanged as +controls (POWER9, same protocol, p = 0.002). + +### float32 sums on SSE2 and NEON (2026-10-18) + +NumPy keeps eight accumulators in its pairwise sum so that the inner loop can +be vectorised "without changing summation ordering". `kernels.AddBlocks8F32` +is that loop: blocks of eight float32 added lane by lane, one IEEE addition +per lane per block, in order. It is SSE2 on amd64 (`ADDPS` on two XMM +registers) and NEON on arm64 (`VFADD` on two `.S4` registers), and the scalar +loop elsewhere, so float32 sums are unchanged bit for bit. The kernel is +tested bit for bit against the scalar loop (NaN, the infinities, −0, +subnormals, overflow), and fenced with `PROT_NONE` pages on both operands. A +kernel adding from the wrong register fails the first test, and one reading +a block too many faults in the fence. + +Measured twice. First with the `bench` workflow on the hosted runners +(base and head alternated ten times in fresh processes on one thread, +`benchstat`, p ≤ 0.002), because the development Mac ran at a load of 97 and +most cfarm hosts refuse their keys since the host CA was revoked. Then on +real, idle hardware reached through a known_hosts file whose keys were +confirmed over a second network path: cfarm424 (Neoverse N1, 0.0% busy) and +cfarm420 (Zen 3, EPYC 7773X, 1.7% busy), same protocol, pinned to the idlest +CPU. On those, float32 `Sum` was −39% / −32% (N1) and −43% / −41% (Zen 3) at +2¹⁴ / 2²⁰. int32 was −15% / −11% on the N1, and on the Zen 3 within the +noise (±10% at that host's variance). The controls were unchanged. + +| vs v0.13.x | amd64 (EPYC 9V45) | arm64 (hosted) | +|---|---:|---:| +| float32 `Sum`, 2¹⁴ | −50% | −33% | +| float32 `Sum`, 2²⁰ | −49% | −43% | +| int32 `Sum`, 2¹⁴ | −12% | −12% | +| int32 `Sum`, 2²⁰ | −15% | −13% | +| float64, int64 `Sum` (controls) | ~ | ~ | + +The integer rows come from a defect the first run exposed. With the kernel +added, int32 `Sum` was **48–56% slower on amd64** although its code had not +changed: the disassembly of both binaries showed the same loop, byte for +byte, moved 224 bytes and now straddling a 64-byte line. The loop also +reloaded the operation from the stack every element. The widening folds now +use one loop per operation and four accumulators (exact: integer addition +and multiplication wrap modulo 2⁶⁴, so the order changes nothing). They also +consume the slice instead of indexing `i+1…i+3`, which had left four bounds +checks per iteration in the amd64 code; now there are none. + +**riscv64 and loong64 (2026-10-18).** RVV: `VSETIVLI $8, E32, M2` gives +vl = 8 on every VLEN ≥ 128, and `VFADDVV` adds a block to the two-register +group. LSX: the eight accumulators sit in two 128-bit registers, and +`VFADD.S` (`VADDF`) adds a block. Each runs behind the feature bit its float64 +kernels use. Both were run on the real hardware: the kernel tests passed +there, and a sabotaged kernel failed them, so the kernel path is the one +taken. Same A/B protocol, idle hosts: + +| float32 `Sum`, 2¹⁴ / 2²⁰ | change | controls | +|---|---:|---| +| SpacemiT X60 (cfarm95, RVV, VLEN 256) | −30% / −41% | float64, int32 ~ | +| Loongson 3C5000L (cfarm401, LSX) | −24% / −24% | float64, int32 ~ | + +The first loong64 kernel used one 256-bit LASX register for all eight +accumulators, and was **26–47% slower** than the scalar loop. Every block's +addition waited on the previous one, a single dependency chain, where the +scalar loop has eight. Two registers give two chains, as SSE2 does on amd64. + +**ppc64le (2026-10-18).** Go's assembler has no `xvaddsp`, so go-asmgen +emits it as a `WORD`. The encoding is pinned against GNU as 2.44 and llvm-mc +on a POWER9 (go-asmgen/asmgen#27). The cmd/asm spelling is +[CL 846285](https://go.dev/cl/846285), and go-asmgen's registry check +assembles every entry with that CL applied. The eight accumulators sit in two +VSX registers, loaded and stored with `LXVW4X`/`STXVW4X`, so every word keeps +its lane. VSX is the ppc64le baseline, so there is no feature check. The kernel +tests passed on the POWER9, and a sabotaged kernel (one `xvsubsp`) failed them. + +| float32 `Sum`, 2¹⁴ / 2²⁰ | change | controls | +|---|---:|---| +| POWER9 (cfarm29) | −36% / −48% | float64, int32 ~ | + +**s390x (2026-10-18).** Go's assembler lacks `vfasb` (vector-enhancements +facility 1, z14), which [CL 846286](https://go.dev/cl/846286) adds. go-asmgen +v0.19.0 emits it as `WORD`+`BYTE`+`BYTE`, the 6 bytes pinned against GNU as +(go-asmgen/asmgen#29). The eight accumulators sit in two vector registers; +`VL`/`VST` keep memory order on this big-endian machine. The kernel runs +behind the `useVXE` gate of max/min, so a z13 takes the scalar loop. The +kernel tests passed on the z machine, and a sabotaged kernel (one `vfssb`) +failed them. Same A/B protocol: + +| float32 `Sum`, 2¹⁴ / 2²⁰ | change | controls | +|---|---:|---| +| IBM Z, LinuxONE (vxe, vxe2) | −73% / −78% | int32 ~; float64 ~ / +2.0% | + +The float64 control at 2²⁰ moved +2.0% with its kernel byte-identical: the +change moved `sumVX` by 32 bytes (0x18f920 → 0x18f940). It is attributed to +layout, not proven. + +Every 64-bit target now adds the float32 block with SIMD. + +### float32 elementwise arithmetic (2026-10-18) + +Measured against NumPy 2.5.3 on one POWER9 core, float32 `Add` was +**7.1× slower** at 2¹⁴ (33.8 µs against 4.74 µs) and 5.0× slower at 2²⁰. A +profile put 57% of the time in the generic loop `binNum[float32]`: a scalar +add per element, with two bounds checks, because nothing told the compiler +that `x` and `y` were as long as `d`. + +Two changes: + +- **Bounds checks.** The elementwise loops reslice their operands to + `len(d)` once, so the compiler drops the per-element checks (verified with + `-d=ssa/check_bce`). On the POWER9, float32 and int32 `Add` gain 7–12%. +- **Kernels.** `kernels.Bin32` adds, subtracts, multiplies or divides 16 + floats per iteration on every 64-bit target: + + | target | instructions | + |---|---| + | amd64 | SSE2 `ADDPS`… | + | arm64 | NEON `.S4` | + | ppc64le | VSX `xvaddsp`… | + | s390x | z14 `vfasb`… | + | loong64 | LSX `vfadd.s`… | + | riscv64 | RVV E32, strip-mined | + + Each lane is one IEEE operation, so the result is the scalar loop's bit + for bit. The kernel tests (special values, every length to 70, in place) + and the PROT_NONE fence pass on Zen 3, Apple M, POWER9, IBM Z, SpacemiT X60 + and Loongson 3C5000L. A sabotaged kernel (one `vfsub` in `add32NEON`) + failed them. + +`AddInto` and the other `*Into` methods also wrote a typed result into a new +array and then copied it into `out`. When `out` has the result dtype they now +write into it directly. + +POWER9, one core, against NumPy 2.5.3: + +| float32 | before | after | NumPy | +|---|---:|---:|---:| +| `Add`, 2¹⁴ | 33.8 µs | 15.2 µs | 4.74 µs (`a+b`) | +| `Add`, 2²⁰ | 1.70 ms | 0.537 ms | 0.342 ms | +| `AddInto`, 2¹⁴ | — | **4.35 µs** | 4.23 µs (`out=`) | +| `AddInto`, 2²⁰ | — | **294 µs** | 366 µs | + +float64 `AddInto` against `np.add(out=)`: 5.70 against 7.75 µs (2¹⁴), and +724 against 768 µs (2²⁰). + +### float32 survey against NumPy, and `Sqrt` (2026-10-20) + +The rest of the float32 surface, on the same POWER9 core against NumPy 2.5.3: + +| | ours | NumPy | | +|---|---:|---:|---| +| float32 `Max`, 2¹⁴ / 2²⁰ | 84.7 µs / 4.86 ms | 7.13 µs / 0.324 ms | **11.9× / 15× slower** | +| float64 `Max`, 2¹⁴ / 2²⁰ | 51.6 µs / 3.33 ms | 11.8 µs / 0.615 ms | 4.4× / 5.4× slower | +| float32 `Sqrt`, 2¹⁴ / 2²⁰ | 100 µs / 6.3 ms | 14.7 µs / 0.835 ms | 6.8× / 7.5× slower | +| float64 `Sqrt`, 2¹⁴ / 2²⁰ | 52.5 µs / 2.7 ms | 37.4 µs / 2.3 ms | 1.4× / 1.2× slower | +| `MatMul` 256², float32 / float64 | 20.4 / 2.25 ms | 30.4 / 29.8 ms | NumPy here has no BLAS | + +Read each row against the one below it: + +- float32 `Max` has no kernel anywhere, and on ppc64le neither has float64: + `xvmaxdp` does not propagate NaN as NumPy does. +- float32 `Sqrt` called `math.Sqrt` through a function value for every + element. +- float32 `MatMul` is 9× slower than our own float64 `MatMul`: float32 has no + packed GEMM. + +`Sqrt` is fixed here. `kernels.Sqrt32` runs `SQRTPS`, NEON `.S4`, +`xvsqrtsp`, `vfsqsb` (z14), LSX `vfsqrt.s` or RVV E32. Each is the correctly +rounded float32 square root, so `float32(math.Sqrt(float64(x)))` bit for bit: +float64 carries more than twice float32's precision. + +The kernel tests (special values, every length to 70, in place) and the +PROT_NONE fence pass on Zen 3, Apple M, POWER9, IBM Z, SpacemiT X60 and +Loongson 3C5000L. Measured with fleettools' `abbench` (base and head +alternated, 8 rounds, pinned core, `--max-busy 15`): + +| float32 `Sqrt`, POWER9 | before | after | NumPy | +|---|---:|---:|---:| +| 2¹⁴ | 102.9 µs | **23.4 µs** (−77%) | 14.7 µs | +| 2²⁰ | 6.21 ms | **1.08 ms** (−83%) | 0.835 ms | + +The controls (float64 `Sqrt`, `Add`, `AddInto`) are flat. The gap left to +NumPy is the allocation, the open defect below. + +### float32 `Max`/`Min` (2026-10-26) + +Two changes: + +- **Every architecture.** float32 `Max`/`Min` converted the whole array to a + new float64 array and reduced that. They now read the float32 elements + (`kernels.Max32`/`Min32`), with four independent chains of Go's `max`/`min`. + The value is the same, since the conversion is exact. +- **amd64 and arm64: vector kernels.** + - **amd64:** `MAXPS`/`MINPS` with a `CMPPS`-unordered NaN mask. A zero + result goes back to the scalar loop for its sign, the v0.19.1 rule. + - **arm64:** NEON `VFMAX`/`VFMIN` on `.S4`, which propagate NaN and order + −0 below +0 themselves. + +Each is bit for bit one sequential pass of Go's `max`/`min`. That is tested +with NaN, real signed zeros and infinities at every position up to length 70, +and a sabotaged kernel fails it on both architectures. Measured with +`abbench` (alternated, 6 rounds, pinned core): + +| float32 `Max`, 2¹⁴ / 2²⁰ | change | control (float64) | +|---|---:|---| +| Zen 3 (SSE2) | **−92% / −88%** | ~ | +| Neoverse N1 (NEON) | **−95% / −93%** | ~ | +| POWER9 (Go loop, no kernel yet) | −42% / −34% | ~ | + +On the POWER9 float32 `Max` is still about 7× slower than NumPy, because +Go's NaN-correct `max` is scalar there. + +Still to do: +- ppc64le: done in v0.24.0, below, without a registry entry. +- loong64: done in v0.25.0, below, without a registry entry. + +### loong64 `Max`/`Min`, and negative NaNs (2026-10-26) + +Go's loong64 assembler has no vector float max, min or compare, so the LSX +kernels work entirely in integer arithmetic: + +- **Order:** `t = x XOR ((x >>a N−1) >>l 1)` flips the magnitude bits of the + negatives. The signed order of `t` is the float order, with −0 below +0 + (Go's rule), and the map is its own inverse. +- **Extremum:** `max(acc, t) = acc XOR ((acc XOR t) AND (acc < t))`, with + `vslt`. +- **NaN:** a lane is NaN iff `(x AND abs) >u +Inf's bits` (`vsltu`), + OR-accumulated. + +**What the sabotage run found.** A sabotaged kernel, with the NaN mask of +`max32LSX`'s first lane dropped, *passed* every test in CI's loong64 lane. In +that integer order a **positive** NaN sorts above +Inf, so a max finds it +without any NaN test. Every NaN in the tests was `math.NaN()`, which is +positive. A **negative** NaN sorts below everything, and only the NaN test +catches it. That is no corner case: x86's default NaN (from `0×∞` or `∞−∞`) +is negative. + +The extremum tests now cover both signs, for float32 and float64 on every +architecture. With them the sabotage failed CI's loong64 lane +(`special Max32 n=16: -0, sequential NaN`), and every other architecture's +kernels passed. + +**Validated under emulation only.** The kernels pass on qemu `la464` (LSX and +LASX, required since #67). The Loongson they would be measured on +(cfarm401) accepted TCP but never sent its SSH banner on 2026-10-26, so their +speed is not measured yet. + +### ppc64le `Max`/`Min` (2026-10-26) + +`xvmaxdp`/`xvmindp` (and the `sp` forms) do not propagate NaN as Go and +NumPy do, and Go's assembler has no VSX float compare to build a NaN mask +from. The mask is built in integer arithmetic instead: + +- A lane is NaN iff its magnitude bits (`x AND 0x7FF…F`) exceed +Inf's bits + as an unsigned integer. `vcmpgtud`/`vcmpgtuw` test that on the VMX + registers, against constants loaded from a read-only DATA table, and the + results are OR-accumulated. +- The kernel works only vertically: four accumulators folded into one vector + stored to memory, plus the NaN flag. +- Go folds the lanes and the tail with its `max`/`min`. A zero result goes to + the scalar oracle for its sign, as on amd64. + +| POWER9, `Max` (`abbench`) | 2¹⁴ | 2²⁰ | NumPy 2.5.3, one core | +|---|---:|---:|---| +| float64 | 53.5 → **7.28 µs** (−86%) | 3.48 → **0.472 ms** (−86%) | 11.8 µs / 0.615 ms | +| float32 | 49.9 → **4.58 µs** (−91%) | 3.13 → **0.289 ms** (−91%) | 7.13 µs / 0.324 ms | + +`Max` is now faster than NumPy on the POWER9 for both widths, and the `Sum` +controls are flat. A sabotaged kernel, with one of the four NaN-mask ORs +removed, fails the float32 and float64 oracle tests there. +- s390x: done in v0.23.0, below. + +### s390x float32 `Max`/`Min`, and the z14 float32 kernels measured (2026-10-26) + +The s390x kernels use `VFMAXSB`/`VFMINSB` in mode 1 (Java's Math.max/min): +a NaN operand gives NaN, and −0 orders below +0. Four four-lane accumulators +are folded by an 8-byte `VSLDB` rotation, then lane 1 onto lane 0, behind the +`useVXE` gate (z14). + +The IBM Z (LinuxONE) had looked unreachable since 2026-10-25, but the access +had not expired. After the macOS upgrade the SSH agent held no key, and the +`id_ed25519` the server accepts needs a passphrase. With the key loaded +again, everything was measured on the real machine (`abbench`, 6 rounds, +alternated, pinned core): + +| float32, IBM Z | 2¹⁴ | 2²⁰ | base | +|---|---:|---:|---| +| `Max` | **−98.3%** | **−97.2%** | v0.22.0 (Go loop) | +| `Add` | −80.2% | −85.3% | v0.17.0 | +| `AddInto` | −91.9% | −89.1% | v0.17.0 | +| `Sqrt` | −81.7% | −82.7% | v0.17.0 | + +The float64 controls are flat in both runs. + +On the machine, a sabotaged kernel (one `VFMINSB` lane in `max32VXE`) +fails the sequential-oracle test. The kernel suite passes with +`NDARRAY_REQUIRE_VXE=1`, and so does the root suite. Since #68 the s390x qemu +lane in CI also runs VX and VXE, on qemu's `max` CPU. +- riscv64: done in v0.22.0, below. + +### riscv64 `Max`/`Min` and a sum defect (2026-10-26) + +The RVV kernels for float64 and float32 work in three steps: + +- **Accumulate:** each strip is folded into a register group with `vfmax.vv` + (or `vfmin.vv`). The tail-undisturbed policy keeps a short last strip from + touching the other lanes. +- **Count NaNs:** RISC-V's `fmax` returns the non-NaN operand, so each strip + also counts its NaN lanes (`vmfne.vv x, x`, then `vcpop.m`), and a non-zero + count gives NaN. +- **Fold:** `vfredmax.vs` reduces the group at the end. `fmax` already orders + −0 below +0. + +On a SpacemiT X60 (`abbench`, 6 rounds, alternated): + +| `Max`, 2¹⁴ / 2²⁰ | change | +|---|---:| +| float64 | **−89% / −83%** | +| float32 | **−93% / −90%** | +| `Sum` (controls) | ~ | + +Running the root suite on that real X60 found a defect: +`MeanAxis(1)` of the int64 row `[MaxInt64, MinInt64, −3]` was 0, NumPy's −1. + +- **Cause:** the strip-mined sum kernel took even 3 elements as one vector, + and its unordered reduction added `MaxInt64 + (−3)` first. In float64 that + rounds back to `MaxInt64` and cancels. +- **Fix:** below 8 elements the sum is now sequential, the order NumPy and the + other targets' kernels (whose scalar tails take n < 8 whole) use there. +- **Why CI never saw it:** its riscv64 lane runs under qemu, whose default CPU + has no V, so no RVV kernel had ever run in CI. The lane now enables V + (`QEMU_CPU=rv64,v=true,vlen=256`). It also sets `NDARRAY_REQUIRE_RVV`, so a + CPU without V fails the lane instead of passing on the scalar paths. + +### float32 `MatMul` on the float64 GEMM (2026-10-26) + +float32 `MatMul` ran a generic triple loop, 9× slower than our float64 GEMM. +It now runs on the float64 GEMM: + +- the operands are widened, which is exact and costs O(n²); +- the products are accumulated in float64; +- each element is rounded once to float32. + +That is at least as accurate as a BLAS sgemm, which accumulates in float32, +and every architecture gets the packed, cache-blocked SIMD kernels at once. +With integer-valued operands the result is exact; with random ones it agrees +with the float32 triple loop to float32 precision (`TestMatMulFloat32`). + +| float32 `MatMul` (`abbench`, 5 rounds) | 64² | 256² | float64 control | +|---|---:|---:|---| +| POWER9 | −77% | **−87%** | ~ | +| Zen 3 | −77% | **−90%** | ~ | + +float32 is now within the widening of float64: 86.7 against 54.0 µs at 64², +2.56 against 2.25 ms at 256² on the POWER9. A native float32 GEMM, twice the +lanes per register and half the bytes, could go further; it is the next step +for products. + +### A native float32 GEMM on amd64 and arm64 (2026-10-26) + +The packed GEMM driver (packing, cache blocking, the parallel bands) is now +generic over float32 and float64. Each element type brings its micro-kernel, +its tile and its pack-buffer pools. float32 gets two kernels, generated by +go-asmgen: + +- **arm64:** `gemmMicro8x8S`, a NEON 8×8 tile. It has 16 accumulators and + uses the by-element `FMLA Vd.4S, Vn.4S, Vm.S[i]`, encoded as a `WORD` and + checked against `go tool objdump`. +- **amd64:** `gemmMicro6x16FMA`, the BLIS Haswell sgemm shape: a 6×16 tile, + `VBROADCASTSS` and `VFMADD231PS` into 12 YMM accumulators. It runs only + where the CPU has FMA. + +float32 `MatMul` (and `Dot`, which goes through it) now accumulates in +float32, as NumPy's sgemm does. Without a kernel (no FMA, or the other four +targets) it still widens to the float64 GEMM. The tests check it against a +float32 ikj oracle, bit for bit on integer data, in every block +configuration, on a transposed and offset B view. On random data they bound +it by k·eps32·Σ|ab|. A sabotaged kernel (a wrong A lane on arm64, a wrong A +row on amd64) failed every job that runs one. + +Profiling the first version at 64² found `packA` at 33% of the time, ahead +of the micro-kernel. It gathered A column by column and paid its loop and +bounds checks every MR elements. A row-major A is now read one contiguous +row at a time, which also speeds up float64. + +| `MatMul` (`abbench`, 10 rounds, 1 core) | 64² | 256² | 1024² | +|---|---:|---:|---:| +| Zen 3, float32 | **−61%** | **−56%** | **−55%** | +| Zen 3, float64 | −5.8% | −4.2% | ~ | +| Neoverse N1, float32 | **−64%** | **−60%** | **−57%** | +| Neoverse N1, float64 | ~ | ~ | ~ | + +Against NumPy 2.5.4 (OpenBLAS, `OPENBLAS_NUM_THREADS=1`, the same core): + +| | 64² | 256² | 1024² | +|---|---:|---:|---:| +| Zen 3 float32, ours / NumPy | 15.0 / 8.1 µs | 485 / 366 µs | 26.0 / 23.1 ms | +| Zen 3 float64 | 23.2 / 15.8 µs | 866 / 795 µs | 51.0 / 50.0 ms | +| N1 float32 | 30.4 / 16.2 µs | 1.19 / 0.94 ms | 68.2 / 60.6 ms | +| N1 float64 | 48.2 / 31.3 µs | 2.46 / 1.95 ms | 154 / 123 ms | + +v0.26.1 packs full B panels with one fixed-size array move per row (8 or 16 +elements, lowered to vector moves), where `packB` had copied element by +element. Profiling had put it at 21% of a 64² float32 product. + +| `MatMul` vs v0.26.0 (`abbench`, 10 rounds) | 64² | 256² | 1024² | +|---|---:|---:|---:| +| Zen 3, float32 | −11.6% | −7.1% | −1.7% | +| Zen 3, float64 | −4.4% | −2.3% | ~ | +| Neoverse N1, float32 | −16.4% | −8.9% | ~ | +| Neoverse N1, float64 | −9.5% | ~ | ~ | + +### Reading A in place (2026-10-27) + +v0.27.0 no longer packs a row-major A (unit column stride, positive row +stride). Unpacked-A kernels read it in place: the "rv" gemmsup kernels of +BLIS (`kernels/haswell/3/sup/bli_gemmsup_rv_haswell_asm_s6x16m.c`). B stays +packed, so its last panel is still zero-padded. There is one kernel per tile +height, so a band's bottom rows never read past A's last row. + +- **amd64 (FMA):** each A value is broadcast straight from its row through + a scaled index (`VBROADCASTSS (SI)(R8*2)`), as BLIS does. +- **arm64:** the same idea, but broadcast loads (`VLD1R`) made it 26–78% + slower on a Neoverse N1, apparently because they compete with the FMAs for + the vector pipes. What shipped instead loads 4 floats (2 doubles) of each + row with one plain vector load, and the next k steps read them by lane with + the by-element FMLA. + +| `MatMul` vs v0.26.1 (`abbench`, 10 rounds) | 64² | 256² | 1024² | +|---|---:|---:|---:| +| Zen 3, float32 | −28% | −9.5% | −3.9% | +| Zen 3, float64 | −17% | −5.5% | ~ | +| Neoverse N1, float32 | −20% | −7.7% | ~ | +| Neoverse N1, float64 | −12% | −4.5% | ~ | + +Against NumPy 2.5.4 (OpenBLAS, one core), the ratio ours / NumPy: + +| | 64² | 256² | 1024² | +|---|---:|---:|---:| +| Zen 3 float32 | 1.18 | 1.13 | 1.04 | +| Zen 3 float64 | 1.16 | 1.03 | **0.97** | +| N1 float32 | 1.20 | 1.06 | 1.09 | +| N1 float64 | 1.18 | 1.11 | 1.18 | + +### ppc64le float32 GEMM (2026-10-27) + +`gemmMicro8x16VSX` follows OpenBLAS's POWER9 sgemm +(`kernel/power/sgemm_macros_power9.S`, `LOAD8x16`) and uses no splats. Each +4-float A vector is rotated three ways (`XXSLDWI a, a, $v`), and every +rotation multiplies every B vector: 32 `XVMADDASP` per k step against 6 loads +and 6 rotations. The tile therefore accumulates in a rotated layout, and the +store untangles each 4×4 block with three `XXSEL`s per row. OpenBLAS rotates +B; rotating A costs half as many permutes, because A has two vectors per k +step and B four. + +| float32 `MatMul` vs v0.27.0 (`abbench`, 8 rounds, POWER9) | 64² | 256² | 1024² | +|---|---:|---:|---:| +| widened to the float64 GEMM → native | **−59%** | **−42%** | **−37%** | + +The NumPy 2.5.3 wheel on that host is no reference for products: x @ x takes +0.48 ms at 64² and 2.7 s at 1024², for float32 and float64 alike. That is +14–31× slower than us, consistent with a BLAS that has no POWER9 kernels. A +comparison with a tuned POWER9 OpenBLAS remains to be made. + +### s390x float32 GEMM (2026-10-27) + +`gemmMicro4x16VXE` is the float64 4×8 kernel at twice the lanes: sixteen +four-lane accumulators, the B row in one `VLM`, each A value replicated with +`VLREPF`, and the z14 fused multiply-add `VFMASB`. go-asmgen encodes that +instruction, because Go's assembler has no short-format vector float op. It +needs the vector-enhancements facility 1, so it is gated on `useVXE`, like +the other float32 kernels. Without the facility, float32 products widen to +the float64 GEMM. + +| float32 `MatMul` vs v0.28.0 (`abbench`, 8 rounds, IBM Z) | 64² | 256² | 1024² | +|---|---:|---:|---:| +| widened to the float64 GEMM → native | **−58%** | **−54%** | **−50%** | + +### Reading B in place too, for small products (2026-10-31) + +BLIS's sup kernels pack neither operand: the "rv" kernel advances B by its +row stride (`b += rs_b`) as it advances A by its column stride. v0.29.1 does +the same on the small-product path (below `GemmThreshold`, one worker). When +both operands are row-major, the A-in-place kernels read B's full panels in +place too, advancing B by `ldb` instead of the packed panel's width. Only a +partial last panel is still packed, for its zero padding. The parallel path, +used for larger products, still packs B once and shares it across workers. + +| `MatMul` vs v0.29.0 (`abbench`, 10 rounds) | 64² | 256² | 1024² | +|---|---:|---:|---:| +| Zen 3, float32 | **−16%** | ~ | ~ | +| Zen 3, float64 | **−10%** | ~ | ~ | +| Neoverse N1, float32 | −7.3% | ~ | ~ | +| Neoverse N1, float64 | ~ | ~ | ~ | + +The N1 host was contended (load 31, rows up to ±39%), so its "~" rows show +no regression but are not a precise measurement. On Zen 3, float32 64² now +takes 8.02 µs against NumPy's 8.14: **parity**. float64 takes 16.3 against +15.8 µs (1.03×). + +**Open defect: products are still up to 1.2× slower than OpenBLAS on one +core** at 256² and 1024² on N1, and float32 1024² on Zen 3 is at 1.04×. + +**Open defect: on a busy N1 host we slow down and OpenBLAS does not** +(measured 2026-10-31). The host was at load 21 on 64 cores. Back to back on +the same pinned core, NumPy's times were the usual ones, but ours were about +a third slower than on a quiet host. So the ratio to NumPy grew to: + +| N1, load 21, ours / NumPy | 64² | 256² | 1024² | +|---|---:|---:|---:| +| float64 | 1.27 | 1.40 | 1.54 | +| float32 | 1.39 | 1.25 | 1.27 | + +Ruled out on that host, by A/B: + +- a regression: v0.26.1, v0.27.0 and v0.29.1 are equally slow there; +- reading A in place: v0.26.1, which packs A, is no faster; +- the block sizes: OpenBLAS's `param.h` values (KC 320, MC 240) change + nothing, and NC = 128 or 256 (a B block well inside the 1 MiB L2) moves + float64 by 4–9% but float32 1024² by +4%. + +On the same host quiet again (load 2, 2026-11-01), measured back to back +with NumPy on the same pinned core, the ratio is: + +| N1, load 2, ours / NumPy | 64² | 256² | 1024² | +|---|---:|---:|---:| +| float64 | 1.09 | 1.07 | 1.16 | +| float32 | 1.08 | **1.00** | 1.07 | + +Our float64 1024² went from 192 ms busy to 137 ms quiet, and NumPy's from +124 to 118 ms. So the defect is the sensitivity to a busy host, not the +quiet-host speed. + +Hardware counters (`perf stat`, `perf record -e ll_cache_miss_rd`) locate +it. Per product, at about the same instruction count, we have 4.5× the +L1D refills, 1.6× the L2 refills and 1.7× the last-level read misses of +OpenBLAS (IPC 1.73 against 2.09). The last level is shared, so contention +there costs us more. The misses fall in the micro-kernel: 36.7M in +`gemmUA4x8D`, against 23.4M in OpenBLAS's kernel plus 2.1M in its A +packing and 1.7M in its B packing (about 10 products each). + +Six candidate causes were tested by A/B with counters, and none closes the +gap: + +- a regression; +- reading A in place; +- block sizes, alone (KC 320, MC 240; NC 128, 256, 4096); +- writing C (a fresh, zeroed C against a reused one: misses ±1%); +- software prefetch after `dgemm_kernel_8x4.S` (L2 refills 14.1M → 6.2M, + but last-level misses unchanged and the times worse); +- OpenBLAS's whole structure at once: packed A plus KC 320, MC 240, + NC 4096 (last-level misses 56M against OpenBLAS's 31M). + +What is left is how the micro-kernel itself touches memory. Closing it +would mean rewriting the N1 kernel after OpenBLAS's instruction by +instruction. + +On Zen 3, by contrast, float64 at 1024² is 3% faster than NumPy. + +The float32 kernels for riscv64 and loong64 are written (PRs #80 and #81) +but not yet measured on hardware. + +**Open: the allocating operations.** `Add` is still 1.5–3.2× slower than +NumPy's `a+b`, for float64 as well. The kernel is no longer the difference, +as `AddInto` shows; the allocation is. It splits like this, measured +2026-10-26 (float64 `Add`, 2¹⁴ elements, one POWER9 core, 3 runs each): + +| | time | what it adds | +|---|---:|---| +| `AddInto` (no allocation) | 5.7 µs | the kernel | +| `Add`, `GOGC=off` | 13.5–15.0 µs | +8 µs: a fresh 128 KiB, faulted in and zeroed | +| `Add`, `GOGC=400` | 15.7 µs | fewer GC cycles | +| `Add`, default GC | 23.5 µs | +9 µs of GC cycles | +| NumPy 2.5.3 `a+b` | 8.48 µs | its allocator returns the buffer just freed, still in cache | + +Neither part is the library's to remove. Go zeroes every new allocation, and +the GC's pace belongs to the program. With a live heap this small, a GC +starts every few dozen results, so the benchmark is the worst case. A program +that holds more data runs the GC far less often per operation. + +What does remove it: + +- **Reuse buffers:** the `*Into` methods with a reused `out`, or a + `Workspace` for chains of operations. +- **Tune the GC:** `GOGC` or `GOMEMLIMIT` for a program dominated by + short-lived results. + +### float32 `Exp` and `Log` on the float64 kernels (2026-11-01) + +float32 `Exp` and `Log` called `math.Exp` and `math.Log` per element: 6–14× +slower than NumPy's float32 SIMD on one Zen 3 core. They now run this +package's float64 kernels (the ports of Arm's optimized-routines, 0.504 ULP +worst case in float64). Elements go 256 at a time through a stack buffer: +widened (exact), computed in place, rounded once. Nothing is allocated. The +result is within 0.5 + 2⁻²⁹ float32 ULP, so correctly rounded except +next to a rounding midpoint. Overflow and underflow come from the rounding: +exp(89) is finite in float64 and rounds to +Inf. + +| float32, one Zen 3 core (`abbench`, 8 rounds) | before | after | NumPy 2.5.4 | after / NumPy | +|---|---:|---:|---:|---:| +| `Exp`, 16 Ki | 297 µs | 84 µs | 26 µs | 3.2 | +| `Log`, 16 Ki | 198 µs | 99 µs | 32 µs | 3.1 | +| `Exp`, 1 Mi | 18.8 ms | 5.2 ms | 1.73 ms | 3.0 | +| `Log`, 1 Mi | 12.4 ms | 6.1 ms | 2.02 ms | 3.0 | + +The tests hold the results to `float32(math.Exp/Log(x))`, within one ULP and +equal on at least 99.9% of 100 000 inputs. They also check the edges +(±0, ±Inf, NaN, overflow, the subnormals, log of a negative), every length +across the chunk boundaries, in-place use and the parallel split. + +#### Correctly rounded, proven over every float32 (2026-11-01) + +A float32 function has 2³² inputs, so correct rounding can be checked +exhaustively instead of by sampling. `TestExp32CorrectlyRounded` and +`TestLog32CorrectlyRounded` (set off by `NDARRAY_EXHAUSTIVE=1`; CI samples +every 65 537th input) run every bit pattern. Each result is certified one of +three ways: + +- the float64 intermediate, within 0.504 float64 ULP of the exact value, is + more than 2 float64 ULP from both float32 rounding midpoints; +- or it is far outside float32's range; +- or a 300-bit reference decides the rounding. + +The certifier is itself tested. Planted values on a midpoint, one ULP from +one, and at the overflow boundary are not certified; that last one was a bug, +caught by its test. A kernel sabotaged to round one ULP up fails 76% of exp +and 50% of log inputs. + +Results, identical on darwin/arm64, linux/amd64 (Zen 3), linux/ppc64le +(POWER9), linux/s390x (IBM Z) and linux/riscv64 (Spacemit X60), each run on +the real machine (loong64: CI's sampled run only, no hardware access): + +| | inputs | decided by the reference | not correctly rounded | +|---|---:|---:|---:| +| `Exp` | 4 294 967 296 | 2 | **0** | +| `Log` before the table | 4 294 967 296 | 24 | 5 | +| `Log` with the table | 4 294 967 296 | 24 | **0** | + +`Log`'s double rounding was wrong for 5 inputs, e.g. log(9.47263622): +`0x400fe5e8` instead of `0x400fe5e7`. The fix is the one correctly-rounded +libraries use. A result within 8 float64 ULP of a float32 midpoint (the 29 +dropped bits are 0x10000000 ± 8) is looked up in a table of the 24 inputs +that come within 2 ULP, each with its correctly rounded result from the +300-bit reference. The check costs nothing measurable (−4%, p = 0.07, on a +host at load 15). + +So float32 `Exp` and `Log` are **correctly rounded**: every result is the +float32 nearest the exact value. NumPy's float32 SIMD kernels are documented +at 2.52 and 3.83 ULP in their sources. + +#### float32 `Exp` in AVX2, correctly rounded and as fast as NumPy (2026-11-02) + +NumPy's float32 exp gets its speed in float32 SIMD and gives up accuracy to +do it: 2.52 ULP in its source. We wanted both. Arm's optimized-routines has a +vector `expf` that is correctly rounded (`math/aarch64/advsimd/cr_expf.c`, +MIT). It widens to float64 and uses a 64-entry table of 2^(i/64) plus a +degree-5 polynomial, cheap enough for SIMD. + +- **`expCR`** transcribes it one operation per line, every fused + multiply-add as `math.FMA`, so a kernel can repeat it bit for bit. Its + table is re-derived at 300 bits (`TestExpCRTable`). Its float64 error is + 1.006 ULP, so the exhaustive certifier uses a 4-ULP margin for it. + Over all 2³² float32 inputs: 7 decided by the 300-bit reference, + **0 incorrectly rounded**, with no exception table needed. +- **`exp32CRAVX2`** (go-asmgen, amd64 with AVX2 and FMA) runs the same + operations eight floats at a time. It widens with `VCVTPS2PD`, gathers the + table with `VPGATHERQQ`, assembles the exponent from `z << 46`, and rounds + once with `VCVTPD2PS`. Inputs are clamped to [−512, 512], which keeps the + float32 result: +Inf above, 0 below. NaN lanes are restored from the input. + It is bit-identical to `expCR` on 100 000 inputs, every special value and + every length 0–70, it is fenced, and on a Zen 3 the exhaustive test, which + runs it, finds **0 incorrectly rounded** of 4 294 967 296. +- Every architecture now runs `expCR`: the AVX2 kernel where there is one, + the Go transcription elsewhere. So the bits are the same everywhere. + +| float32 `Exp`, one Zen 3 core | v0.32.0 | AVX2 | NumPy 2.5.4 | AVX2 / NumPy | +|---|---:|---:|---:|---:| +| 16 Ki | 90 µs | **26.9 µs** | 27.1 µs | **0.99** | +| 1 Mi | 5.8 ms | **1.48 ms** | 1.66 ms | **0.89** | + +These are +235% and +289% in throughput (`abbench`, 8 rounds, p = 0.000). The +host was at load 59 on 128 cores; NumPy ran in the same session on the same +pinned core. + +#### float32 `Log` in AVX2, correctly rounded and as fast as NumPy (2026-11-02) + +float32 `Log` was 3× slower than NumPy (`simd_log_f32`, 3.83 ULP in its +source). optimized-routines has no correctly rounded logf; CORE-MATH has one +(`src/binary32/log/logf.c`, Alexei Sibidanov and Paul Zimmermann, MIT). + +- **`logCR`** transcribes it. A 64-entry table and a degree-3 polynomial in + float64 give a lower bound r of log x, with r + 0x1.f06p-33 an upper + bound; when both round to the same float32, that is the answer. Otherwise + a degree-8 polynomial decides: 762 750 of the 2³¹ positive inputs, about + 1 in 2800. Every product is converted to float64 explicitly, which Go + defines as rounding it, so no architecture fuses it into an FMA. +- **Proof, two ways, over all 2³² inputs:** `logCR` equals v0.33.0's `log32` + (widened float64 `Log` plus a 24-entry exception table, no shared code) on + every input, and `Log32` is held to the 300-bit reference by + `TestLog32CorrectlyRounded`: **0 incorrectly rounded**, on darwin/arm64 + (Go) and on Zen 3 (AVX2). +- **`log32CRAVX2`** (go-asmgen, amd64 with AVX2) runs the fast path on eight + floats at a time. The integer part is done in 32-bit lanes, the tables are + gathered with `VGATHERDPD`, and the arithmetic is `VMULPD`/`VADDPD` in + `logCR`'s order, never fused. A block holding a lane that is not a positive + normal float32, or whose bounds round apart, is not stored; it is handed + back to `logCR`. x = 1 gives +0 in the vector. The kernel is bit-identical + to `logCR` on 300 000 inputs and every length 0–70, in place too, and it + is fenced. +- The old via64 `log32` and its exception table now live in a test file as + the oracle; the 24 hardest inputs stay a regression test. + +| float32 `Log`, 16 Ki in [0.25, 24.25), one Zen 3 core, load 0.35 | time | +|---|---:| +| v0.33.0 (via64) | 91.5 µs | +| `logCR` in Go | 82 µs | +| **`log32CRAVX2`** | **31.1 µs** | +| NumPy 2.5.4, `np.log(a, out=o)` | 31.0 µs | + +Through the API (`BenchmarkDTypeExpLog`, allocating), against v0.33.0, +`abbench` 8 interleaved rounds with load at most 8: −65% at 16 Ki (104.6 → +37.1 µs) and −68% at 1 Mi (6.74 → 2.16 ms), p = 0.000. + +The four gathers per block are not the bottleneck: replacing the `tl` gather +by a register copy saves at most 8%. Doing better than NumPy needs a +different algorithm, not a tweak of this one. + +#### Through the API, float32 results come from the Workspace (2026-11-07) + +The table above compares kernels. Through the API, `Log` on a Float32 array +was still 1.25× NumPy at 16 Ki and 1.16× at 1 Mi (35.5 µs against 28.3 µs, +2.07 ms against 1.79 ms; `BenchmarkDTypeExpLog`'s data, 0.25 + (i%97)·0.25, +allocating on both sides). The difference is the result's allocation: +`make` alone costs 4.1 µs at 16 Ki and 130 µs at 1 Mi on that core, because Go +takes a large span from the heap and zeroes it, where NumPy reuses an +unzeroed buffer. On this data the kernel hands no block back to `logCR` +(`handed-blocks/op` in `BenchmarkLog32Kernels`). + +- **Float32 in a Workspace.** The Workspace used to give only float64 + memory, so every Float32 result went to the heap even inside one. + `Workspace.alloc32` now carves float32 results from the same blocks (two + per float64 element, 64-byte aligned, cap = len). It is used for `Exp`, + `Log`, the other float32 ufuncs, arithmetic, `Power`, `Clip` and the + float32 GEMM (zeroed there, since the GEMM accumulates). + `TestWorkspace32MatchesHeap` holds every one of them to the heap result + bit for bit, also after the arena is filled with NaN bits; setting the + GEMM's allocation to unzeroed makes it fail. +- **The kernel's range check** moved to the end of the block: one wrapping + add and one compare, ux + 0x7f800000 ≤ −0x01000001 as int32, select + exactly the positive normals. The separate test and branch before the + arithmetic are gone: −2.4% (`abbench`, 8 rounds, p ≤ 0.03 on three of four + sizes). + +| float32, one Zen 3 core, load ≤ 4.2 | 16 Ki | 1 Mi | +|---|---:|---:| +| `Exp`, allocating | 24.2 µs | 1.39 ms | +| `Exp` in a Workspace | **19.4 µs** | **1.23 ms** | +| NumPy 2.5.4, `np.exp(a)` | 22.0–22.3 µs | 1.37–1.47 ms | +| `Log`, allocating | 34.5 µs | 2.06 ms | +| `Log` in a Workspace | **29.9 µs** | **1.90 ms** | +| NumPy 2.5.4, `np.log(a)` | 28.1–28.8 µs | 1.77–1.82 ms | + +Ours are medians of six runs in two interleaved rounds; NumPy's ranges are +the medians of nine of every quiet round of the day (one 1 Mi `exp` round at +2.47 ms and two 16 Ki `log` rounds at 31–34 µs are left out, which favours +NumPy). In a Workspace, float32 `Exp` is about 12% faster than NumPy and +`Log` about 5% slower; NumPy computes both in float32, at 2.52 and 3.83 ULP. +Allocating, Go's zeroing of each result adds 4 µs at 16 Ki. + +#### float64 `Log` in AVX2: 1.55× faster than NumPy (2026-11-07) + +float64 `Log` was 1.23× slower than NumPy at 16 Ki on Zen 3 (85 µs against +69 µs). NumPy's 4.2 ns per element is glibc's scalar log, which is +optimized-routines' log.c, the code ours ports; the gap was our scalar Go. + +**`logAVX2`** (go-asmgen, AVX2 and FMA) runs `Log`'s table path on four +doubles at a time, with `Log`'s operations in its order: the one FMA it has +(r = z·invc − 1) as `VFMADD231PD`, every other product and sum as a separate +`VMULPD`/`VADDPD`, which is how Go compiles them on amd64. +- `invc` and `logc` are gathered from the 16-byte table rows with + `VPGATHERQQ`. +- AVX2 has no 64-bit arithmetic shift and no int64→float64 conversion, so + the exponent kd = int64(tmp)>>52 is built exactly from u = tmp>>52: + the float64 with bits 0x4330000000000000 | (u ^ 0x800), minus 2^52 + 2048. +- A block holding a lane that is not a positive normal, or one within 2⁻⁴ + of 1 (`Log`'s polynomial path), is not stored; it is handed back to the Go + loop. + +`TestLogAVX2MatchesGo` holds it to the Go loop bit for bit on 300 000 inputs +covering every exponent and table index, near 1, the special values, every +length 0–70 and in place. Dropping the `+ kd·ln2lo` term fails it, while +`TestLogULP` alone (0.5078 ULP worst, unchanged) would not see it. Fenced; +the constant block's layout is tested. + +| float64 `Log`, one Zen 3 core, load ≤ 3.7 | 16 Ki | 1 Mi | +|---|---:|---:| +| v0.35.0 | 83.9 µs | 5.06 ms | +| **AVX2** | **44.4 µs** | **2.59 ms** | +| NumPy 2.5.4, `np.log(a)` | 68.9–69.4 µs | 4.33–4.41 ms | + +−47% and −49% against v0.35.0 (`abbench`, 8 interleaved rounds, p = 0.000), +both allocating, on `BenchmarkDTypeExpLog`'s data. + +Since v0.37.0 these kernels live in go-simd/floats (`floats.Exp`, `Log`, +`Float32Exp`, `Float32Log`, go-simd/floats#29), with NEON versions of the +float32 pair for arm64; ndarray calls them (`internal/kernels/explog.go`). +The NEON kernels are proven correctly rounded over all 2³² inputs on +darwin/arm64 but were not benchmarked: every arm64 host available was at +load 48–100. ppc64le, s390x, riscv64 and loong64 still run the Go +transcriptions. + +#### float64 `Exp` on 16 cores: the allocation, not the kernel (2026-11-08) + +The README listed float64 `Exp` below 256 Ki as slower than NumPy (0.82×), +measured with 16 cores. On one core it is faster at every size. With +GOMAXPROCS = 16 the same serial code (below `ParThreshold`, 32 Ki, nothing +is split) got slower than with 1: 89 µs against 73 at 16 Ki. The same holds +for `Add`, which goes from 14 to 23–26 µs, while `AddInto`, which allocates +nothing, costs 4.3 µs at both. Each result's allocation costs more with more +Ps: every garbage-collector cycle starts and stops work on all of them. In a +`Workspace` (`BenchmarkExpWS`) the difference disappears: + +| float64 `Exp`, Zen 3, load ≤ 5 | 1 Ki | 16 Ki | 256 Ki | 4 Mi | +|---|---:|---:|---:|---:| +| NumPy 2.5.4, `np.exp(a)` (one thread) | 6.0 µs | 85.5 µs | 1.32 ms | 25.0 ms | +| ours, 1 P, allocating | 4.9 µs | 73 µs | 1.11 ms | 19.3 ms | +| ours, 16 P, allocating | 5.8–6.6 µs | 83–95 µs | 0.63–0.78 ms | 4.8–6.7 ms | +| ours, 16 P, `Workspace` | **4.1 µs** | **64 µs** | **0.18 ms** | **1.47 ms** | + +`BenchmarkExp` and `BenchmarkExpWS` (`(i%97)·0.5`, the README's data), two +rounds each at `-cpu 1` and `-cpu 16` on cores 0–15; NumPy medians of nine +on one pinned core. In a `Workspace` on 16 cores, float64 `Exp` is 1.3–1.5× +NumPy up to 16 Ki and 7–17× from 256 Ki. Allocating, it is at parity up to +16 Ki, and at 4 Mi the allocation of each 32 MB result takes about 3.3 ms of +the 4.8. + +## SIMD coverage + +- **amd64 (SSE2)** ships hand-vectorized `sum` (4-accumulator `ADDPD`), `sqrt` + (packed `SQRTPD`), `max`/`min` (`MAXPD`/`MINPD` + `CMPPD` NaN scan), the + **elementwise `add`/`sub`/`mul`/`div`** (packed `ADD/SUB/MUL/DIVPD`, 8 + doubles/iter + scalar tail), all at the GOAMD64=v1 baseline, and the **GEMM + micro-kernel**: `gemmMicro6x8FMA` (6×8, 12 YMM accumulators, `VBROADCASTSD` + + `VFMADD231PD`, C and A prefetched) when go-asmgen's CPUID probe reports FMA + with the OS saving YMM state, else a pair of SSE2 6×4 `MULPD`+`ADDPD` tiles + over the same packing. Generated by go-asmgen and validated per-arch in CI; + the FMA kernel on real Cascade Lake and Zen 3 hosts. float32 products have + their own FMA kernel, `gemmMicro6x16FMA` (6×16, `VFMADD231PS`). +- **arm64 (NEON)** ships hand-vectorized `sum`, `dot`, **packed `sqrt`** + (`VFSQRT`), the **elementwise `add`/`sub`/`mul`/`div`** (`VFADD`/`VFSUB`/ + `VFMUL`/`VFDIV`), **`max`/`min`** (`VFMAX`/`VFMIN`, four D2 accumulators from + 8 elements up) and the **GEMM micro-kernel** (`gemmMicro4x8`: a 4×8 tile, 16 D2 + accumulators, using the **by-lane FMLA** `FMLA Vd.2D, Vn.2D, Vm.D[i]`), with + a float32 one, `gemmMicro8x8S` (8×8, `FMLA Vd.4S, Vn.4S, Vm.S[i]`). Only + the indexed-element FMLA is still a raw `WORD` (16 of them): Go's assembler + does not name it yet (pending as CL 764224). + + ⚠ **Corrected in v0.5.2.** Up to v0.5.1 this paragraph said Go's arm64 + assembler had no vector `FSQRT`, no plain vector FP add, and no vector form for + `div`, so `add`/`sub`/`mul` went through an exact FMA against `1.0`, `sqrt` was + a `WORD`, and `div` and `max`/`min` stayed scalar. Go 1.27 (the floor since + v0.5.0) names all of them; the ISA always had them. Measured on one core, + interleaved against v0.5.1 (8 rounds, median; `Sum`, `SqrtInto` and `AddInto`, + unchanged, measured at 1.00× as the control): + + | one core | `DivInto` | `Max` | `MulInto` | + |---|---|---|---| + | Apple M (local) | 1.34–2.19× | 1.87–2.24× | 1.21× at 1 Ki, 1.00× above | + | X-Gene (cfarm185) | 1.00× | 1.18–1.41× | 1.08–1.35× | + + X-Gene's vector divide is no faster than its scalar one. The sum, dot and + GEMM C-accumulate still fold with an exact `VFMLA` against `1.0`, written + before `VFADD` existed in Go; the result is the same. +- **ppc64le (VSX)**, since v0.2.3: sum, dot, sqrt, add/sub/mul/div and an 8×8 + GEMM micro-kernel; max/min stay scalar (the ISA's `xvmaxdp` NaN rule is not + NumPy's). See the ppc64le section above. +- **loong64 (LASX)**, since v0.2.5, when the kernel reports LASX in AT_HWCAP + (not every LoongArch CPU has it): sum, dot, sqrt, add/sub/mul/div and an 8×8 + GEMM micro-kernel; max/min stay scalar. See the loong64 section above. +- **riscv64 (RVV)**, since v0.7.0, when the kernel reports V in AT_HWCAP (the + vector extension is optional): sum, dot, sqrt and add/sub/mul/div, in + vector-length-agnostic loops (`VSETVLI` strip-mining, LMUL=8, no scalar + tail), every instruction a Go mnemonic. max/min stay scalar: RVV's + `vfmax`/`vfmin` implement IEEE 754-2019 maximumNumber, which drops a NaN + that NumPy propagates. Since v0.7.1 the GEMM micro-kernel is a 6×8 RVV tile + (see below). On a SpacemiT X60 (cfarm95, VLEN = 256), one core pinned to an idle CPU, 6 + interleaved rounds against the scalar code (median): + + | | 1 Ki | 16 Ki | 256 Ki | + |---|---|---|---| + | `Sum` | 5.81× | 2.51× | 1.84× | + | `AddInto` / `MulInto` | 3.2–3.3× | 1.11–1.13× | 1.07–1.12× | + | `DivInto` | 2.27× | 2.30× | 2.28× | + | `SqrtInto` | 2.43× | 2.44× | 2.47× | + + `MatVec` 1024² is 2.02× faster and `Dot` 2^20 1.29×. On a riscv64 CPU without V + (cfarm94, SiFive U74), the same build runs the scalar path and passes the same + tests. + + **GEMM micro-kernel (v0.7.1).** NR is fixed by the packing, while VLEN varies + between RISC-V cores. The V extension guarantees VLEN ≥ 128, so a group of + four registers (LMUL=4) holds at least eight float64, and `VSETIVLI $8` + gives vl = 8 on every core: a 6×8 tile, six accumulator groups, the B row + loaded once per step and each A value fused in with `vfmacc.vf`. Without V, + the same 6×8 tile runs in Go. Measured with `hostload -pick 1` to choose an + idle CPU on a host otherwise 64% busy, and `benchstat` over 8 interleaved runs + each against v0.7.0's scalar 4×4 (all p = 0.000): + + | one core, X60 | 64² | 128² | 256² | 512² | 1024² | + |---|---|---|---|---|---| + | `MatMul` time | −31% | −32% | −45% | −43% | −44% (1.79 s → 1.00 s) | + + `Inner` is −44%, and the small rectangular products are −22% to −38%. At about + 2.1 GFLOP/s on one core this is still far from the hardware: VLEN = 256 with + LMUL=4 uses half of each register group, which keeps one tile portable to + every VLEN at that cost. +- **s390x (vector facility)**, since v0.8.0, when AT_HWCAP reports it: + sum, dot, sqrt and add/sub/mul/div, eight float64 per loop in four + two-lane registers plus a scalar tail, every instruction a Go mnemonic. + Since v0.9.0 the GEMM micro-kernel is a 4×8 tile: sixteen two-lane + accumulators, the B row in one `VLM`, each A value replicated with `VLREPG` + and fused in with `VFMADB`. Since v0.9.1, max/min are vector too on a z14 + or later: `VFMAXDB`/`VFMINDB` in mode 1, Java's Math.max/min, which gives NaN + for a NaN operand and orders −0 below +0, NumPy's rule. They come with the + vector-enhancements facility 1 (AT_HWCAP VXRS_EXT), beyond the z13 that Go + also supports, where the scalar reducers remain. On the z17, one core, + `benchstat` over 8 interleaved runs, all at p = 0.000: `Max` takes 92% to 96% + less time (1 Ki: 2.58 µs → 112 ns; 4 Mi: 10.7 ms → 0.81 ms), and `MaxAxis(1)` + 94% less. `Sum`, untouched, measured within ±0.6%: the control. A sabotaged copy in + mode 0 (IEEE maxNum, which drops a NaN) failed `TestMaxMinSIMD` on the z17. + The operand order of + `VFSDB`/`VFDDB`/`VFMADB` was **measured**, not read: each run once on known + inputs (`VFSDB V1, V2, V3` is V3 = V2 − V1). AT_HWCAP is read in the + machine's byte order, which on big-endian s390x is not the little-endian the + parser first assumed. + + v0.8.0 shipped these kernels validated under emulation only. Since then they + run on **real hardware**: an IBM z17 (Type 9175), through the LinuxONE + Community Cloud, as a z/VM 7.3.0 guest with 8 vCPUs in a shared LPAR. There + the whole suite passes, as do the fence tests (a kernel reading one element + too far faults), `TestParallelStress` (27,752 calls) and two sabotaged GEMM + and elementwise kernels, which fail as they should. Measured with `hostload` + (0% busy, 0% steal) and `benchstat`, 8 interleaved runs each, every + difference at p = 0.000 unless marked: + + | IBM z17, time vs the scalar code | 1 core | 8 cores | + |---|---|---| + | `Sum` | −85% to −87% | −82% to −87% | + | `AddInto` / `MulInto` | −44% to −61% | −36% to −60% | + | `DivInto` | −6% to −18% | −6% to −22% | + | `SqrtInto` | −2% to −11% | −5% to −9% at 1–16 Ki, ~ above | + | `MatVec` 1024² | −44% | −40% | + | `Dot` 2^20 | −11% | ~ (p = 0.08) | + | `MatMul` 1024² (v0.9.0 GEMM) | 340 → 70 ms (−79%) | 43 → 10 ms (−76%) | + | GEMM geomean, 11 shapes | −74% | −66% | + + `Exp`, which none of this touches, measured within ±0.3% on one core: the + control. +- The 32-bit targets keep + the validated scalar oracles, using the same four-accumulator max/min, direct + sqrt loop, and a **scalar 4×4 GEMM micro-kernel** over the packed panels, and + still get the **packing + cache blocking + multicore** structure (they have + not been measured against NumPy). What the Go assembler offers them, checked + on Go 1.26.4 and 1.27.1 by assembling and disassembling (2026-10-04): + **ppc64le** has no vector-double arithmetic (no `XVADDDP`/`XVMADDADP`), which + go-asmgen v0.13.0 now encodes as `WORD`s, so ppc64le has kernels; + **loong64** has vector-double add/sub/mul/div (`VADDD` assembles to + `vfadd.d`, `VMULD` to `vfmul.d`, `XVADDD` to `xvfadd.d`) and the broadcast + load (`XVMOVQ off(R), X.V4` = `xvldrepl.d`), but no vector FMA, which + go-asmgen v0.14.0 encodes (transitionally), so loong64 has kernels; + **s390x** has them, FMA included (`VFADB`, `VFMADB`); **riscv64** has them + (`VFADDVV`, `VFMACCVV`) and has had kernels since v0.7.0, s390x since v0.8.0. + (s390x additionally exercises the big-endian path in CI.) + +All six are exercised in CI (native amd64/arm64 + qemu for the rest); each +per-arch job regenerates the committed `.s`, fails if it is stale, vets +(asmdecl), builds (cmd/asm encodes), and runs the bit/NaN-correctness suite. The +multicore path and the packed/cache-blocked GEMM driver are +architecture-independent; only the GEMM micro-kernel is per-arch (NEON 4×8 on +arm64, AVX2/FMA 6×8 or SSE2 on amd64, scalar 4×4 on the other four), each held to +the scalar ikj oracle in CI: exactly on integer-valued data, within tolerance on +general data, since above KC the block partials regroup the sum and the FMA +kernels round once per step where the scalar oracle may round twice. diff --git a/docs/perf-table.md b/docs/perf-table.md new file mode 100644 index 0000000..64f6ee8 --- /dev/null +++ b/docs/perf-table.md @@ -0,0 +1,270 @@ +# Performance table + + + +- Date: 2026-11-08T12:33:21+01:00 +- Commit: +- Go: go1.27.3 +- Machine: darwin/arm64, 16 CPUs +- Timing: **deferred** (no verdict; allocation counts only) + +ns/call, median [first quartile, third quartile]; ratio = Go / reference. + +| case | function | reference | Go | reference | ratio | verdict | parity | allocs | +|---|---|---|---:|---:|---:|---|---|---:| +| Array.Abs | `Array.Abs` | numpy np.abs | — | — | — | deferred | none | 5 | +| Array.Neg | `Array.Neg` | numpy np.negative | — | — | — | deferred | none | 5 | +| Array.Sqrt | `Array.Sqrt` | numpy np.sqrt | — | — | — | deferred | none | 5 | +| Array.Square | `Array.Square` | numpy np.square | — | — | — | deferred | none | 5 | +| Array.Exp | `Array.Exp` | numpy np.exp | — | — | — | deferred | none | 5 | +| Array.Log | `Array.Log` | numpy np.log | — | — | — | deferred | none | 5 | +| Array.Log2 | `Array.Log2` | numpy np.log2 | — | — | — | deferred | none | 5 | +| Array.Log10 | `Array.Log10` | numpy np.log10 | — | — | — | deferred | none | 5 | +| Array.Sin | `Array.Sin` | numpy np.sin | — | — | — | deferred | none | 5 | +| Array.Cos | `Array.Cos` | numpy np.cos | — | — | — | deferred | none | 5 | +| Array.Tan | `Array.Tan` | numpy np.tan | — | — | — | deferred | none | 5 | +| Array.Ceil | `Array.Ceil` | numpy np.ceil | — | — | — | deferred | none | 5 | +| Array.Floor | `Array.Floor` | numpy np.floor | — | — | — | deferred | none | 5 | +| Array.Round | `Array.Round` | numpy np.round | — | — | — | deferred | none | 5 | +| Array.Conj | `Array.Conj` | numpy np.conj | — | — | — | deferred | none | 4 | +| Array.Real | `Array.Real` | numpy np.real | — | — | — | deferred | none | 4 | +| Array.Imag | `Array.Imag` | numpy np.imag | — | — | — | deferred | none | 4 | +| Array.Angle | `Array.Angle` | numpy np.angle | — | — | — | deferred | none | 16 | +| Array.LogicalNot | `Array.LogicalNot` | numpy np.logical_not | — | — | — | deferred | none | 8 | +| Array.Copy | `Array.Copy` | numpy lambda x: x.copy() | — | — | — | deferred | none | 4 | +| Array.Flatten | `Array.Flatten` | numpy lambda x: x.flatten() | — | — | — | deferred | none | 4 | +| Array.Ravel | `Array.Ravel` | numpy lambda x: x.ravel() | — | — | — | deferred | none | 4 | +| Array.Power | `Array.Power` | numpy lambda x: np.power(x, 2.5) | — | — | — | deferred | none | 6 | +| Array.CumSumFlat | `Array.CumSumFlat` | numpy np.cumsum | — | — | — | deferred | none | 5 | +| Array.Nonzero | `Array.Nonzero` | numpy np.flatnonzero | — | — | — | deferred | none | 35 | +| Array.Repr | `Array.Repr` | numpy repr | — | — | — | deferred | none | 63 | +| Array.AsType | `Array.AsType` | numpy lambda x: x.astype(np.float32) | — | — | — | deferred | none | 6 | +| Array.Add | `Array.Add` | numpy np.add | — | — | — | deferred | none | 3 | +| Array.Sub | `Array.Sub` | numpy np.subtract | — | — | — | deferred | none | 3 | +| Array.Mul | `Array.Mul` | numpy np.multiply | — | — | — | deferred | none | 3 | +| Array.Div | `Array.Div` | numpy np.divide | — | — | — | deferred | none | 3 | +| Array.Maximum | `Array.Maximum` | numpy np.maximum | — | — | — | deferred | none | 2 | +| Array.Minimum | `Array.Minimum` | numpy np.minimum | — | — | — | deferred | none | 2 | +| Array.Equal | `Array.Equal` | numpy np.equal | — | — | — | deferred | none | 8 | +| Array.NotEqual | `Array.NotEqual` | numpy np.not_equal | — | — | — | deferred | none | 8 | +| Array.Less | `Array.Less` | numpy np.less | — | — | — | deferred | none | 8 | +| Array.LessEqual | `Array.LessEqual` | numpy np.less_equal | — | — | — | deferred | none | 8 | +| Array.Greater | `Array.Greater` | numpy np.greater | — | — | — | deferred | none | 8 | +| Array.GreaterEqual | `Array.GreaterEqual` | numpy np.greater_equal | — | — | — | deferred | none | 8 | +| Array.LogicalAnd | `Array.LogicalAnd` | numpy np.logical_and | — | — | — | deferred | none | 12 | +| Array.LogicalOr | `Array.LogicalOr` | numpy np.logical_or | — | — | — | deferred | none | 12 | +| Array.LogicalXor | `Array.LogicalXor` | numpy np.logical_xor | — | — | — | deferred | none | 12 | +| Array.Inner | `Array.Inner` | numpy np.inner | — | — | — | deferred | none | 4 | +| Array.AddInto | `Array.AddInto` | numpy np.add (out= preallocated) | — | — | — | deferred | none | 2 | +| Array.SubInto | `Array.SubInto` | numpy np.subtract (out= preallocated) | — | — | — | deferred | none | 2 | +| Array.MulInto | `Array.MulInto` | numpy np.multiply (out= preallocated) | — | — | — | deferred | none | 2 | +| Array.DivInto | `Array.DivInto` | numpy np.divide (out= preallocated) | — | — | — | deferred | none | 2 | +| Array.AddScalar | `Array.AddScalar` | numpy x + 1.5 | — | — | — | deferred | none | 8 | +| Array.SubScalar | `Array.SubScalar` | numpy x - 1.5 | — | — | — | deferred | none | 8 | +| Array.MulScalar | `Array.MulScalar` | numpy x * 1.5 | — | — | — | deferred | none | 8 | +| Array.DivScalar | `Array.DivScalar` | numpy x / 1.5 | — | — | — | deferred | none | 8 | +| Array.SqrtInto | `Array.SqrtInto` | numpy np.sqrt (out= preallocated) | — | — | — | deferred | none | 1 | +| Array.Clip | `Array.Clip` | numpy np.clip | — | — | — | deferred | none | 4 | +| Array.Dot | `Array.Dot` | numpy np.dot, 128 x 128 (OpenBLAS, one thread) | — | — | — | deferred | none | 13 | +| Array.MatMul | `Array.MatMul` | numpy np.matmul, 128 x 128 (OpenBLAS, one thread) | — | — | — | deferred | none | 13 | +| Array.Outer | `Array.Outer` | numpy np.outer, 1000 x 1000 | — | — | — | deferred | none | 4 | +| Array.Transpose | `Array.Transpose` | numpy x.T (a view; Go may copy) | — | — | — | deferred | none | 3 | +| Array.Reshape | `Array.Reshape` | numpy x.reshape (a view) | — | — | — | deferred | none | 5 | +| Array.ExpandDims | `Array.ExpandDims` | numpy np.expand_dims | — | — | — | deferred | none | 3 | +| Array.Squeeze | `Array.Squeeze` | numpy np.squeeze | — | — | — | deferred | none | 3 | +| Array.Slice | `Array.Slice` | numpy x[0:n:2] (a view) | — | — | — | deferred | none | 3 | +| Array.Take | `Array.Take` | numpy np.take, 1000 indices | — | — | — | deferred | none | 4 | +| Array.MaskSelect | `Array.MaskSelect` | numpy x[mask] (mask x > 2 built outside the call) | — | — | — | deferred | none | 30 | +| Where | `Where` | numpy np.where (mask x > 2 built outside the call) | — | — | — | deferred | none | 9 | +| Concatenate | `Concatenate` | numpy np.concatenate | — | — | — | deferred | none | 4 | +| Stack | `Stack` | numpy np.stack | — | — | — | deferred | none | 10 | +| HStack | `HStack` | numpy np.hstack | — | — | — | deferred | none | 4 | +| VStack | `VStack` | numpy np.vstack | — | — | — | deferred | none | 10 | +| Array.Sum | `Array.Sum` | numpy np.sum | — | — | — | deferred | none | 7 | +| Array.SumAll | `Array.SumAll` | numpy np.sum | — | — | — | deferred | none | 9 | +| Array.Prod | `Array.Prod` | numpy np.prod | — | — | — | deferred | none | 0 | +| Array.ProdAll | `Array.ProdAll` | numpy np.prod | — | — | — | deferred | none | 2 | +| Array.Mean | `Array.Mean` | numpy np.mean | — | — | — | deferred | none | 7 | +| Array.Max | `Array.Max` | numpy np.max | — | — | — | deferred | none | 2 | +| Array.Min | `Array.Min` | numpy np.min | — | — | — | deferred | none | 2 | +| Array.ArgMax | `Array.ArgMax` | numpy np.argmax | — | — | — | deferred | none | 0 | +| Array.ArgMin | `Array.ArgMin` | numpy np.argmin | — | — | — | deferred | none | 0 | +| Array.All | `Array.All` | numpy np.all | — | — | — | deferred | none | 3 | +| Array.Any | `Array.Any` | numpy np.any | — | — | — | deferred | none | 3 | +| Array.CumProdFlat | `Array.CumProdFlat` | numpy np.cumprod | — | — | — | deferred | none | 5 | +| Array.SumAxis/axis0 | `Array.SumAxis` | numpy np.sum(axis=0) | — | — | — | deferred | none | 4 | +| Array.SumAxis/axis1 | `Array.SumAxis` | numpy np.sum(axis=1) | — | — | — | deferred | none | 4 | +| Array.ProdAxis/axis0 | `Array.ProdAxis` | numpy np.prod(axis=0) | — | — | — | deferred | none | 4 | +| Array.ProdAxis/axis1 | `Array.ProdAxis` | numpy np.prod(axis=1) | — | — | — | deferred | none | 4 | +| Array.MeanAxis/axis0 | `Array.MeanAxis` | numpy np.mean(axis=0) | — | — | — | deferred | none | 4 | +| Array.MeanAxis/axis1 | `Array.MeanAxis` | numpy np.mean(axis=1) | — | — | — | deferred | none | 4 | +| Array.MaxAxis/axis0 | `Array.MaxAxis` | numpy np.max(axis=0) | — | — | — | deferred | none | 4 | +| Array.MaxAxis/axis1 | `Array.MaxAxis` | numpy np.max(axis=1) | — | — | — | deferred | none | 4 | +| Array.MinAxis/axis0 | `Array.MinAxis` | numpy np.min(axis=0) | — | — | — | deferred | none | 4 | +| Array.MinAxis/axis1 | `Array.MinAxis` | numpy np.min(axis=1) | — | — | — | deferred | none | 4 | +| Array.ArgMaxAxis/axis0 | `Array.ArgMaxAxis` | numpy np.argmax(axis=0) | — | — | — | deferred | none | 6 | +| Array.ArgMaxAxis/axis1 | `Array.ArgMaxAxis` | numpy np.argmax(axis=1) | — | — | — | deferred | none | 6 | +| Array.ArgMinAxis/axis0 | `Array.ArgMinAxis` | numpy np.argmin(axis=0) | — | — | — | deferred | none | 6 | +| Array.ArgMinAxis/axis1 | `Array.ArgMinAxis` | numpy np.argmin(axis=1) | — | — | — | deferred | none | 6 | +| Array.CumSum/axis0 | `Array.CumSum` | numpy np.cumsum(axis=0) | — | — | — | deferred | none | 5 | +| Array.CumProd/axis0 | `Array.CumProd` | numpy np.cumprod(axis=0) | — | — | — | deferred | none | 5 | +| Array.CumSum/axis1 | `Array.CumSum` | numpy np.cumsum(axis=1) | — | — | — | deferred | none | 5 | +| Array.CumProd/axis1 | `Array.CumProd` | numpy np.cumprod(axis=1) | — | — | — | deferred | none | 5 | +| Arange | `Arange` | numpy np.arange(0.0, 100000.0, 1.0) | — | — | — | deferred | none | 6 | +| Eye | `Eye` | numpy np.eye(512) | — | — | — | deferred | none | 5 | +| Identity | `Identity` | numpy np.identity(512) | — | — | — | deferred | none | 5 | +| Full | `Full` | numpy np.full(100000, 1.5) | — | — | — | deferred | none | 5 | +| FullOf | `FullOf` | numpy np.full(100000, 1.5, dtype=np.float32) | — | — | — | deferred | none | 8 | +| Linspace | `Linspace` | numpy np.linspace(0.0, 1.0, 100000) | — | — | — | deferred | none | 7 | +| New | `New` | numpy np.zeros(100000) | — | — | — | deferred | none | 5 | +| Zeros | `Zeros` | numpy np.zeros(100000) | — | — | — | deferred | none | 5 | +| Ones | `Ones` | numpy np.ones(100000) | — | — | — | deferred | none | 5 | +| OnesOf | `OnesOf` | numpy np.ones(100000, dtype=np.float32) | — | — | — | deferred | none | 8 | +| ZerosOf | `ZerosOf` | numpy np.zeros(100000, dtype=np.float32) | — | — | — | deferred | none | 6 | +| Scalar | `Scalar` | numpy np.array(1.5) | — | — | — | deferred | none | 2 | +| FromData | `FromData` | numpy np.asarray (wraps, no copy) | — | — | — | deferred | none | 5 | +| FromSlice | `FromSlice` | numpy np.asarray (wraps, no copy) | — | — | — | deferred | none | 6 | +| fft.FFT | `fft.FFT` | numpy np.fft.fft | — | — | — | deferred | none | 19 | +| fft.FFTWith | `fft.FFTWith` | numpy np.fft.fft(norm='ortho') | — | — | — | deferred | none | 19 | +| fft.IFFT | `fft.IFFT` | numpy np.fft.ifft | — | — | — | deferred | none | 19 | +| fft.IFFTWith | `fft.IFFTWith` | numpy np.fft.ifft(norm='ortho') | — | — | — | deferred | none | 19 | +| fft.RFFT | `fft.RFFT` | numpy np.fft.rfft | — | — | — | deferred | none | 30 | +| fft.RFFTWith | `fft.RFFTWith` | numpy np.fft.rfft(norm='ortho') | — | — | — | deferred | none | 30 | +| fft.IRFFT | `fft.IRFFT` | numpy np.fft.irfft | — | — | — | deferred | none | 24 | +| fft.IRFFTWith | `fft.IRFFTWith` | numpy np.fft.irfft(norm='ortho') | — | — | — | deferred | none | 24 | +| fft.HFFT | `fft.HFFT` | numpy np.fft.hfft | — | — | — | deferred | none | 29 | +| fft.HFFTWith | `fft.HFFTWith` | numpy np.fft.hfft(norm='ortho') | — | — | — | deferred | none | 29 | +| fft.IHFFT | `fft.IHFFT` | numpy np.fft.ihfft | — | — | — | deferred | none | 35 | +| fft.IHFFTWith | `fft.IHFFTWith` | numpy np.fft.ihfft(norm='ortho') | — | — | — | deferred | none | 35 | +| fft.FFT2 | `fft.FFT2` | numpy np.fft.fft2 | — | — | — | deferred | none | 20 | +| fft.FFT2With | `fft.FFT2With` | numpy np.fft.fft2(norm='ortho') | — | — | — | deferred | none | 20 | +| fft.IFFT2 | `fft.IFFT2` | numpy np.fft.ifft2 | — | — | — | deferred | none | 20 | +| fft.IFFT2With | `fft.IFFT2With` | numpy np.fft.ifft2(norm='ortho') | — | — | — | deferred | none | 20 | +| fft.RFFT2 | `fft.RFFT2` | numpy np.fft.rfft2 | — | — | — | deferred | none | 31 | +| fft.RFFT2With | `fft.RFFT2With` | numpy np.fft.rfft2(norm='ortho') | — | — | — | deferred | none | 31 | +| fft.IRFFT2 | `fft.IRFFT2` | numpy np.fft.irfft2 | — | — | — | deferred | none | 25 | +| fft.IRFFT2With | `fft.IRFFT2With` | numpy np.fft.irfft2(norm='ortho') | — | — | — | deferred | none | 25 | +| fft.FFTN | `fft.FFTN` | numpy np.fft.fftn | — | — | — | deferred | none | 21 | +| fft.FFTNWith | `fft.FFTNWith` | numpy np.fft.fftn(norm='ortho') | — | — | — | deferred | none | 21 | +| fft.IFFTN | `fft.IFFTN` | numpy np.fft.ifftn | — | — | — | deferred | none | 21 | +| fft.IFFTNWith | `fft.IFFTNWith` | numpy np.fft.ifftn(norm='ortho') | — | — | — | deferred | none | 21 | +| fft.RFFTN | `fft.RFFTN` | numpy np.fft.rfftn | — | — | — | deferred | none | 32 | +| fft.RFFTNWith | `fft.RFFTNWith` | numpy np.fft.rfftn(norm='ortho') | — | — | — | deferred | none | 32 | +| fft.IRFFTN | `fft.IRFFTN` | numpy np.fft.irfftn | — | — | — | deferred | none | 26 | +| fft.IRFFTNWith | `fft.IRFFTNWith` | numpy np.fft.irfftn(norm='ortho') | — | — | — | deferred | none | 26 | +| fft.FFTShift | `fft.FFTShift` | numpy np.fft.fftshift | — | — | — | deferred | none | 29 | +| fft.IFFTShift | `fft.IFFTShift` | numpy np.fft.ifftshift | — | — | — | deferred | none | 29 | +| fft.FFTFreq | `fft.FFTFreq` | numpy np.fft.fftfreq | — | — | — | deferred | none | 7 | +| fft.RFFTFreq | `fft.RFFTFreq` | numpy np.fft.rfftfreq | — | — | — | deferred | none | 7 | +| linalg.Det | `linalg.Det` | numpy np.linalg.det(a) | — | — | — | deferred | none | 25 | +| linalg.Inv | `linalg.Inv` | numpy np.linalg.inv(a) | — | — | — | deferred | none | 30 | +| linalg.Eigvals | `linalg.Eigvals` | numpy np.linalg.eigvals(a) | — | — | — | deferred | none | 25 | +| linalg.Pinv | `linalg.Pinv` | numpy np.linalg.pinv(a) | — | — | — | deferred | none | 255 | +| linalg.SVDVals | `linalg.SVDVals` | numpy np.linalg.svd(a, compute_uv=False) | — | — | — | deferred | none | 23 | +| linalg.MatrixRank | `linalg.MatrixRank` | numpy np.linalg.matrix_rank(a) | — | — | — | deferred | none | 34 | +| linalg.Cholesky | `linalg.Cholesky` | numpy np.linalg.cholesky(a) | — | — | — | deferred | none | 20 | +| linalg.Eigvalsh | `linalg.Eigvalsh` | numpy np.linalg.eigvalsh(a) | — | — | — | deferred | none | 22 | +| linalg.Cond | `linalg.Cond` | numpy np.linalg.cond(a) | — | — | — | deferred | none | 39 | +| linalg.Norm | `linalg.Norm` | numpy np.linalg.norm(a) | — | — | — | deferred | none | 11 | +| linalg.MatrixRankTol | `linalg.MatrixRankTol` | numpy np.linalg.matrix_rank(a, tol=1e-8) | — | — | — | deferred | none | 34 | +| linalg.PinvRcond | `linalg.PinvRcond` | numpy np.linalg.pinv(a, rcond=1e-10) | — | — | — | deferred | none | 255 | +| linalg.MatrixPower | `linalg.MatrixPower` | numpy np.linalg.matrix_power(a, 5) | — | — | — | deferred | none | 28 | +| linalg.Eig | `linalg.Eig` | numpy np.linalg.eig(a) | — | — | — | deferred | none | 89 | +| linalg.Eigh | `linalg.Eigh` | numpy np.linalg.eigh(a) | — | — | — | deferred | none | 166 | +| linalg.QR | `linalg.QR` | numpy np.linalg.qr(a) | — | — | — | deferred | none | 93 | +| linalg.SVD | `linalg.SVD` | numpy np.linalg.svd(a, full_matrices=False) | — | — | — | deferred | none | 265 | +| linalg.Slogdet | `linalg.Slogdet` | numpy np.linalg.slogdet(a) | — | — | — | deferred | none | 29 | +| linalg.Solve | `linalg.Solve` | numpy np.linalg.solve(a, b) | — | — | — | deferred | none | 40 | +| linalg.Lstsq | `linalg.Lstsq` | numpy np.linalg.lstsq(a, b, rcond=None) | — | — | — | deferred | none | 276 | +| linalg.LstsqRcond | `linalg.LstsqRcond` | numpy np.linalg.lstsq(a, b, rcond=1e-10) | — | — | — | deferred | none | 276 | +| linalg.MultiDot | `linalg.MultiDot` | numpy np.linalg.multi_dot([a, b, c]) | — | — | — | deferred | none | 25 | +| random.Generator.Beta | `random.Generator.Beta` | numpy Generator.beta(2.0, 3.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Binomial | `random.Generator.Binomial` | numpy Generator.binomial(100, 0.3, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.ChiSquare | `random.Generator.ChiSquare` | numpy Generator.chisquare(3.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.ChoiceN | `random.Generator.ChoiceN` | numpy Generator.choice(1000, 100000) | — | — | — | deferred | none | 8 | +| random.Generator.Dirichlet | `random.Generator.Dirichlet` | numpy Generator.dirichlet([1.0, 2.0, 3.0], 20000) | — | — | — | deferred | none | 7 | +| random.Generator.Exponential | `random.Generator.Exponential` | numpy Generator.exponential(1.5, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.F | `random.Generator.F` | numpy Generator.f(5.0, 10.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Gamma | `random.Generator.Gamma` | numpy Generator.gamma(2.0, 1.5, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Geometric | `random.Generator.Geometric` | numpy Generator.geometric(0.2, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Gumbel | `random.Generator.Gumbel` | numpy Generator.gumbel(0.0, 1.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Integers | `random.Generator.Integers` | numpy Generator.integers(0, 1000, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.IntegersOf | `random.Generator.IntegersOf` | numpy Generator.integers(0, 1000, 100000, dtype=np.int32) | — | — | — | deferred | none | 6 | +| random.Generator.IntegersUint64 | `random.Generator.IntegersUint64` | numpy Generator.integers(0, 1000, 100000, dtype=np.uint64) | — | — | — | deferred | none | 6 | +| random.Generator.Laplace | `random.Generator.Laplace` | numpy Generator.laplace(0.0, 1.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Logistic | `random.Generator.Logistic` | numpy Generator.logistic(0.0, 1.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Lognormal | `random.Generator.Lognormal` | numpy Generator.lognormal(0.0, 1.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Logseries | `random.Generator.Logseries` | numpy Generator.logseries(0.6, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Multinomial | `random.Generator.Multinomial` | numpy Generator.multinomial(100, [0.2, 0.3, 0.5], 20000) | — | — | — | deferred | none | 7 | +| random.Generator.NegativeBinomial | `random.Generator.NegativeBinomial` | numpy Generator.negative_binomial(5.0, 0.5, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.NoncentralChiSquare | `random.Generator.NoncentralChiSquare` | numpy Generator.noncentral_chisquare(3.0, 2.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.NoncentralF | `random.Generator.NoncentralF` | numpy Generator.noncentral_f(5.0, 10.0, 2.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Normal | `random.Generator.Normal` | numpy Generator.normal(0.0, 1.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Pareto | `random.Generator.Pareto` | numpy Generator.pareto(3.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Permutation | `random.Generator.Permutation` | numpy Generator.permutation(100000) | — | — | — | deferred | none | 10 | +| random.Generator.Poisson | `random.Generator.Poisson` | numpy Generator.poisson(5.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Power | `random.Generator.Power` | numpy Generator.power(3.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Random | `random.Generator.Random` | numpy Generator.random(100000) | — | — | — | deferred | none | 6 | +| random.Generator.RandomOf | `random.Generator.RandomOf` | numpy Generator.random(100000, dtype=np.float32) | — | — | — | deferred | none | 6 | +| random.Generator.Rayleigh | `random.Generator.Rayleigh` | numpy Generator.rayleigh(1.5, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.StandardCauchy | `random.Generator.StandardCauchy` | numpy Generator.standard_cauchy(100000) | — | — | — | deferred | none | 6 | +| random.Generator.StandardExponential | `random.Generator.StandardExponential` | numpy Generator.standard_exponential(100000) | — | — | — | deferred | none | 6 | +| random.Generator.StandardExponentialOf | `random.Generator.StandardExponentialOf` | numpy Generator.standard_exponential(100000, dtype=np.float32, method='zig') | — | — | — | deferred | none | 6 | +| random.Generator.StandardGamma | `random.Generator.StandardGamma` | numpy Generator.standard_gamma(2.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.StandardGammaOf | `random.Generator.StandardGammaOf` | numpy Generator.standard_gamma(2.0, 100000, dtype=np.float32) | — | — | — | deferred | none | 6 | +| random.Generator.StandardNormal | `random.Generator.StandardNormal` | numpy Generator.standard_normal(100000) | — | — | — | deferred | none | 6 | +| random.Generator.StandardNormalOf | `random.Generator.StandardNormalOf` | numpy Generator.standard_normal(100000, dtype=np.float32) | — | — | — | deferred | none | 6 | +| random.Generator.StandardT | `random.Generator.StandardT` | numpy Generator.standard_t(5.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Triangular | `random.Generator.Triangular` | numpy Generator.triangular(0.0, 1.0, 3.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Uniform | `random.Generator.Uniform` | numpy Generator.uniform(0.0, 1.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.VonMises | `random.Generator.VonMises` | numpy Generator.vonmises(0.0, 2.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Wald | `random.Generator.Wald` | numpy Generator.wald(1.0, 2.0, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Weibull | `random.Generator.Weibull` | numpy Generator.weibull(1.5, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Zipf | `random.Generator.Zipf` | numpy Generator.zipf(2.5, 100000) | — | — | — | deferred | none | 6 | +| random.Generator.Choice | `random.Generator.Choice` | numpy Generator.choice(x, 100000) | — | — | — | deferred | none | 200013 | +| random.Generator.PermutationOf | `random.Generator.PermutationOf` | numpy Generator.permutation(x, axis=0) | — | — | — | deferred | none | 10 | +| random.Generator.Permuted | `random.Generator.Permuted` | numpy Generator.permuted(x) | — | — | — | deferred | none | 7 | +| random.Generator.PermutedAxis | `random.Generator.PermutedAxis` | numpy Generator.permuted(x, axis=0) | — | — | — | deferred | none | 8 | +| random.Generator.Shuffle | `random.Generator.Shuffle` | numpy Generator.shuffle (in place) | — | — | — | deferred | none | 10 | +| random.Generator.ShuffleInt64 | `random.Generator.ShuffleInt64` | numpy Generator.shuffle of 100000 int64 (in place) | — | — | — | deferred | none | 0 | +| random.Generator.FillIntN | `random.Generator.FillIntN` | numpy Generator.integers(0, 1000, 100000) (allocates; Go fills dst) | — | — | — | deferred | none | 0 | +| random.Generator.FillUniform | `random.Generator.FillUniform` | numpy Generator.uniform(0, 1, 100000) (allocates; Go fills dst) | — | — | — | deferred | none | 0 | +| random.Generator.Bytes | `random.Generator.Bytes` | numpy Generator.bytes(1000000) | — | — | — | deferred | none | 1 | +| random.RandomState.Binomial | `random.RandomState.Binomial` | numpy RandomState.binomial(100, 0.3, 100000) | — | — | — | deferred | none | 6 | +| random.RandomState.ChiSquare | `random.RandomState.ChiSquare` | numpy RandomState.chisquare(3.0, 100000) | — | — | — | deferred | none | 6 | +| random.RandomState.ChoiceN | `random.RandomState.ChoiceN` | numpy RandomState.choice(1000, 100000) | — | — | — | deferred | none | 7 | +| random.RandomState.Exponential | `random.RandomState.Exponential` | numpy RandomState.exponential(1.5, 100000) | — | — | — | deferred | none | 6 | +| random.RandomState.Gamma | `random.RandomState.Gamma` | numpy RandomState.gamma(2.0, 1.5, 100000) | — | — | — | deferred | none | 6 | +| random.RandomState.Lognormal | `random.RandomState.Lognormal` | numpy RandomState.lognormal(0.0, 1.0, 100000) | — | — | — | deferred | none | 6 | +| random.RandomState.Multinomial | `random.RandomState.Multinomial` | numpy RandomState.multinomial(100, [0.2, 0.3, 0.5], 20000) | — | — | — | deferred | none | 7 | +| random.RandomState.Normal | `random.RandomState.Normal` | numpy RandomState.normal(0.0, 1.0, 100000) | — | — | — | deferred | none | 6 | +| random.RandomState.Permutation | `random.RandomState.Permutation` | numpy RandomState.permutation(100000) | — | — | — | deferred | none | 10 | +| random.RandomState.Poisson | `random.RandomState.Poisson` | numpy RandomState.poisson(5.0, 100000) | — | — | — | deferred | none | 6 | +| random.RandomState.Rand | `random.RandomState.Rand` | numpy RandomState.rand(100000) | — | — | — | deferred | none | 6 | +| random.RandomState.Randint | `random.RandomState.Randint` | numpy RandomState.randint(0, 1000, 100000) | — | — | — | deferred | none | 6 | +| random.RandomState.RandintOf | `random.RandomState.RandintOf` | numpy RandomState.randint(0, 1000, 100000, dtype=np.int32) | — | — | — | deferred | none | 6 | +| random.RandomState.Randn | `random.RandomState.Randn` | numpy RandomState.randn(100000) | — | — | — | deferred | none | 6 | +| random.RandomState.RandomSample | `random.RandomState.RandomSample` | numpy RandomState.random_sample(100000) | — | — | — | deferred | none | 6 | +| random.RandomState.StandardExponential | `random.RandomState.StandardExponential` | numpy RandomState.standard_exponential(100000) | — | — | — | deferred | none | 6 | +| random.RandomState.StandardGamma | `random.RandomState.StandardGamma` | numpy RandomState.standard_gamma(2.0, 100000) | — | — | — | deferred | none | 6 | +| random.RandomState.StandardNormal | `random.RandomState.StandardNormal` | numpy RandomState.standard_normal(100000) | — | — | — | deferred | none | 6 | +| random.RandomState.Uniform | `random.RandomState.Uniform` | numpy RandomState.uniform(0.0, 1.0, 100000) | — | — | — | deferred | none | 6 | +| random.RandomState.Choice | `random.RandomState.Choice` | numpy RandomState.choice(x, 100000) | — | — | — | deferred | none | 200012 | +| random.RandomState.PermutationOf | `random.RandomState.PermutationOf` | numpy RandomState.permutation(x) | — | — | — | deferred | none | 10 | +| random.RandomState.Shuffle | `random.RandomState.Shuffle` | numpy RandomState.shuffle (in place, on a copy) | — | — | — | deferred | none | 10 | +| random.RandomState.ShuffleInt64 | `random.RandomState.ShuffleInt64` | numpy RandomState.shuffle of 100000 int64 (in place) | — | — | — | deferred | none | 0 | +| random.RandomState.FillIntN | `random.RandomState.FillIntN` | numpy RandomState.randint(0, 1000, 100000) (allocates; Go fills dst) | — | — | — | deferred | none | 0 | +| random.RandomState.FillUniform | `random.RandomState.FillUniform` | numpy RandomState.uniform(0, 1, 100000) (allocates; Go fills dst) | — | — | — | deferred | none | 0 | +| random.RandomState.Bytes | `random.RandomState.Bytes` | numpy RandomState.bytes(1000000) | — | — | — | deferred | none | 1 | +| random.Binomial | `random.Binomial` | numpy np.random.binomial(100, 0.3, 100000) | — | — | — | deferred | none | 6 | +| random.Normal | `random.Normal` | numpy np.random.normal(0.0, 1.0, 100000) | — | — | — | deferred | none | 6 | +| random.Permutation | `random.Permutation` | numpy np.random.permutation(100000) | — | — | — | deferred | none | 10 | +| random.Poisson | `random.Poisson` | numpy np.random.poisson(5.0, 100000) | — | — | — | deferred | none | 6 | +| random.Rand | `random.Rand` | numpy np.random.rand(100000) | — | — | — | deferred | none | 6 | +| random.Randint | `random.Randint` | numpy np.random.randint(0, 1000, 100000) | — | — | — | deferred | none | 6 | +| random.Randn | `random.Randn` | numpy np.random.randn(100000) | — | — | — | deferred | none | 6 | +| random.RandomSample | `random.RandomSample` | numpy np.random.random_sample(100000) | — | — | — | deferred | none | 6 | +| random.Uniform | `random.Uniform` | numpy np.random.uniform(0.0, 1.0, 100000) | — | — | — | deferred | none | 6 | +| random.Choice | `random.Choice` | numpy np.random.choice(x, 100000) | — | — | — | deferred | none | 200012 | +| random.Shuffle | `random.Shuffle` | numpy np.random.shuffle (in place, on a copy) | — | — | — | deferred | none | 11 | +| random.RandomRaw | `random.RandomRaw` | numpy PCG64.random_raw(100000) | — | — | — | deferred | none | 1 | diff --git a/docs/perf.json b/docs/perf.json new file mode 100644 index 0000000..c9e61aa --- /dev/null +++ b/docs/perf.json @@ -0,0 +1,4626 @@ +{ + "schema": 1, + "status": "deferred", + "date": "2026-11-08T12:33:21+01:00", + "commit": "", + "repo": "go-ndarray/ndarray", + "go_version": "go1.27.3", + "machine": "darwin/arm64, 16 CPUs", + "max_load": 8, + "load": { + "min": 0, + "median": 0, + "max": 0 + }, + "valid": true, + "rows": [ + { + "name": "Array.Abs", + "func": "Array.Abs", + "reference": "numpy np.abs", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Neg", + "func": "Array.Neg", + "reference": "numpy np.negative", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Sqrt", + "func": "Array.Sqrt", + "reference": "numpy np.sqrt", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Square", + "func": "Array.Square", + "reference": "numpy np.square", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Exp", + "func": "Array.Exp", + "reference": "numpy np.exp", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Log", + "func": "Array.Log", + "reference": "numpy np.log", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Log2", + "func": "Array.Log2", + "reference": "numpy np.log2", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Log10", + "func": "Array.Log10", + "reference": "numpy np.log10", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Sin", + "func": "Array.Sin", + "reference": "numpy np.sin", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Cos", + "func": "Array.Cos", + "reference": "numpy np.cos", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Tan", + "func": "Array.Tan", + "reference": "numpy np.tan", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Ceil", + "func": "Array.Ceil", + "reference": "numpy np.ceil", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Floor", + "func": "Array.Floor", + "reference": "numpy np.floor", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Round", + "func": "Array.Round", + "reference": "numpy np.round", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Conj", + "func": "Array.Conj", + "reference": "numpy np.conj", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Real", + "func": "Array.Real", + "reference": "numpy np.real", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Imag", + "func": "Array.Imag", + "reference": "numpy np.imag", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Angle", + "func": "Array.Angle", + "reference": "numpy np.angle", + "parity": "none", + "allocs": 16, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.LogicalNot", + "func": "Array.LogicalNot", + "reference": "numpy np.logical_not", + "parity": "none", + "allocs": 8, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Copy", + "func": "Array.Copy", + "reference": "numpy lambda x: x.copy()", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Flatten", + "func": "Array.Flatten", + "reference": "numpy lambda x: x.flatten()", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Ravel", + "func": "Array.Ravel", + "reference": "numpy lambda x: x.ravel()", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Power", + "func": "Array.Power", + "reference": "numpy lambda x: np.power(x, 2.5)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.CumSumFlat", + "func": "Array.CumSumFlat", + "reference": "numpy np.cumsum", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Nonzero", + "func": "Array.Nonzero", + "reference": "numpy np.flatnonzero", + "parity": "none", + "allocs": 35, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Repr", + "func": "Array.Repr", + "reference": "numpy repr", + "parity": "none", + "allocs": 63, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.AsType", + "func": "Array.AsType", + "reference": "numpy lambda x: x.astype(np.float32)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Add", + "func": "Array.Add", + "reference": "numpy np.add", + "parity": "none", + "allocs": 3, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Sub", + "func": "Array.Sub", + "reference": "numpy np.subtract", + "parity": "none", + "allocs": 3, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Mul", + "func": "Array.Mul", + "reference": "numpy np.multiply", + "parity": "none", + "allocs": 3, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Div", + "func": "Array.Div", + "reference": "numpy np.divide", + "parity": "none", + "allocs": 3, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Maximum", + "func": "Array.Maximum", + "reference": "numpy np.maximum", + "parity": "none", + "allocs": 2, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Minimum", + "func": "Array.Minimum", + "reference": "numpy np.minimum", + "parity": "none", + "allocs": 2, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Equal", + "func": "Array.Equal", + "reference": "numpy np.equal", + "parity": "none", + "allocs": 8, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.NotEqual", + "func": "Array.NotEqual", + "reference": "numpy np.not_equal", + "parity": "none", + "allocs": 8, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Less", + "func": "Array.Less", + "reference": "numpy np.less", + "parity": "none", + "allocs": 8, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.LessEqual", + "func": "Array.LessEqual", + "reference": "numpy np.less_equal", + "parity": "none", + "allocs": 8, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Greater", + "func": "Array.Greater", + "reference": "numpy np.greater", + "parity": "none", + "allocs": 8, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.GreaterEqual", + "func": "Array.GreaterEqual", + "reference": "numpy np.greater_equal", + "parity": "none", + "allocs": 8, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.LogicalAnd", + "func": "Array.LogicalAnd", + "reference": "numpy np.logical_and", + "parity": "none", + "allocs": 12, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.LogicalOr", + "func": "Array.LogicalOr", + "reference": "numpy np.logical_or", + "parity": "none", + "allocs": 12, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.LogicalXor", + "func": "Array.LogicalXor", + "reference": "numpy np.logical_xor", + "parity": "none", + "allocs": 12, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Inner", + "func": "Array.Inner", + "reference": "numpy np.inner", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.AddInto", + "func": "Array.AddInto", + "reference": "numpy np.add (out= preallocated)", + "parity": "none", + "allocs": 2, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.SubInto", + "func": "Array.SubInto", + "reference": "numpy np.subtract (out= preallocated)", + "parity": "none", + "allocs": 2, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.MulInto", + "func": "Array.MulInto", + "reference": "numpy np.multiply (out= preallocated)", + "parity": "none", + "allocs": 2, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.DivInto", + "func": "Array.DivInto", + "reference": "numpy np.divide (out= preallocated)", + "parity": "none", + "allocs": 2, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.AddScalar", + "func": "Array.AddScalar", + "reference": "numpy x + 1.5", + "parity": "none", + "allocs": 8, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.SubScalar", + "func": "Array.SubScalar", + "reference": "numpy x - 1.5", + "parity": "none", + "allocs": 8, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.MulScalar", + "func": "Array.MulScalar", + "reference": "numpy x * 1.5", + "parity": "none", + "allocs": 8, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.DivScalar", + "func": "Array.DivScalar", + "reference": "numpy x / 1.5", + "parity": "none", + "allocs": 8, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.SqrtInto", + "func": "Array.SqrtInto", + "reference": "numpy np.sqrt (out= preallocated)", + "parity": "none", + "allocs": 1, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Clip", + "func": "Array.Clip", + "reference": "numpy np.clip", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Dot", + "func": "Array.Dot", + "reference": "numpy np.dot, 128 x 128 (OpenBLAS, one thread)", + "parity": "none", + "allocs": 13, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.MatMul", + "func": "Array.MatMul", + "reference": "numpy np.matmul, 128 x 128 (OpenBLAS, one thread)", + "parity": "none", + "allocs": 13, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Outer", + "func": "Array.Outer", + "reference": "numpy np.outer, 1000 x 1000", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Transpose", + "func": "Array.Transpose", + "reference": "numpy x.T (a view; Go may copy)", + "parity": "none", + "allocs": 3, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Reshape", + "func": "Array.Reshape", + "reference": "numpy x.reshape (a view)", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.ExpandDims", + "func": "Array.ExpandDims", + "reference": "numpy np.expand_dims", + "parity": "none", + "allocs": 3, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Squeeze", + "func": "Array.Squeeze", + "reference": "numpy np.squeeze", + "parity": "none", + "allocs": 3, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Slice", + "func": "Array.Slice", + "reference": "numpy x[0:n:2] (a view)", + "parity": "none", + "allocs": 3, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Take", + "func": "Array.Take", + "reference": "numpy np.take, 1000 indices", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.MaskSelect", + "func": "Array.MaskSelect", + "reference": "numpy x[mask] (mask x \u003e 2 built outside the call)", + "parity": "none", + "allocs": 30, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Where", + "func": "Where", + "reference": "numpy np.where (mask x \u003e 2 built outside the call)", + "parity": "none", + "allocs": 9, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Concatenate", + "func": "Concatenate", + "reference": "numpy np.concatenate", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Stack", + "func": "Stack", + "reference": "numpy np.stack", + "parity": "none", + "allocs": 10, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "HStack", + "func": "HStack", + "reference": "numpy np.hstack", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "VStack", + "func": "VStack", + "reference": "numpy np.vstack", + "parity": "none", + "allocs": 10, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Sum", + "func": "Array.Sum", + "reference": "numpy np.sum", + "parity": "none", + "allocs": 7, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.SumAll", + "func": "Array.SumAll", + "reference": "numpy np.sum", + "parity": "none", + "allocs": 9, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Prod", + "func": "Array.Prod", + "reference": "numpy np.prod", + "parity": "none", + "allocs": 0, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.ProdAll", + "func": "Array.ProdAll", + "reference": "numpy np.prod", + "parity": "none", + "allocs": 2, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Mean", + "func": "Array.Mean", + "reference": "numpy np.mean", + "parity": "none", + "allocs": 7, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Max", + "func": "Array.Max", + "reference": "numpy np.max", + "parity": "none", + "allocs": 2, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Min", + "func": "Array.Min", + "reference": "numpy np.min", + "parity": "none", + "allocs": 2, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.ArgMax", + "func": "Array.ArgMax", + "reference": "numpy np.argmax", + "parity": "none", + "allocs": 0, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.ArgMin", + "func": "Array.ArgMin", + "reference": "numpy np.argmin", + "parity": "none", + "allocs": 0, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.All", + "func": "Array.All", + "reference": "numpy np.all", + "parity": "none", + "allocs": 3, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.Any", + "func": "Array.Any", + "reference": "numpy np.any", + "parity": "none", + "allocs": 3, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.CumProdFlat", + "func": "Array.CumProdFlat", + "reference": "numpy np.cumprod", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.SumAxis/axis0", + "func": "Array.SumAxis", + "reference": "numpy np.sum(axis=0)", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.SumAxis/axis1", + "func": "Array.SumAxis", + "reference": "numpy np.sum(axis=1)", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.ProdAxis/axis0", + "func": "Array.ProdAxis", + "reference": "numpy np.prod(axis=0)", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.ProdAxis/axis1", + "func": "Array.ProdAxis", + "reference": "numpy np.prod(axis=1)", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.MeanAxis/axis0", + "func": "Array.MeanAxis", + "reference": "numpy np.mean(axis=0)", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.MeanAxis/axis1", + "func": "Array.MeanAxis", + "reference": "numpy np.mean(axis=1)", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.MaxAxis/axis0", + "func": "Array.MaxAxis", + "reference": "numpy np.max(axis=0)", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.MaxAxis/axis1", + "func": "Array.MaxAxis", + "reference": "numpy np.max(axis=1)", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.MinAxis/axis0", + "func": "Array.MinAxis", + "reference": "numpy np.min(axis=0)", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.MinAxis/axis1", + "func": "Array.MinAxis", + "reference": "numpy np.min(axis=1)", + "parity": "none", + "allocs": 4, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.ArgMaxAxis/axis0", + "func": "Array.ArgMaxAxis", + "reference": "numpy np.argmax(axis=0)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.ArgMaxAxis/axis1", + "func": "Array.ArgMaxAxis", + "reference": "numpy np.argmax(axis=1)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.ArgMinAxis/axis0", + "func": "Array.ArgMinAxis", + "reference": "numpy np.argmin(axis=0)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.ArgMinAxis/axis1", + "func": "Array.ArgMinAxis", + "reference": "numpy np.argmin(axis=1)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.CumSum/axis0", + "func": "Array.CumSum", + "reference": "numpy np.cumsum(axis=0)", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.CumProd/axis0", + "func": "Array.CumProd", + "reference": "numpy np.cumprod(axis=0)", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.CumSum/axis1", + "func": "Array.CumSum", + "reference": "numpy np.cumsum(axis=1)", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Array.CumProd/axis1", + "func": "Array.CumProd", + "reference": "numpy np.cumprod(axis=1)", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Arange", + "func": "Arange", + "reference": "numpy np.arange(0.0, 100000.0, 1.0)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Eye", + "func": "Eye", + "reference": "numpy np.eye(512)", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Identity", + "func": "Identity", + "reference": "numpy np.identity(512)", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Full", + "func": "Full", + "reference": "numpy np.full(100000, 1.5)", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "FullOf", + "func": "FullOf", + "reference": "numpy np.full(100000, 1.5, dtype=np.float32)", + "parity": "none", + "allocs": 8, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Linspace", + "func": "Linspace", + "reference": "numpy np.linspace(0.0, 1.0, 100000)", + "parity": "none", + "allocs": 7, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "New", + "func": "New", + "reference": "numpy np.zeros(100000)", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Zeros", + "func": "Zeros", + "reference": "numpy np.zeros(100000)", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Ones", + "func": "Ones", + "reference": "numpy np.ones(100000)", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "OnesOf", + "func": "OnesOf", + "reference": "numpy np.ones(100000, dtype=np.float32)", + "parity": "none", + "allocs": 8, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "ZerosOf", + "func": "ZerosOf", + "reference": "numpy np.zeros(100000, dtype=np.float32)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "Scalar", + "func": "Scalar", + "reference": "numpy np.array(1.5)", + "parity": "none", + "allocs": 2, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "FromData", + "func": "FromData", + "reference": "numpy np.asarray (wraps, no copy)", + "parity": "none", + "allocs": 5, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "FromSlice", + "func": "FromSlice", + "reference": "numpy np.asarray (wraps, no copy)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.FFT", + "func": "fft.FFT", + "reference": "numpy np.fft.fft", + "parity": "none", + "allocs": 19, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.FFTWith", + "func": "fft.FFTWith", + "reference": "numpy np.fft.fft(norm='ortho')", + "parity": "none", + "allocs": 19, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.IFFT", + "func": "fft.IFFT", + "reference": "numpy np.fft.ifft", + "parity": "none", + "allocs": 19, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.IFFTWith", + "func": "fft.IFFTWith", + "reference": "numpy np.fft.ifft(norm='ortho')", + "parity": "none", + "allocs": 19, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.RFFT", + "func": "fft.RFFT", + "reference": "numpy np.fft.rfft", + "parity": "none", + "allocs": 30, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.RFFTWith", + "func": "fft.RFFTWith", + "reference": "numpy np.fft.rfft(norm='ortho')", + "parity": "none", + "allocs": 30, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.IRFFT", + "func": "fft.IRFFT", + "reference": "numpy np.fft.irfft", + "parity": "none", + "allocs": 24, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.IRFFTWith", + "func": "fft.IRFFTWith", + "reference": "numpy np.fft.irfft(norm='ortho')", + "parity": "none", + "allocs": 24, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.HFFT", + "func": "fft.HFFT", + "reference": "numpy np.fft.hfft", + "parity": "none", + "allocs": 29, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.HFFTWith", + "func": "fft.HFFTWith", + "reference": "numpy np.fft.hfft(norm='ortho')", + "parity": "none", + "allocs": 29, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.IHFFT", + "func": "fft.IHFFT", + "reference": "numpy np.fft.ihfft", + "parity": "none", + "allocs": 35, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.IHFFTWith", + "func": "fft.IHFFTWith", + "reference": "numpy np.fft.ihfft(norm='ortho')", + "parity": "none", + "allocs": 35, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.FFT2", + "func": "fft.FFT2", + "reference": "numpy np.fft.fft2", + "parity": "none", + "allocs": 20, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.FFT2With", + "func": "fft.FFT2With", + "reference": "numpy np.fft.fft2(norm='ortho')", + "parity": "none", + "allocs": 20, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.IFFT2", + "func": "fft.IFFT2", + "reference": "numpy np.fft.ifft2", + "parity": "none", + "allocs": 20, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.IFFT2With", + "func": "fft.IFFT2With", + "reference": "numpy np.fft.ifft2(norm='ortho')", + "parity": "none", + "allocs": 20, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.RFFT2", + "func": "fft.RFFT2", + "reference": "numpy np.fft.rfft2", + "parity": "none", + "allocs": 31, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.RFFT2With", + "func": "fft.RFFT2With", + "reference": "numpy np.fft.rfft2(norm='ortho')", + "parity": "none", + "allocs": 31, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.IRFFT2", + "func": "fft.IRFFT2", + "reference": "numpy np.fft.irfft2", + "parity": "none", + "allocs": 25, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.IRFFT2With", + "func": "fft.IRFFT2With", + "reference": "numpy np.fft.irfft2(norm='ortho')", + "parity": "none", + "allocs": 25, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.FFTN", + "func": "fft.FFTN", + "reference": "numpy np.fft.fftn", + "parity": "none", + "allocs": 21, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.FFTNWith", + "func": "fft.FFTNWith", + "reference": "numpy np.fft.fftn(norm='ortho')", + "parity": "none", + "allocs": 21, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.IFFTN", + "func": "fft.IFFTN", + "reference": "numpy np.fft.ifftn", + "parity": "none", + "allocs": 21, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.IFFTNWith", + "func": "fft.IFFTNWith", + "reference": "numpy np.fft.ifftn(norm='ortho')", + "parity": "none", + "allocs": 21, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.RFFTN", + "func": "fft.RFFTN", + "reference": "numpy np.fft.rfftn", + "parity": "none", + "allocs": 32, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.RFFTNWith", + "func": "fft.RFFTNWith", + "reference": "numpy np.fft.rfftn(norm='ortho')", + "parity": "none", + "allocs": 32, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.IRFFTN", + "func": "fft.IRFFTN", + "reference": "numpy np.fft.irfftn", + "parity": "none", + "allocs": 26, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.IRFFTNWith", + "func": "fft.IRFFTNWith", + "reference": "numpy np.fft.irfftn(norm='ortho')", + "parity": "none", + "allocs": 26, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.FFTShift", + "func": "fft.FFTShift", + "reference": "numpy np.fft.fftshift", + "parity": "none", + "allocs": 29, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.IFFTShift", + "func": "fft.IFFTShift", + "reference": "numpy np.fft.ifftshift", + "parity": "none", + "allocs": 29, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.FFTFreq", + "func": "fft.FFTFreq", + "reference": "numpy np.fft.fftfreq", + "parity": "none", + "allocs": 7, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "fft.RFFTFreq", + "func": "fft.RFFTFreq", + "reference": "numpy np.fft.rfftfreq", + "parity": "none", + "allocs": 7, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.Det", + "func": "linalg.Det", + "reference": "numpy np.linalg.det(a)", + "parity": "none", + "allocs": 25, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.Inv", + "func": "linalg.Inv", + "reference": "numpy np.linalg.inv(a)", + "parity": "none", + "allocs": 30, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.Eigvals", + "func": "linalg.Eigvals", + "reference": "numpy np.linalg.eigvals(a)", + "parity": "none", + "allocs": 25, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.Pinv", + "func": "linalg.Pinv", + "reference": "numpy np.linalg.pinv(a)", + "parity": "none", + "allocs": 255, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.SVDVals", + "func": "linalg.SVDVals", + "reference": "numpy np.linalg.svd(a, compute_uv=False)", + "parity": "none", + "allocs": 23, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.MatrixRank", + "func": "linalg.MatrixRank", + "reference": "numpy np.linalg.matrix_rank(a)", + "parity": "none", + "allocs": 34, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.Cholesky", + "func": "linalg.Cholesky", + "reference": "numpy np.linalg.cholesky(a)", + "parity": "none", + "allocs": 20, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.Eigvalsh", + "func": "linalg.Eigvalsh", + "reference": "numpy np.linalg.eigvalsh(a)", + "parity": "none", + "allocs": 22, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.Cond", + "func": "linalg.Cond", + "reference": "numpy np.linalg.cond(a)", + "parity": "none", + "allocs": 39, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.Norm", + "func": "linalg.Norm", + "reference": "numpy np.linalg.norm(a)", + "parity": "none", + "allocs": 11, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.MatrixRankTol", + "func": "linalg.MatrixRankTol", + "reference": "numpy np.linalg.matrix_rank(a, tol=1e-8)", + "parity": "none", + "allocs": 34, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.PinvRcond", + "func": "linalg.PinvRcond", + "reference": "numpy np.linalg.pinv(a, rcond=1e-10)", + "parity": "none", + "allocs": 255, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.MatrixPower", + "func": "linalg.MatrixPower", + "reference": "numpy np.linalg.matrix_power(a, 5)", + "parity": "none", + "allocs": 28, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.Eig", + "func": "linalg.Eig", + "reference": "numpy np.linalg.eig(a)", + "parity": "none", + "allocs": 89, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.Eigh", + "func": "linalg.Eigh", + "reference": "numpy np.linalg.eigh(a)", + "parity": "none", + "allocs": 166, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.QR", + "func": "linalg.QR", + "reference": "numpy np.linalg.qr(a)", + "parity": "none", + "allocs": 93, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.SVD", + "func": "linalg.SVD", + "reference": "numpy np.linalg.svd(a, full_matrices=False)", + "parity": "none", + "allocs": 265, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.Slogdet", + "func": "linalg.Slogdet", + "reference": "numpy np.linalg.slogdet(a)", + "parity": "none", + "allocs": 29, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.Solve", + "func": "linalg.Solve", + "reference": "numpy np.linalg.solve(a, b)", + "parity": "none", + "allocs": 40, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.Lstsq", + "func": "linalg.Lstsq", + "reference": "numpy np.linalg.lstsq(a, b, rcond=None)", + "parity": "none", + "allocs": 276, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.LstsqRcond", + "func": "linalg.LstsqRcond", + "reference": "numpy np.linalg.lstsq(a, b, rcond=1e-10)", + "parity": "none", + "allocs": 276, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "linalg.MultiDot", + "func": "linalg.MultiDot", + "reference": "numpy np.linalg.multi_dot([a, b, c])", + "parity": "none", + "allocs": 25, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Beta", + "func": "random.Generator.Beta", + "reference": "numpy Generator.beta(2.0, 3.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Binomial", + "func": "random.Generator.Binomial", + "reference": "numpy Generator.binomial(100, 0.3, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.ChiSquare", + "func": "random.Generator.ChiSquare", + "reference": "numpy Generator.chisquare(3.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.ChoiceN", + "func": "random.Generator.ChoiceN", + "reference": "numpy Generator.choice(1000, 100000)", + "parity": "none", + "allocs": 8, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Dirichlet", + "func": "random.Generator.Dirichlet", + "reference": "numpy Generator.dirichlet([1.0, 2.0, 3.0], 20000)", + "parity": "none", + "allocs": 7, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Exponential", + "func": "random.Generator.Exponential", + "reference": "numpy Generator.exponential(1.5, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.F", + "func": "random.Generator.F", + "reference": "numpy Generator.f(5.0, 10.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Gamma", + "func": "random.Generator.Gamma", + "reference": "numpy Generator.gamma(2.0, 1.5, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Geometric", + "func": "random.Generator.Geometric", + "reference": "numpy Generator.geometric(0.2, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Gumbel", + "func": "random.Generator.Gumbel", + "reference": "numpy Generator.gumbel(0.0, 1.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Integers", + "func": "random.Generator.Integers", + "reference": "numpy Generator.integers(0, 1000, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.IntegersOf", + "func": "random.Generator.IntegersOf", + "reference": "numpy Generator.integers(0, 1000, 100000, dtype=np.int32)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.IntegersUint64", + "func": "random.Generator.IntegersUint64", + "reference": "numpy Generator.integers(0, 1000, 100000, dtype=np.uint64)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Laplace", + "func": "random.Generator.Laplace", + "reference": "numpy Generator.laplace(0.0, 1.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Logistic", + "func": "random.Generator.Logistic", + "reference": "numpy Generator.logistic(0.0, 1.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Lognormal", + "func": "random.Generator.Lognormal", + "reference": "numpy Generator.lognormal(0.0, 1.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Logseries", + "func": "random.Generator.Logseries", + "reference": "numpy Generator.logseries(0.6, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Multinomial", + "func": "random.Generator.Multinomial", + "reference": "numpy Generator.multinomial(100, [0.2, 0.3, 0.5], 20000)", + "parity": "none", + "allocs": 7, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.NegativeBinomial", + "func": "random.Generator.NegativeBinomial", + "reference": "numpy Generator.negative_binomial(5.0, 0.5, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.NoncentralChiSquare", + "func": "random.Generator.NoncentralChiSquare", + "reference": "numpy Generator.noncentral_chisquare(3.0, 2.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.NoncentralF", + "func": "random.Generator.NoncentralF", + "reference": "numpy Generator.noncentral_f(5.0, 10.0, 2.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Normal", + "func": "random.Generator.Normal", + "reference": "numpy Generator.normal(0.0, 1.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Pareto", + "func": "random.Generator.Pareto", + "reference": "numpy Generator.pareto(3.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Permutation", + "func": "random.Generator.Permutation", + "reference": "numpy Generator.permutation(100000)", + "parity": "none", + "allocs": 10, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Poisson", + "func": "random.Generator.Poisson", + "reference": "numpy Generator.poisson(5.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Power", + "func": "random.Generator.Power", + "reference": "numpy Generator.power(3.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Random", + "func": "random.Generator.Random", + "reference": "numpy Generator.random(100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.RandomOf", + "func": "random.Generator.RandomOf", + "reference": "numpy Generator.random(100000, dtype=np.float32)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Rayleigh", + "func": "random.Generator.Rayleigh", + "reference": "numpy Generator.rayleigh(1.5, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.StandardCauchy", + "func": "random.Generator.StandardCauchy", + "reference": "numpy Generator.standard_cauchy(100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.StandardExponential", + "func": "random.Generator.StandardExponential", + "reference": "numpy Generator.standard_exponential(100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.StandardExponentialOf", + "func": "random.Generator.StandardExponentialOf", + "reference": "numpy Generator.standard_exponential(100000, dtype=np.float32, method='zig')", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.StandardGamma", + "func": "random.Generator.StandardGamma", + "reference": "numpy Generator.standard_gamma(2.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.StandardGammaOf", + "func": "random.Generator.StandardGammaOf", + "reference": "numpy Generator.standard_gamma(2.0, 100000, dtype=np.float32)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.StandardNormal", + "func": "random.Generator.StandardNormal", + "reference": "numpy Generator.standard_normal(100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.StandardNormalOf", + "func": "random.Generator.StandardNormalOf", + "reference": "numpy Generator.standard_normal(100000, dtype=np.float32)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.StandardT", + "func": "random.Generator.StandardT", + "reference": "numpy Generator.standard_t(5.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Triangular", + "func": "random.Generator.Triangular", + "reference": "numpy Generator.triangular(0.0, 1.0, 3.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Uniform", + "func": "random.Generator.Uniform", + "reference": "numpy Generator.uniform(0.0, 1.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.VonMises", + "func": "random.Generator.VonMises", + "reference": "numpy Generator.vonmises(0.0, 2.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Wald", + "func": "random.Generator.Wald", + "reference": "numpy Generator.wald(1.0, 2.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Weibull", + "func": "random.Generator.Weibull", + "reference": "numpy Generator.weibull(1.5, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Zipf", + "func": "random.Generator.Zipf", + "reference": "numpy Generator.zipf(2.5, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Choice", + "func": "random.Generator.Choice", + "reference": "numpy Generator.choice(x, 100000)", + "parity": "none", + "allocs": 200013, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.PermutationOf", + "func": "random.Generator.PermutationOf", + "reference": "numpy Generator.permutation(x, axis=0)", + "parity": "none", + "allocs": 10, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Permuted", + "func": "random.Generator.Permuted", + "reference": "numpy Generator.permuted(x)", + "parity": "none", + "allocs": 7, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.PermutedAxis", + "func": "random.Generator.PermutedAxis", + "reference": "numpy Generator.permuted(x, axis=0)", + "parity": "none", + "allocs": 8, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Shuffle", + "func": "random.Generator.Shuffle", + "reference": "numpy Generator.shuffle (in place)", + "parity": "none", + "allocs": 10, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.ShuffleInt64", + "func": "random.Generator.ShuffleInt64", + "reference": "numpy Generator.shuffle of 100000 int64 (in place)", + "parity": "none", + "allocs": 0, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.FillIntN", + "func": "random.Generator.FillIntN", + "reference": "numpy Generator.integers(0, 1000, 100000) (allocates; Go fills dst)", + "parity": "none", + "allocs": 0, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.FillUniform", + "func": "random.Generator.FillUniform", + "reference": "numpy Generator.uniform(0, 1, 100000) (allocates; Go fills dst)", + "parity": "none", + "allocs": 0, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Generator.Bytes", + "func": "random.Generator.Bytes", + "reference": "numpy Generator.bytes(1000000)", + "parity": "none", + "allocs": 1, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.Binomial", + "func": "random.RandomState.Binomial", + "reference": "numpy RandomState.binomial(100, 0.3, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.ChiSquare", + "func": "random.RandomState.ChiSquare", + "reference": "numpy RandomState.chisquare(3.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.ChoiceN", + "func": "random.RandomState.ChoiceN", + "reference": "numpy RandomState.choice(1000, 100000)", + "parity": "none", + "allocs": 7, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.Exponential", + "func": "random.RandomState.Exponential", + "reference": "numpy RandomState.exponential(1.5, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.Gamma", + "func": "random.RandomState.Gamma", + "reference": "numpy RandomState.gamma(2.0, 1.5, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.Lognormal", + "func": "random.RandomState.Lognormal", + "reference": "numpy RandomState.lognormal(0.0, 1.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.Multinomial", + "func": "random.RandomState.Multinomial", + "reference": "numpy RandomState.multinomial(100, [0.2, 0.3, 0.5], 20000)", + "parity": "none", + "allocs": 7, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.Normal", + "func": "random.RandomState.Normal", + "reference": "numpy RandomState.normal(0.0, 1.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.Permutation", + "func": "random.RandomState.Permutation", + "reference": "numpy RandomState.permutation(100000)", + "parity": "none", + "allocs": 10, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.Poisson", + "func": "random.RandomState.Poisson", + "reference": "numpy RandomState.poisson(5.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.Rand", + "func": "random.RandomState.Rand", + "reference": "numpy RandomState.rand(100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.Randint", + "func": "random.RandomState.Randint", + "reference": "numpy RandomState.randint(0, 1000, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.RandintOf", + "func": "random.RandomState.RandintOf", + "reference": "numpy RandomState.randint(0, 1000, 100000, dtype=np.int32)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.Randn", + "func": "random.RandomState.Randn", + "reference": "numpy RandomState.randn(100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.RandomSample", + "func": "random.RandomState.RandomSample", + "reference": "numpy RandomState.random_sample(100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.StandardExponential", + "func": "random.RandomState.StandardExponential", + "reference": "numpy RandomState.standard_exponential(100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.StandardGamma", + "func": "random.RandomState.StandardGamma", + "reference": "numpy RandomState.standard_gamma(2.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.StandardNormal", + "func": "random.RandomState.StandardNormal", + "reference": "numpy RandomState.standard_normal(100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.Uniform", + "func": "random.RandomState.Uniform", + "reference": "numpy RandomState.uniform(0.0, 1.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.Choice", + "func": "random.RandomState.Choice", + "reference": "numpy RandomState.choice(x, 100000)", + "parity": "none", + "allocs": 200012, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.PermutationOf", + "func": "random.RandomState.PermutationOf", + "reference": "numpy RandomState.permutation(x)", + "parity": "none", + "allocs": 10, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.Shuffle", + "func": "random.RandomState.Shuffle", + "reference": "numpy RandomState.shuffle (in place, on a copy)", + "parity": "none", + "allocs": 10, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.ShuffleInt64", + "func": "random.RandomState.ShuffleInt64", + "reference": "numpy RandomState.shuffle of 100000 int64 (in place)", + "parity": "none", + "allocs": 0, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.FillIntN", + "func": "random.RandomState.FillIntN", + "reference": "numpy RandomState.randint(0, 1000, 100000) (allocates; Go fills dst)", + "parity": "none", + "allocs": 0, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.FillUniform", + "func": "random.RandomState.FillUniform", + "reference": "numpy RandomState.uniform(0, 1, 100000) (allocates; Go fills dst)", + "parity": "none", + "allocs": 0, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomState.Bytes", + "func": "random.RandomState.Bytes", + "reference": "numpy RandomState.bytes(1000000)", + "parity": "none", + "allocs": 1, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Binomial", + "func": "random.Binomial", + "reference": "numpy np.random.binomial(100, 0.3, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Normal", + "func": "random.Normal", + "reference": "numpy np.random.normal(0.0, 1.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Permutation", + "func": "random.Permutation", + "reference": "numpy np.random.permutation(100000)", + "parity": "none", + "allocs": 10, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Poisson", + "func": "random.Poisson", + "reference": "numpy np.random.poisson(5.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Rand", + "func": "random.Rand", + "reference": "numpy np.random.rand(100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Randint", + "func": "random.Randint", + "reference": "numpy np.random.randint(0, 1000, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Randn", + "func": "random.Randn", + "reference": "numpy np.random.randn(100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomSample", + "func": "random.RandomSample", + "reference": "numpy np.random.random_sample(100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Uniform", + "func": "random.Uniform", + "reference": "numpy np.random.uniform(0.0, 1.0, 100000)", + "parity": "none", + "allocs": 6, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Choice", + "func": "random.Choice", + "reference": "numpy np.random.choice(x, 100000)", + "parity": "none", + "allocs": 200012, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.Shuffle", + "func": "random.Shuffle", + "reference": "numpy np.random.shuffle (in place, on a copy)", + "parity": "none", + "allocs": 11, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + }, + { + "name": "random.RandomRaw", + "func": "random.RandomRaw", + "reference": "numpy PCG64.random_raw(100000)", + "parity": "none", + "allocs": 1, + "go": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ref": { + "median": 0, + "q1": 0, + "q3": 0 + }, + "ratio": 0 + } + ] +} diff --git a/docs/perf.md b/docs/perf.md index a327e8b..8e2e7cd 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -1,1816 +1,75 @@ -# Performance — go-ndarray vs NumPy - -Honest, reproducible head-to-head of `go-ndarray/ndarray` against **NumPy -2.2.4** on identical hardware. "On n'a pas le droit de se tromper": every number -here is measured, every win is real, and where NumPy still leads it says so. - -## How a pure-Go library can beat NumPy - -NumPy's elementwise and reduction ufuncs (`+`, `*`, `sqrt`, `sum`, `max`, …) are -**single-threaded SIMD C**. So a pure-Go library wins them by combining two -levers NumPy does not use for these ops: - -1. **Multicore** — fan the contiguous loop across `GOMAXPROCS` goroutines above a - size threshold (small arrays stay serial so scheduling never dominates). -2. **SIMD via [go-asmgen](https://github.com/go-asmgen)** — for the sum - reduction, whose scalar `s += v` loop the Go compiler will not auto-vectorize - (it would change FP grouping), a hand-written NEON/SSE2 kernel with four - independent accumulators recovers the lane parallelism NumPy's C has. - -A third lever — **eliminating allocation** — turned out to be the single biggest -factor: the old elementwise path materialised two full broadcast copies *and* a -destination per op (3×N). The same-shape contiguous fast path now passes the -operands' live backing slices to the kernel and allocates only the result. - -NumPy's `matmul` is BLAS, a different fight: go-ndarray answers it with its own -**panel-packed, cache-blocked GEMM** + go-asmgen SIMD-FMA micro-kernel (the -OpenBLAS/BLIS structure). With the by-lane-FMLA micro-kernel it reaches **parity -with tuned multi-threaded OpenBLAS 0.3.29 at n=1024 (≈0.99×, ~203 GFLOP/s)** and -~0.97× at n=512; only small n=256 still trails (per-call overhead). See the -matmul section below. - -## Test bench - -- **Machine**: debian arm64 (Apple-silicon Tart VM), 4 vCPU. -- **NumPy**: 2.2.4. Elementwise/reduction rows use Debian **reference BLAS - 3.12.1** (irrelevant to those ufuncs). The matmul rows are measured against - **OpenBLAS 0.3.29 (pthread, multi-threaded)** — the tuned BLAS — selected via - `update-alternatives --set libblas.so.3 …/openblas-pthread/libblas.so.3` and - confirmed loaded by reading `/proc/self/maps` (libopenblasp-r0.3.29.so). This - is the meaningful bar: the earlier reference-BLAS matmul comparison is kept only - as a footnote. -- **Go**: 1.26.4, `CGO_ENABLED=0`, `GOWORK=off`. -- **go-ndarray bench**: `go test -bench=. -benchtime=1s` (`bench_test.go`), - ns/op. Multi-core = default `GOMAXPROCS=4`; single-core = `GOMAXPROCS=1`. -- **NumPy bench**: `docs/bench_numpy.py` (matched ops/sizes), min-of-7 ns/op. - -Reproduce: rsync the repo to the VM, then -`go test -bench=. -benchtime=1s .` and `python3 docs/bench_numpy.py`. - -## Elementwise and reductions (ns/op; lower is better) - -`go ×` is NumPy ÷ go-ndarray: **> 1 means go-ndarray wins.** Two go forms are -shown for the binary/sqrt ops: - -- **alloc** — `a + b` / `np.sqrt(a)`: returns a fresh array (NumPy `x + y`). -- **into** — `a.AddInto(out, b)` / `a.SqrtInto(out)`: writes a caller-supplied - buffer, the no-allocation form (NumPy `np.add(x, y, out=z)` / `np.sqrt(x, out=z)`). - This is the apples-to-apples small-N comparison: it removes the per-op result - allocation, which is the *only* thing NumPy beats us on at small n (see below). - -| op | n | NumPy 2.2.4 | go alloc | go into | go × alloc | go × into | -|------|------:|------:|------:|------:|:--:|:--:| -| Add | 1 024 | 432 | 1 049 | **148** | 0.41 (numpy) | **2.41** | -| Add | 4 194 304 | 1 600 974 | **768 722** | **429 410** | **2.08** | **2.10** | -| Mul | 1 024 | 424 | 978 | **168** | 0.43 (numpy) | **2.08** | -| Mul | 4 194 304 | 1 572 361 | **763 339** | **435 109** | **2.06** | **1.81** | -| Sqrt | 1 024 | 496 | 1 299 | **263** | 0.38 (numpy) | **1.80** | -| Sqrt | 4 194 304 | 1 669 901 | **605 561** | **333 289** | **2.76** | **3.22** | -| Sum | 1 024 | 637 | **130** | — | **4.90** | — | -| Sum | 4 194 304 | 496 000 | **193 679** | — | **2.56** | — | -| Max | 1 024 | 611 | **160** | — | **3.83** | — | -| Max | 4 194 304 | 326 466 | **231 626** | — | **1.41** | — | - -(All numbers measured back-to-back in one VM session, 2026-06; go is -`-benchtime=1s`, NumPy `min`-of-runs in `docs/bench_numpy.py`. Add/Sub/Mul/Div -now run a SIMD kernel — packed SSE2 `ADD/SUB/MUL/DIVPD` on amd64, NEON `VFMLA` -on arm64 — so even the *alloc* form wins ~2× at large n. Sum/Max are reductions -with no result array, so they have no separate *into* row.) - -### Reading the table - -- **Add / Mul on large arrays: go-ndarray wins ~2×.** Allocation elimination + - multicore. Note even single-core large Mul edges out NumPy now (the kernel is - memory-bandwidth bound and the old copies were the bottleneck). -- **Sum: go-ndarray wins everywhere — up to ~5× on small, 2.4× on large.** The - SIMD 4-accumulator kernel already beats NumPy single-core (493 µs vs 506 µs at - 4 M); multicore then roughly halves it again. Small sums are dominated by - NumPy's per-call Python/dispatch overhead, which Go does not pay. -- **Sqrt: go-ndarray now WINS 1.75× at 4 M (was a ~1.3× loss).** The fix was to - stop routing `Sqrt` through `Map`'s `func(float64) float64`: that indirection - blocked *both* the Go compiler's `FSQRTD` intrinsic (arm64) and a packed SSE2 - `SQRTPD` (amd64), making the old path pay a real function call *per element* - (~2.7× slower single-core). A dedicated `sqrt` kernel behind the kernels seam — - packed `SQRTPD` on amd64, the intrinsic-lowered scalar `FSQRTD` loop on - arm64/others — plus the existing multicore fan-out is what turns the loss into - a win. The kernel is **bit-identical** to a scalar `math.Sqrt` loop (validated - per-arch and bit-for-bit against NumPy's `np.sqrt`). arm64 now also has a - **packed NEON `FSQRT V.2D`** kernel (two doubles per instruction; emitted by - `WORD` until v0.5.2, `VFSQRT` since Go 1.27 names it), replacing the prior - one-lane-at-a-time scalar `FSQRTD` loop. Small `n=1 024`: the *alloc* form loses - to NumPy on allocation cost (as Add/Mul), but `a.SqrtInto(out)` wins 1.8×. -- **Max: go-ndarray now WINS — 4.0× small, 1.34× at 4 M (was 0.62 / 0.31 - losses).** Two changes. (1) The scalar oracle was switched from `if v > m` - (which silently *ignored* NaNs, diverging from NumPy) to the **builtin - `max`/`min`**, which is NaN-propagating *and* lowers to the hardware `FMAXD`/ - `FMIND` intrinsic — `math.Max` would also propagate NaN but is an - un-intrinsified call ~12× slower in this hot loop. (2) The SIMD max/min - reduction uses **four independent accumulators** to break the dependency chain - (~3.6× over one), exactly like the sum kernel: packed `MAXPD`/`MINPD` + a NaN - scan on amd64, register-unrolled builtin `max`/`min` on arm64/others. See the - NaN convention note below. -- **Small arrays (n≈1 024) for Add/Mul/Sqrt — the *alloc* form: NumPy wins; the - *into* form: go-ndarray wins ~2×.** The entire gap is the result allocation. - `make([]float64, 1024)` measures ~900–1 080 ns on this VM's allocator (it zeroes - the 8 KiB the kernel then fully overwrites), while NumPy serves the temp from a - cached free-list for ~150 ns. So `a + b` at n=1024 is allocator-bound (≈1 µs, - of which the SIMD compute is only ~150 ns) and loses 0.4×; **`a.AddInto(out, b)` - — the no-allocation form, NumPy's `np.add(x, y, out=z)` — removes that cost and - wins 1.8–2.4× at the same size.** This is a Go-allocator ceiling, not a kernel - one: Go's `make` always zeroes and offers no unsafe "give me raw bytes" escape, - and returning pooled memory would break the caller-owns-the-result contract, so - the allocating small-N form cannot match NumPy's free-list — the `*Into` parity - path is the supported way to hit/beat NumPy there (and at large n even the alloc - form wins, because the kernel time then dwarfs the one allocation). (Sum and Max are - the exceptions — their reduction kernels need no result allocation and win even - serially.) **Superseded for loops by `Workspace`, see below.** - -### NaN convention for Max / Min (and the SIMD max kernel) - -`Max`/`Min` are **NaN-propagating**: if any element is NaN the result is NaN. -This matches `numpy.max`/`numpy.min`, Go's builtin `max`/`min`, and IEEE-754 -*maximum* (not the C `fmax` / IEEE `maxNum` "ignore NaN" rule); on signed zeros -it returns `+0` for `max(-0,+0)` and `-0` for `min(-0,+0)`, also matching NumPy. -The previous `if v > m` scalar form silently skipped NaNs (and a *leading* NaN -poisoned the scan), so it disagreed with NumPy; this is the corrected, documented -semantics, and the entire stack — scalar oracle, the four-accumulator reducer, -the amd64 `MAXPD`/`MINPD` kernel, and the multicore fold — is held **bit-for-bit -identical** to it (NaN at any position → NaN; exact extreme otherwise), validated -across every length residue and NaN placement in CI. The amd64 kernel gets this -despite `MAXPD`'s own non-propagating NaN rule by carrying a parallel -`CMPPD`-unordered OR-mask and forcing the result to NaN if any lane ever held -one — so the value `MAXPD` leaves in a NaN lane is irrelevant. - -## Matrix multiply vs TUNED BLAS (OpenBLAS) — the hard frontier - -This is the honest fight: go-ndarray's pure-Go (+ generated asm) GEMM against -**OpenBLAS 0.3.29 pthread**, decades-tuned multi-threaded assembly. Both run on -the same 4-vCPU Apple-silicon VM, back-to-back. GFLOP/s = `2·n³ / time`. - -All rows measured **back-to-back, interleaved, in the same VM session** (the only -fair way on a shared host whose load drifts), 4 samples each, `go ÷ OB` reported -as the per-sample ratio (robust to the absolute time drifting between sessions). - -| n (N×N) | go-ndarray (4 core) | OpenBLAS-mt | go GFLOP/s | OB GFLOP/s | go ÷ OB | -|------:|------:|------:|------:|------:|:--:| -| 256 | 261 000 | 174 000 | 102.8 | 154.2 | 0.67× | -| 512 | 1 530 000 | 1 500 000 | 175.4 | 178.9 | 0.97× | -| 1024 | 10 600 000 | 10 580 000 | 202.6 | 203.0 | **≈0.99× (parity; samples 0.96–1.00)** | - -**Verdict: at n=1024 the pure-Go (+ generated asm) GEMM reaches PARITY with tuned -OpenBLAS** (~0.99×, individual samples straddling 1.00×), sustaining **~203 -GFLOP/s** — up from the prior kernel's ~23 GFLOP/s and from this kernel's own 0.76× -before the by-lane-FMLA fix below. n=512 is ~0.97×; only small n=256, where fixed -per-call overhead (panel packing + the 4-way goroutine fan-out) dominates the -~250 µs of compute, still trails at 0.67× — a small-matrix overhead ceiling, not a -kernel-throughput one (the same kernel hits parity once the matrix is large enough -to amortise the setup). - -### The fix that reached parity: the by-lane FMLA - -The single change that took n=1024 from **0.76× to ≈0.99×** was the arm64 -micro-kernel's inner FMA. The kernel broadcasts one packed A value into every -column of its row; the prior version did this with `MOVD` (A double → GP register) -+ `VDUP` (GP → all lanes of a vector) before a plain vector `VFMLA` — an extra -instruction *and* a GP↔SIMD register-file round-trip per A value, every k-step. -The fix uses the indexed-element double FMLA `FMLA Vd.2D, Vn.2D, Vm.D[i]` that -every tuned arm64 dgemm uses: load the four packed A doubles of a k-step as two -`D2` vectors and have each row's FMA read its A scalar straight from a lane — no -GP detour, no `VDUP`. Go's arm64 assembler cannot *name* this indexed form -("illegal combination … ELEM"), so it is emitted by its raw 32-bit `WORD` -encoding (see `fmlaElem` in `asmgen/arm64/gen.go`, cross-checked against -`objdump`); this is still pure Go assembly, CGO=0. - -Measured on this Apple-silicon core: the L1-resident micro-kernel went **~44 → -~58 GFLOP/s/core (~1.28×)**, and the full GEMM tracked it to parity. (An earlier -note on this page claimed the indexed form was *slower* on Apple silicon — that -was wrong; it had never actually been measured against a correct encoding.) - -Two further levers were prototyped and **measured to NOT help on this core**, so -they were not shipped (documented here so they are not re-attempted blindly): -- **Software pipelining** (prefetch + double-buffer the next k-step's A/B into - shadow registers, rotate after the FMAs): ~2% on the L1 micro-kernel but **1–9% - *slower* on the full GEMM** — the M-series out-of-order window already hides the - L1/L2 load latency, and the extra `VMOV` rotations + register pressure cost more - than they save. -- **Wider 6×8 tile** (24 accumulators vs 16): same GFLOP/s as the 4×8 — the loop - is FMA-latency-bound, not register-pressure-bound, so a wider tile adds no - throughput. -- **MC/KC/NC retuning**: a 12-point sweep confirmed the shipped 256/256/512 is - already the optimum on this core. - -### What shipped: a panel-packed, cache-blocked GEMM - -The earlier register-blocked attempt read A and B straight from the source -matrices; on power-of-two row strides its destination rows collided in L1 and it -lost to the autovectorized ikj kernel at large n, so it was capped to `n ≤ 224` -and **packing was prototyped but not shipped** (the previous note here). This -round implements the full OpenBLAS/BLIS structure and ships it for **all** sizes: - -- **Packing** — A is copied into MR-tall row panels and B into NR-wide column - panels in contiguous, unit-stride scratch (pooled, allocation-free per call). - The micro-kernel then streams conflict-free memory **regardless of the source - stride** — this is the precise fix for the power-of-two L1 set-conflicts that - defeated the unpacked attempt. Edge tiles are zero-padded so the kernel always - sees a full MR×NR block; a ragged right/bottom tile runs the same micro-kernel - into a scratch tile, and only its valid corner is added to C. -- **Cache blocking** — an `NC` (columns) → `KC` (contraction) → `MC` (rows) loop - nest keeps the packed B panel L2-resident and each packed A panel in L1. - Defaults `MC=256, KC=256, NC=512` (tuned on this VM). -- **SIMD-FMA micro-kernel** (go-asmgen) — **NEON 4×8 on arm64** (16 D2 - accumulators, the source of the win), **AVX2/FMA 6×8 on amd64** (12 YMM - accumulators, picked at run time by a CPUID probe; an SSE2 6×4 pair without - FMA is the fallback), **scalar 4×4** on the - four arches without vector-double asm — which still gain from contiguous packed - data + blocking + multicore. -- **Parallelism** — the M rows are split into MR-aligned bands, one per core, - each writing a disjoint region of C. - -The arithmetic is the ikj order `dst[i][j] += a[i][p]·b[p][j]`, summed per KC -block; for k ≤ KC that is the scalar oracle's order exactly, and above it the -block partials regroup the sum (a valid reordering, as in every blocked BLAS). -At 128×128 the result matched NumPy's `A@B` against OpenBLAS with max abs diff -**0.0**. - -### Packed GEMM vs the PRIOR kernel (why it ships) - -Measured back-to-back in one binary under identical load (so the ratio is robust -to the noisy VM); the prior kernel is the register-blocked/ikj path this replaces: - -| n | prior kernel | packed | speedup | -|------:|------:|------:|:--:| -| 64 | 6–9 GF | 20–21 GF | **2.2–3.4×** | -| 128 | 16 GF | 32–52 GF | **3.1–4.2×** | -| 256 | 10–19 GF| 53–119 GF| **5.0–6.2×** | -| 512 | 13–21 GF| 80–156 GF| **6.0–7.3×** | -| 1024 | 16–22 GF| 99–156 GF| **6.2–7.2×** | - -The packed kernel wins at **every** size, so it is shipped uniformly — no -size-routing — and the old `n ≤ 224` blocked/ikj split is removed. - -### Where the remaining gap is (small n only) - -At n=1024 there is effectively **no** kernel-throughput gap left: the by-lane -FMLA inner loop runs ~58 GFLOP/s/core, the full GEMM hits ~203 GFLOP/s, and -go ÷ OB straddles 1.00×. The only sizes still short are **small** matrices, and -there the gap is *fixed per-call overhead*, not compute: - -- At n=256 the matmul is ~250 µs of real work, but every call still pays the full - panel-pack pass and a 4-way goroutine fan-out. OpenBLAS amortises that setup - far better (smaller pack cost, a thread pool that is already warm), so it leads - 0.67× here even though the per-core FMA rate is the same. As n grows the setup - is amortised away — n=512 is already 0.97×. - -This is a small-matrix overhead ceiling. Closing it further would mean a -serial-vs-parallel crossover tuned per size and a cheaper pack for tiny panels; -it is not a Go-assembler or algorithm limit, and it does not affect the -large-matrix parity result. (amd64 now has its AVX2/FMA kernel too; see the -real-hardware section below.) - -### Footnote: vs reference BLAS - -Against Debian's single-threaded **reference BLAS 3.12.1** (the prior comparison -on this page), the packed GEMM wins by a wide margin at every size — but that is -not a meaningful bar for a matmul claim, so the table above uses OpenBLAS. - -## Correctness - -Every result is validated **bit-for-bit against NumPy** (shapes and values), not -just timed — see the cross-check in the work log: `Sqrt` (bit-identical to -`np.sqrt` via a `uint64` view), `Max`, `Min` (exact, incl. NaN propagation), -`Add`, `Mul` and the full `MatMul` (zero max-abs-diff against `A @ A`) match -NumPy at n = 100 000 / 80×80. - -The SIMD kernels are also validated against the scalar oracle per-arch in CI: - -- **Sqrt** — `SQRTPD` (amd64) is held **bit-identical** to a scalar `math.Sqrt` - loop across every length residue mod 8 and the IEEE edge cases (negatives → - NaN, ±Inf, signed zeros, explicit NaN); sqrt is a single correctly-rounded - operation, so bit-identity (not mere closeness) is the contract. -- **Max / Min** — the four-accumulator reducer and the amd64 `MAXPD`/`MINPD` - kernel are held **bit-identical** to the builtin-`max`/`min` oracle across - every length and with a NaN injected at the start, middle, end, and as the sole - element (all must return NaN); ±Inf and signed zeros bit-match too. -- **Add / Sub / Mul / Div** — the elementwise SIMD kernels are held - **bit-identical** to the scalar oracle across every length residue mod 8 and the - IEEE edge inputs (signed zeros, ±Inf, NaN, extremes): each lane is a single - independent correctly-rounded IEEE op (no reduction grouping), and on arm64 the - FMA-against-an-exact-constant form (`b+a·1`, `a−b·1`, `0+a·b`) rounds identically - to the plain op, so bit-identity is the contract. -- **Sum** — validated to a tight *relative tolerance* (not bit-identity): its - lane-parallel grouping is a valid reordering, the same kind NumPy's pairwise - summation uses, so closeness — not bit-identity — is the contract for a - floating-point reduction. - -## Where go-ndarray still loses, and the plan - -| op | status | note | -|----|--------|-----| -| `Add`/`Sub`/`Mul`/`Div`/`Sqrt`, **alloc** form, small n (≈1 K) | NumPy wins (Go-allocator ceiling) | the result `make` (~900 ns zeroing 8 KiB) is the whole cost; Go has no unzeroed-alloc escape and pooling would break result ownership. The `*Into` no-alloc form **wins 1.8–2.4×** — the supported parity path. Large-n alloc form already wins ~2×. | -| `Add`/`Sub`/`Mul`/`Div` | **FIXED — go wins ~2× large n, *Into* wins all n** | new SIMD kernels: packed SSE2 `ADD/SUB/MUL/DIVPD` (amd64), NEON `VFMLA`/`VFMLS` (arm64); bit-identical to the scalar oracle | -| `Sqrt` | **FIXED — go wins 2.8× at 4 M, *Into* 1.8× small** | packed `SQRTPD` (amd64) / packed NEON `FSQRT V.2D` (arm64) / intrinsic `FSQRTD` (others), off a non-`func`-pointer seam | -| `Max` / `Min` | **FIXED — go wins 1.4× at 4 M, ~4× small** | builtin-`max` NaN-propagating oracle + 4-accumulator reducer + amd64 `MAXPD`+NaN-scan; NEON `FMAX`/`FMIN` on arm64 since v0.5.2 | -| `MatMul` vs tuned BLAS (OpenBLAS) | **FIXED — parity at n=1024 (~0.99×, ~203 GFLOP/s); 0.97× at n=512** | by-lane FMLA micro-kernel (`FMLA Vd.2D,Vn.2D,Vm.D[i]` via `WORD`) closed the prior 0.76× gap. Only small n=256 trails (0.67×) on per-call overhead, not throughput — see above | -| `Exp`, `Log`, `Log10` | **FIXED in v0.2.0** — own kernels | ports of Arm's optimized-routines, 2–3.5× `math.Exp`/`math.Log`; see the Exp and Log sections below | -| `Sin`, `Cos`, `Tan`, … (through `Map`) | ~parity with NumPy | per element on one Zen 3 core: Go `math.Sin` 10.5 ns vs NumPy 10.4, `math.Cos` 11.1 vs 10.7, `math.Tanh` 16.5 vs 13.5 (2026-10-04); `Map`'s call per element adds 15–20% | - -## Allocation: `Workspace` (2026-10-04) - -On real amd64 hardware the allocating forms were far worse than on the M4: on -a Zen 3 (16 cores), `x + y` lost to NumPy 0.13–0.25× at every size up to 256 Ki -elements, while `AddInto` won. Slicing copies, `Concatenate` and broadcasting -adds (whose results also allocate) lost 5–9×. Measured causes, on M4: - -- **The garbage collector.** A benchmark's live heap is tiny, so a 128 KiB - result per call triggered a GC cycle every ~22 calls (912 cycles in 20 000). - With 256 MiB–2 GiB of live heap, which is closer to a real program, the - overhead halves: 8.3 → 3.6 µs at 16 Ki, against 2.4 µs for `AddInto`. -- **The rest:** `make` zeroes memory the kernel overwrites anyway, and fresh - memory is cold or page-faulted. `GOGC=off` does not help: it trades the - cycles for page faults (9.4 µs). - -A library must not set `GOGC`, and Go has no unzeroed allocation. What NumPy's -reference counting gets for free is scoped reuse. ND4J, the Java ndarray -library, has the same problem and answers it with *workspaces*: an arena that -results of one pass come from, recycled at the end of the pass. `Workspace` is -that. `ws.Use(x)` binds an input, every result computed from a bound array is -carved from the arena (64-byte aligned, capped so an `append` cannot spill), -results stay bound so a chain stays in it, and `Reset` recycles everything, -folding a pass that needed several blocks into one block. Sites whose kernel -writes every element (elementwise, ufuncs, copies, broadcasts, concatenation, -`Where`, `Clip`, scans, `Take`, `Outer`, mat·vec) skip the zeroing. Those that -accumulate (GEMM, vec·mat, axis reductions) clear their slot. - -Zen 3, 16 cores pinned, NumPy 2.5.3 single-threaded, best of two runs: - -| op | n | heap | **Workspace** | `*Into` | NumPy | Workspace vs NumPy | -|----|--:|--:|--:|--:|--:|:--:| -| Add | 1 024 | 2.6 µs | **0.38 µs** | 0.31 µs | 0.82 µs | 2.1× | -| Add | 16 Ki | 35 µs | **4.6 µs** | 4.3 µs | 5.1 µs | 1.1× | -| Add | 256 Ki | 498 µs | **67 µs** | 75 µs | 150 µs | 2.2× | -| Add | 4 Mi | 8.0 ms | **2.2 ms** | 2.2 ms | 6.3 ms | 2.9× | -| sqrt(x·y+x) | 1 024 | 12.2 µs | **2.6 µs** | — | 3.6 µs | 1.4× | -| sqrt(x·y+x) | 16 Ki | 112 µs | **33 µs** | — | 34 µs | 1.0× | -| sqrt(x·y+x) | 256 Ki | 1.18 ms | **0.39 ms** | — | 2.97 ms | 7.5× | -| sqrt(x·y+x) | 4 Mi | 23.4 ms | **9.1 ms** | — | 19.2 ms | 2.1× | - -On M4 the same chain gains ×5 at 1 024 and ×1.6 at 4 Mi. Correctness: every -operation is checked bit for bit against its heap result, including on passes -whose recycled memory was first filled with NaN (a site that skipped zeroing it -needed would leak the NaN; planting that bug in the GEMM made the test fail). - -## amd64 on real hardware (2026-10-04) - -Every figure above was taken on Apple silicon. This round measured amd64 for -the first time, on an AMD EPYC 7773X (Zen 3, GCC Compile Farm cfarm421), with -go-ndarray and NumPy both pinned to the same 16 cores of one NUMA node -(`taskset -c 16-31`; Go's GOMAXPROCS follows the affinity mask). NumPy 2.5.3 -with its bundled OpenBLAS 0.3.34 (DYNAMIC_ARCH, Haswell kernels on Zen 3), -once with `OPENBLAS_NUM_THREADS=1` and once with 16. go rows are the minimum -of three runs interleaved with the previous version, so drift on a shared host -hits both alike. - -| op | before | after | gain | vs OpenBLAS 1 thread | vs OpenBLAS 16 threads | -|----|------:|------:|:--:|:--:|:--:| -| MatMul 1024² | 21.87 ms | **8.05 ms** | ×2.72 | 5.93× | 0.60× | -| MatMul 512² | 3.69 ms | 1.56 ms | ×2.36 | 3.89× | 0.66× | -| MatMul 256² | 0.661 ms | 0.558 ms | ×1.19 | 1.36× | 0.30× | -| MatMul 128² | 0.210 ms | 0.141 ms | ×1.49 | 0.73× | 0.36× | -| MatMul 1024×256·256×1024 | 5.72 ms | 2.71 ms | ×2.11 | 4.55× | 0.54× | -| Inner 512² (a·bᵀ) | 5.87 ms | **1.47 ms** | ×4.00 | 4.19× | **0.96×** | - -Four changes, each measured on its own before the next: - -1. **AVX2/FMA 6×8 micro-kernel** (the BLIS Haswell shape). In L1 it runs at - 43 GFLOP/s on one Cascade Lake core (16 flops per cycle at about 2.7 GHz, - if the virtual machine's unknown clock is near that); the SSE2 4×4 it - replaces had no FMA and half the vector width. -2. **Edge tiles through the micro-kernel.** With MR=6, every 256-row MC block - ends in a 4-row edge panel, and the scalar edge loop took **40%** of one - profile covering serial 64² and 512² products. A partial tile now runs the - kernel into a scratch tile. -3. **Prefetch** of the C tile at kernel entry and of the A panel eight steps - ahead: serial 768² went from 27.5 to 34.7 GFLOP/s (+26%). 90% of the time was - already in the kernel, which ran at 28 GFLOP/s there against 43 with its - panels in L1, so it was waiting on memory. A sweep of MC (48–252) and KC - (128–512) moved nothing (±3%), so the blocking is unchanged. -4. **B packed by all workers.** MatMulP packs the shared B block once per - (jc, pc) round, and one goroutine did it while the others waited: +30–70% - in parallel. Below 64 Ki elements the launch costs more than the copy (M4: - −8–15% at 96³–128²), so small blocks are still packed by one. - -`Inner` gained the most because it used to materialise bᵀ before multiplying. -The GEMM now reads any 2-D view through its strides while packing (the copy it -makes anyway), so a transposed, sliced, reversed or broadcast operand costs no -extra pass. - -**Still behind OpenBLAS with 16 threads** (0.54–0.66× on large products, 0.3× -below 256²). Where the parallel loss is, measured on the Zen 3: - -- It is not the hardware: 16 *independent* serial 512² GEMMs, one per core, - each ran at 41 GFLOP/s (656 in total), while the parallel 1024² GEMM ran its - kernel at 15–17 GFLOP/s per core. Per-core efficiency already drops from 40 - to ~30 GFLOP/s at 2 cores. -- ~~It is not scheduling overhead: 83% of the parallel profile is the - micro-kernel itself, so the kernel runs slower when cores share work.~~ - **Wrong, corrected the same day:** a profile of cycles samples only the cores - that run, never the ones that wait. `perf stat` shows 7.1 of 16 CPUs busy - during a 1024² product: the loss was mostly waiting, and the fix is in - [Parallel GEMM: start once, wait on work](#parallel-gemm-start-once-wait-on-work-2026-10-04). - Why the kernel itself also runs slower in parallel is still **not established**. The shared packed B panel is the suspect (on this part - the last-level cache is split between core complexes, so the panel is partly - remote), but the drop already shows at 2 cores of one complex, which that - explanation does not cover. -- Taller row bands (more reuse of each B micro-panel) do not help: interleaved - over 5 runs they were worse at 1024² and equal at 2048². -- **A 2-D tiling of C with private packing** (no barrier, nothing shared) was - built and measured, and is **not shipped**: ×1.24 at 1024² on the Zen 3, but - ×0.62 at 512² and Inner, and ×0.70–0.93 at every size on M4, whose cluster - L2 holds the shared panel well. A tile repacks (rows + cols)·k elements for - rows·cols·k multiply-adds, and a strided pack costs far more per element than - an FMA, so tiles under ~256×256 lose. - -The hardware counters were read the same day; see the next-but-one -section. An AVX-512 kernel for the hosts that have it remains open. - -Correctness was checked on real hardware for five of the six 64-bit targets: -amd64 (Zen 3, FMA path), arm64 (cfarm185), ppc64le (cfarm120), riscv64 (cfarm94) -and loong64 (cfarm401). The s390x host (LinuxONE) did not answer; s390x stays -covered by the qemu lane in CI. - -## Axis reductions (2026-10-04) - -On the Zen 3 the axis reductions of a 1024×1024 matrix lost to NumPy 0.23–0.43×. -Two causes, both measured: - -- **An axis-0 reduction ran on one core.** It has a single outer slab, and the - driver only split outer slabs; the comment justifying that said a column split - would need a strided gather. It does not: a band of columns is a contiguous run - of every row. `RunAxisP` now splits the columns into bands of at least 512 - when there are fewer slabs than workers, and each row run is added with the - elementwise SIMD kernel (exact per element, so the sum is still in axis order). -- **A row reduction (axis 1, `inner` = 1) was a sequential scalar loop**, - latency-bound at one add per 4 cycles. Each row now goes through the SIMD - sum/max/min. Max and min are exact; the sum is regrouped lane-parallel, as - NumPy's own pairwise sum is. - -Zen 3, 16 cores pinned, minimum of three interleaved runs: - -| op (1024×1024) | before | after | NumPy 1 thread | after vs NumPy | -|----|--:|--:|--:|:--:| -| SumAxis(0) | 696 µs | **186 µs** | 180 µs | 0.97× | -| SumAxis(1) | 531 µs | **92 µs** | 224 µs | 2.4× | -| MaxAxis(1) | 601 µs | **93 µs** | 189 µs | 2.0× | - -On M4: MaxAxis(1) ×6–8, SumAxis(0) ×1.9. -## Broadcasting a row without materialising it (2026-10-04) - -`M + row` (1024×1024 plus 1×1024) lost to NumPy 0.26× on the Zen 3 even with a -`Workspace`: the broadcast path copied the row out to a full 8 MiB matrix -before adding. When one operand has the full shape and the other, leading 1s -dropped, is a suffix of it (a row, a 2-D mean subtracted from a 3-D stack), the -second simply repeats in blocks of its own length, so `binOp` now streams the -live slices block by block through the SIMD kernel (`kernels.RepeatP`), split -across cores. Below 64 elements per block the old path is kept. - -| BroadcastAdd 1024²+row, Zen 3 | heap | `Workspace` | NumPy 1 thread | -|----|--:|--:|--:| -| before | 3.1 ms | 1.76 ms | 460 µs | -| after | **0.84 ms** | **114–196 µs** | 460 µs | -## Exp (2026-10-04) - -`Exp` went through `Map(math.Exp)`, and `math.Exp` itself was the cost: 13.2 ns -per element on one Zen 3 core, 3.9 ns on M4 (the func-pointer call added only -15–20%). It is now a port of the double-precision exp of Arm's -optimized-routines (Szabolcs Nagy; glibc's exp since 2.28; MIT licence): x = -k·ln2/128 + r, 2^(k/128) from a 128-entry table as scale·(1+tail), exp(r)−1 from -a degree-5 polynomial. The common case is written out in the loop, because the -function is too large to inline and a call per element cost a third of the -time. - -- **Accuracy:** worst 0.504 ULP over 80 000 inputs across the whole finite - range, the region near 0, the overflow edge and the subnormal range, - measured against a 300-bit reference (`math/big`, ln 2 from its atanh - series), against Arm's documented 0.509; the committed test samples 20 000 - of them and finds 0.501. `math.Exp` measured 0.879 ULP on the 80 000 inputs - on arm64 (0.841 on the test's 20 000). The table was not trusted as copied: - `TestExpTable` re-derives all 256 entries from 2^(k/128) at 300 bits. -- **A Go bug it removes**, reported as - [golang/go#81995](https://github.com/golang/go/issues/81995): on amd64, - `math.Exp` returns **+Inf for 709.436139303104 ≤ x < 709.782712893384**, - although the result is finite there (NumPy, glibc and mpmath: - `exp(709.5)` = 1.3549863193146328e+308). Its assembly rounds k = x·log2(e) to - 1024 from x = 1023.5·ln 2, and the biased-exponent check treats that as - overflow although the reduced factor is below 1. Reproduced with Go 1.26.8 and - 1.27.1 on AMD Zen 3, Intel Cascade Lake and Intel Haswell, with and without - the FMA path; the assembly is unchanged on master. go-ndarray's `Exp` - inherited it until v0.2.0; `TestExpTopOfRange` pins the fix. -- Just above ln(2^-1075), exp rounds up to the smallest subnormal, as glibc - and NumPy do. Go's arm64 `math.Exp` returns 0 there (amd64 does not); that is - one unit of the last subnormal place, not a contract violation. - -Speed, kernel only, 16 Ki elements: M4 30 µs (`math.Exp` 65 µs); one Zen 3 core -62 µs (`math.Exp` 215 µs, NumPy single-threaded 78 µs). Whole `Exp` on Zen 3, -16 cores: 1 Ki ×2.7, 16 Ki ×3, 256 Ki ×1.6, 4 Mi ×1.65; from 256 Ki on it is -2–5× NumPy. A SIMD version (AVX2 gathers for the table) is the next step. -## Dot and mat·vec (2026-10-04) - -`dotRange`, under `Dot` (1-D), `MatVec` and `Dot1DP`, was a four-chain scalar -loop (gc does not vectorise it): on one Zen 3 core 2^20 elements took 464 µs, -2× NumPy's single thread. Two generated kernels replace it: `dotFMA` on amd64 -(four YMM accumulators, b read straight from memory by VFMADD231PD; two loads -per FMA make it load-bound, which four chains cover), gated on the FMA probe, -and `dotNEON` on arm64 (eight D2 accumulators for the four FP pipes of an Apple -core). The other targets keep `dotRange`. The lane-parallel sum is a regrouping, -held to the n·ε·Σ|aᵢbᵢ| bound and exact on integer data. - -| Zen 3 | before | after | NumPy 1 thread | -|----|--:|--:|--:| -| Dot 2^20, 1 core | 464 µs | **229 µs** | 231 µs | -| MatVec 1024², 1 core | 482 µs | **134 µs** | 153 µs | -| Dot 2^20, 16 cores | 159 µs | 99 µs | — | -| MatVec 1024², 16 cores | 165 µs | 89 µs | — | - -NumPy's multi-threaded dot still wins on this part (24 µs). The go-ndarray -version stops scaling at about 90 GB/s from 4 cores on. ~~A likely cause, not -verified, is cache affinity: OpenMP keeps each thread on one core, so it -rereads its own chunk from its own L3 slice on every repetition, while -goroutines move between core complexes.~~ - -**Measured 2026-10-07 (POWER9 bare metal, cfarm29, one thread per core):** -the cache-affinity guess does not hold. The pattern is the fork/join of -`parallelFor`, the same as the GEMM's (see *Parallel GEMM: start once, wait on -work*). A 2^20 dot took 212 µs on 4 cores and 553 µs on 8, close to the 688 µs -of one core. With 8 workers, only 2.4 CPUs were busy (`perf stat`). Three -replacements for `parallelFor` were built and measured against it -(8–10 interleaved rounds); **none is shipped**: - -| scheduler | 8 workers | 4 workers | -|---|---|---| -| shared queue of 4w blocks, caller works | Dot 1.55×, MatVec 2.0×, SumAxis(1) 2.0× | Sum 4 Mi 0.51×, Dot 0.55× | -| owned runs (the static chunks) + stealing, caller works | Dot 2.18×, MatVec 2.0× | Sum 4 Mi 0.63×, Dot 0.68× | -| owned runs + stealing, caller blocks, last block wakes it | mostly 1.0–1.4×, Exp 1.87× | Sum and AddInto at 256 Ki 0.51–0.55× | - -The second row keeps exactly the static chunks of the original and still -loses at 4 workers, so affinity is not the cause. `perf stat` places the loss -in scheduling instead: with the caller working, 2.1 of 4 CPUs were busy -against 3.4 for the original. Its goroutines wait in the run queue of the -caller's processor until another one is woken to steal them. With the caller -blocking, the 4-worker case recovers, but the 8-worker gains go with it. The -code is on the `experiment/parallel-for-scheduling` branch. - -**Solved in v0.6.0 with a persistent helper pool.** The cost being paid was -waking threads, so the fix is to stop putting them to sleep between back-to-back -operations, as OpenBLAS and OpenMP do. Up to `GOMAXPROCS-1` helper goroutines -are started once. After an operation they poll for the next one for 200 µs, -then park. The work keeps owned runs with stealing (row two of the table -above). Reductions now use four partials per worker: with one each, a helper -that saw the job late had its only block taken by the caller, who then summed -two in a row (Sum of 4 Mi on 4 cores: 2.8 of 4 CPUs busy). On POWER9 bare metal, -each case in a **fresh process**, 6 interleaved rounds, against v0.5.2 -(median): - -| | 2 workers | 4 workers | 8 workers | 8 workers, before → after | -|---|---|---|---|---| -| Dot 2^20 | 3.29× | 2.41× | **10.8×** | 625 → 58 µs | -| MatVec 1024² | 2.01× | 1.65× | 5.78× | 323 → 56 µs | -| SumAxis(1) | 2.06× | 1.80× | 7.09× | 310 → 44 µs | -| Sum 256 Ki | 2.33× | 3.23× | 5.60× | 61 → 11 µs | -| Max 256 Ki | 1.75× | 1.34× | 6.78× | 757 → 112 µs | -| AddInto 256 Ki | 2.22× | 1.91× | 5.57× | 102 → 18 µs | -| Exp 256 Ki | 1.18× | 1.49× | 1.97× | 1013 → 513 µs | -| Sum 4 Mi | 1.33× | 1.17× | 1.41× | 431 → 305 µs | -| Exp 4 Mi | 1.00× | 1.10× (p25 0.84×) | 1.29× | | -| BroadcastAdd | 0.95× | 1.01× | 1.67× | | -| Chain (Mul, Add, Sqrt) 4 Mi | | 0.91× | 1.04× | | -| MatMul 256², 512² (own driver) | | 1.00× | 0.96–0.99× | | - -A first combined run, with every benchmark in one process, also showed serial -operations speeding up (Sqrt of 1 Ki, 1.5–2.2×). Run alone, those are equal. -That gain belonged to whatever state the earlier benchmarks had left, not to -this change, so only fresh-process figures are reported here. The cost: after -an operation, the helpers spend up to 200 µs of CPU polling. They are never -stopped (see the README, *Goroutines*). - -**Zen 3 (cfarm421, back up on 2026-10-07)**, each case in a fresh process, -6 interleaved rounds, v0.6.1 against v0.5.2 (median): - -| | 2 workers | 8 workers | 16 workers | 16: before → after | -|---|---|---|---|---| -| Dot 2^20 | 1.24× | 2.06× | 2.08× | 120 → 57 µs | -| MatVec 1024² | 1.41× | 2.26× | 1.92× | 94 → 49 µs | -| Sum 256 Ki | 1.61× | 2.12× | 2.31× | 37 → 16 µs | -| Sum 4 Mi | 1.10× | 1.95× | 2.07× | 184 → 89 µs | -| Max 256 Ki | 1.47× | 2.38× | 2.36× | 41 → 18 µs | -| SumAxis(1) | 1.25× | 2.20× | 1.63× | 93 → 57 µs | -| AddInto 256 Ki | 1.39× | 1.22× | 1.29× | 74 → 57 µs | -| Exp 256 Ki | 1.05× | **0.85×** | 1.13× | | -| Exp 4 Mi | 1.28× | 1.09× | **0.94×** | | -| Chain 4 Mi | 1.74× | 1.04× | **0.88×** | | - -In the same run, NumPy 2.5.3 with 16 OpenBLAS threads took 39 µs for the 2^20 -dot and 25 µs for the 1024² mat·vec, so go-ndarray is at **0.69×** and **0.52×** -of it (0.16× before the pool). The losses at the top of each table are long, -memory-bound chains, where all workers stream at once; not investigated further. -Apple M not measured (shared machine). - -## Log and Log10 (2026-10-04) - -`Log` went through `Map(math.Log)`: 11.6 ns per element on a Zen 3 core -against NumPy's 4.2. It is now a port of the double-precision log of Arm's -optimized-routines (the companion of `Exp`, also glibc's since 2.28; MIT -licence): x = 2^k·z, z split into 128 subintervals, log(x) = k·ln2 + log(c) + -log1p(z/c − 1) with 1/c and log(c) from a table and a degree-6 polynomial; -inputs within 2⁻⁴ of 1 take a degree-12 polynomial. z/c − 1 is one -`math.FMA` (exact on every target), so the second table the non-FMA variant -needs is not used. `Log10` is log(x)·(1/ln10), the formula of `math.Log10`, on -this log. - -- **Accuracy:** worst 0.508 ULP over the test's 10 000 inputs (whole range, - near 1, subnormals), against a 300-bit reference (log m = - 2·atanh((m−1)/(m+1))); `math.Log` measured 0.724 on the same inputs on - arm64. `TestLogTable` checks each of the 128 - entries against the properties `log_data.c` documents (1/invc inside its - subinterval, logc = log(c) within the rounding of invc, 0x1.8p9 + logc - exact). -- **A second Go bug it removes**, known since 2022 as - [golang/go#56600](https://github.com/golang/go/issues/56600) (fix pending in - CL 448216): on amd64, `math.Log` is wrong for every subnormal input. - `math.Log(5e-324)` returns −709.09 instead of −744.44, and `math.Log(1e-310)` - −709.09 instead of −713.80 (NumPy, glibc: −744.44, −713.80). Its assembly - inlines `Frexp` with bit masks that assume a normal input. Reproduced with Go - 1.26.8 and 1.27.1 on three x86 CPUs. `Log` and `Log10` inherited it; `TestLogSubnormal` pins the fix. - (`Log2` already normalised through `math.Frexp` and was right.) - -Speed, 16 Ki elements, one core: M4 37 µs (`math.Log` 72), Zen 3 73 µs -(`math.Log` 175, NumPy 69). - -## ppc64le: VSX kernels (2026-10-04) - -Go's ppc64 assembler has the VSX loads, stores and permutes but no vector -double arithmetic, so ppc64le ran the scalar code. go-asmgen v0.13.0 encodes -`xvadddp`, `xvsubdp`, `xvmuldp`, `xvdivdp`, `xvmaddadp`, `xvmaxdp`, `xvmindp` -and `xvsqrtdp` as `WORD`s, pinned against GNU as 2.44 and run bit-identical to -Go's arithmetic and `math.FMA` on POWER8 and POWER9. With them, go-ndarray has -VSX kernels for sum, dot, sqrt, add/sub/mul/div and the GEMM. - -The GEMM micro-kernel (8×8, 32 accumulators) follows OpenBLAS's POWER9 dgemm: -no splats. Each A pair and its swap multiply each B column pair, so one -accumulator holds a diagonal (C[r][c], C[r+1][c+1]) and another the -anti-diagonal; the store rebuilds the rows with `xxpermdi`. On one POWER9 core, -measured in one binary: 13.3 GFLOP/s with `lxvdsx` splats, 14.8 with `xxpermdi` -splats, 15.4 with those unrolled by two, **16.9** with the diagonal scheme -unrolled by two (shipped). - -POWER9 (cfarm29, 8 cores × SMT4), against the scalar code (best of 3–4 -interleaved runs): - -| op | before | after | gain | -|----|--:|--:|:--:| -| micro-kernel, 1 core | 8.3 GFLOP/s | 16.2 GFLOP/s | ×1.96 | -| serial GEMM 512², 1 core | 7.9 GFLOP/s | 15.8 GFLOP/s | ×2.0 | -| MatMul 1024², 32 threads | 39.2 ms | 23.6 ms | ×1.66 | -| MatMul 128² / 512² | | | ×1.8 / ×1.5 | -| MatVec 1024² | 823 µs | 267 µs | ×3.1 | -| Sum 1 Ki / 16 Ki / 256 Ki | | | ×6.6 / ×7.3 / ×3.2 | -| AddInto 1 Ki–256 Ki | | | ×2.7–4.1 | -| Sqrt, 1 core | | | ×1.05–1.18 | - -At 4 Mi elements sum and add are memory-bound (×1.1). **Against OpenBLAS** -0.3.34 (its POWER9 kernel, loaded through `scipy-openblas64` on the same host; -the ppc64le NumPy wheel there has no BLAS): one thread 22.8 GFLOP/s at 512², -so the serial GEMM is at 0.69×; 32 threads 13.1 ms at 1024², so MatMul is at -0.56×. OpenBLAS unrolls further and uses POWER9-only `lxv` loads; Go's ppc64le -baseline is POWER8. - -## loong64: LASX kernels (2026-10-04) - -Go's loong64 assembler has LASX vector float64 add/sub/mul/div, square root, -256-bit loads and the broadcast load (spelled as an arrangement, -`XVMOVQ off(R), X.V4`, which assembles to `xvldrepl.d`), but no fused -multiply-add; go-asmgen v0.14.0 encodes `xvfmadd.d` as a `WORD` (pinned -against GNU as 2.43, bit-identical to `math.FMA` on a Loongson 3C5000L; -transitional until cmd/asm names it). go-ndarray uses them for sum, dot, sqrt, -add/sub/mul/div and an 8×8 GEMM micro-kernel (16 four-lane accumulators, A -broadcast by `xvldrepl.d`). (v0.2.5 said Go had no broadcast load and emitted -it as a WORD too; it has one, and v0.2.6 emits the mnemonic, the same machine -code.) LASX is not on every LoongArch CPU, so the kernels run only when -the kernel reports it in AT_HWCAP (`/proc/self/auxv`), with the scalar code as -the fallback; the parsing is a pure function tested on every target. - -Loongson 3C5000L (cfarm401, 32 cores), against the scalar code, best of 3 -interleaved runs (the host's load average of ~146 is processes stuck in -uninterruptible sleep; its CPUs measured 100% idle): - -| op | before | after | gain | -|----|--:|--:|:--:| -| micro-kernel, 1 core | 7.0 GFLOP/s | 27.5 GFLOP/s | ×3.95 | -| serial GEMM 512², 1 core | 5.3 GFLOP/s | 20.0 GFLOP/s | ×3.75 | -| MatMul 64² / 512² / 1024², 32 threads | | | ×1.9 / ×1.3 / ×1.1 | -| Sum 256 Ki / 4 Mi, 1 core | 658 / 11 750 µs | 65 / 4 615 µs | ×10.2 / ×2.5 | -| Sum 1 Ki / 16 Ki (below the parallel threshold: one core) | | | ×16 / ×13 | -| Sqrt 1 Ki–16 Ki | | | ×3.8 | -| MatVec 1024² | 364 µs | 224 µs | ×1.6 | - -At 4 Mi elements in parallel, sum and sqrt are memory-bound (parity); the -parallel 4 Mi sum is bimodal on this host (1.6 ms or 7 ms in either version), -so only the minima are compared. No NumPy or BLAS is installable there (the -host has no route to package mirrors), so there is no external reference. - -## Parallel GEMM: start once, wait on work (2026-10-04) - -**Diagnosis, from hardware counters** (`perf stat`, Zen 3 guest cfarm421, -steady state, 1024², 3 s per configuration): - -| workers | CPUs busy | IPC | instructions | -|---|---|---|---| -| 1 | 1.00 | 3.35 | 37.5 G | -| 2 | 1.91 | 2.00 | 37.6 G | -| 8 | 5.58 | 2.16 | 172 G (192 products) | -| 16 | 7.08 | 2.28 | 193 G (226 products) | - -The instruction count does not grow, so nobody busy-waits. L1 misses do not -grow either. What changes is that at 16 workers the CPUs are busy only 44% of -the time. The driver forked and joined goroutines twice per (jc, pc) block of -the product, once to pack B and once for the row bands, 16 times in a 1024² -product. A fork/join wakes parked threads one after another. A micro-benchmark -on the same guest (16 goroutines, 700 µs of work each) measures its cost at -**26 µs with 2 workers, 165 µs with 8, 330 µs with 16**. 16 × 330 µs is ~40% -of a 12.5 ms product. - -Two hypotheses were tested and **refuted** first: - -- SMT siblings. The guest has 128 vCPUs, as many as the host has threads. - Two serial GEMMs pinned on vCPU 16 and any other vCPU slow each other by - 0–10%, never by half. -- A block of A larger than L2. With MC = 256, the A block is 512 KiB, all of - Zen 3's L2, against BLIS's MC = 72. Sweeping MC over {72, 120, 144, 192, 256} - changed nothing beyond the noise at 2 workers, and gave +17% at 16 workers - for 1024² but −8% for 512². - -**The change.** The workers start once per product. They synchronise on -**work done**, never on workers arrived: a block's bands start once all of its -B panels are packed and all bands of the previous block are done, and the -packing is shared in claims of four NR-wide panels. A first version used a -barrier that waited for every worker, and it put the wake-up chain straight -back on the critical path for small products: 96³ on 8 POWER9 cores ran at -0.57×. B is double-buffered, so block i+1 is packed while block i is still -being multiplied. Each C tile is still accumulated in pc order, so the result -is bit-identical to the serial product. The pack buffers moved to two pools, -one for A and one for B: a pair per worker had held 16 MiB of unused B buffers -at 16 workers, which the collector empties and the next call zeroes again -(`memclr` page faults, 20% of the cycles in a KVM spinlock in one profile). - -**Measured.** Every figure is the median of interleaved runs against the -previous driver. - -| | 2 workers | 8 workers | 16 workers | -|---|---|---|---| -| Zen 3 guest, 1024² | 1.06× | 1.33× | 1.39× | -| Zen 3 guest, 512² | 1.20× | 1.20× | 1.33× | -| Zen 3 guest, 256² | 0.92× | 1.19× | 1.24× | -| Zen 3 guest, 128² | **0.89×** | 1.50× | 1.16× | -| Zen 3 guest, (1024×256)·(256×1024) | **0.82×** | 1.07× | 0.89× (p25 1.00×) | -| POWER9 bare metal, 512² | 1.34× | 1.37× | | -| POWER9 bare metal, 256² | 1.61× | 1.78× | | -| POWER9 bare metal, (1024×256)·(256×1024) | 1.13× | 1.18× | | - -The Zen 3 runs were 12 rounds and the POWER9 runs 6, except 24 for 128² and -256² at 8 workers. Those two are bimodal on that machine, in both drivers, and -6 rounds had shown 256² at 0.72×. POWER9 used one thread per core (CPUs 0, 4, -8, …). - -**Still behind.** On the Zen 3 guest, two workers lose 8–18% on mid-size -products, where a fork/join costs only 26 µs. Bare-metal POWER9 gains at two -workers, so this is not a property of the scheme, and no second code path was -added for it. Against OpenBLAS with 16 threads, measured three ways in one run: - -| n | OpenBLAS-16 | previous driver | new driver | -|---|---|---|---| -| 256 | 708 µs | 1.06× | **1.44×** | -| 512 | 789 µs | 0.29× | 0.42× | -| 1024 | 4.60 ms | 0.43× | 0.51× | - -(× = OpenBLAS time / ours, so higher is faster). On this guest, absolute times -drift through the day: the previous driver measured 0.54–0.66× of OpenBLAS in -the morning. Only ratios taken in the same run are comparable. Not measured on -Apple M-series, whose machine was shared with other jobs at the time. The bands -stay dynamic, so its slower efficiency cores are handled as before. - -## Sums of the other dtypes (2026-10-18) - -Measured first, because nothing had: `BenchmarkDTypeSum` and -`BenchmarkDTypeAdd` (`benchmarks/dtype_bench_test.go`) run the same operation -on float64, float32, int64 and int32. On POWER9 (cfarm29, one thread pinned to -the idlest CPU by `hostload -pick 1`, the host 3% busy before and after), a -float32 `Sum` of 2²⁰ elements took **6.85 ms against 0.23 ms for float64**, -6.8 ns an element, while `Add` was only 1.5× slower than float64. A missing -SIMD kernel explains a factor of 4 to 8, not 30. - -The cause was the reduction loop. Every non-float64 reduction walked the -`[outer][axisLen][inner]` layout with the reduced axis outside and the kept -elements inside, so a whole-array sum (`inner` = 1) took a slice and ran a -one-iteration loop per element. A contiguous run is now folded directly, as -NumPy's add and multiply reductions do: the first element, then the rest -added to it, **pairwise for float32 and the complex dtypes** (NumPy's -`FLOAT/CFLOAT_pairwise_sum`, `loops_utils.h.src` at v2.5.3: eight -accumulators, blocks of 128, halved above), in order for the integers (exact -in any order) and for products (NumPy multiplies in order). - -| POWER9, 1 thread, n = 2²⁰ | before | after | | -|---|---:|---:|---:| -| float32 `Sum` | 6.850 ms | 1.637 ms | −76% | -| int64 `Sum` | 5.195 ms | 1.870 ms | −64% | -| int32 `Sum` | 6.170 ms | 2.868 ms | −54% | -| float64 `Sum` (its own kernels; the control) | 225.7 µs | 225.7 µs | ~ | - -Six runs each, old and new binaries alternating, `benchstat` p = 0.002. The -float32 sum is also more accurate: on 2¹⁶ values of mixed sign and magnitude -its error against the exact sum (`math/big`) fell from 75 to 0.87, and 2²⁴ -followed by 1000 ones no longer saturates at 2²⁴ (`sum_pairwise_test.go`). - -The narrow integer sums still converted the whole array to int64 (NumPy's -accumulator) before adding: an extra pass and 8 bytes an element. Since -v0.14.0 a sum or product of bools or integers narrower than 64 bits along a -contiguous axis widens each element as it is read (`foldWidened`, same -arithmetic, wrap-around included): **int32 `Sum` of 2²⁰ elements 2.872 → -1.548 ms (−46%)**, 2¹⁴ −51%, with float64, float32 and int64 unchanged as -controls (POWER9, same protocol, p = 0.002). - -### float32 sums on SSE2 and NEON (2026-10-18) - -NumPy keeps eight accumulators in its pairwise sum so that the inner loop can -be vectorised "without changing summation ordering". `kernels.AddBlocks8F32` -is that loop: blocks of eight float32 added lane by lane, one IEEE addition -per lane per block, in order. It is SSE2 on amd64 (`ADDPS` on two XMM -registers) and NEON on arm64 (`VFADD` on two `.S4` registers), and the scalar -loop elsewhere, so float32 sums are unchanged bit for bit. The kernel is -tested bit for bit against the scalar loop (NaN, the infinities, −0, -subnormals, overflow), and fenced with `PROT_NONE` pages on both operands. A -kernel adding from the wrong register fails the first test, and one reading -a block too many faults in the fence. - -Measured twice. First with the `bench` workflow on the hosted runners -(base and head alternated ten times in fresh processes on one thread, -`benchstat`, p ≤ 0.002), because the development Mac ran at a load of 97 and -most cfarm hosts refuse their keys since the host CA was revoked. Then on -real, idle hardware reached through a known_hosts file whose keys were -confirmed over a second network path: cfarm424 (Neoverse N1, 0.0% busy) and -cfarm420 (Zen 3, EPYC 7773X, 1.7% busy), same protocol, pinned to the idlest -CPU. On those, float32 `Sum` was −39% / −32% (N1) and −43% / −41% (Zen 3) at -2¹⁴ / 2²⁰. int32 was −15% / −11% on the N1, and on the Zen 3 within the -noise (±10% at that host's variance). The controls were unchanged. - -| vs v0.13.x | amd64 (EPYC 9V45) | arm64 (hosted) | -|---|---:|---:| -| float32 `Sum`, 2¹⁴ | −50% | −33% | -| float32 `Sum`, 2²⁰ | −49% | −43% | -| int32 `Sum`, 2¹⁴ | −12% | −12% | -| int32 `Sum`, 2²⁰ | −15% | −13% | -| float64, int64 `Sum` (controls) | ~ | ~ | - -The integer rows come from a defect the first run exposed. With the kernel -added, int32 `Sum` was **48–56% slower on amd64** although its code had not -changed: the disassembly of both binaries showed the same loop, byte for -byte, moved 224 bytes and now straddling a 64-byte line. The loop also -reloaded the operation from the stack every element. The widening folds now -use one loop per operation and four accumulators (exact: integer addition -and multiplication wrap modulo 2⁶⁴, so the order changes nothing). They also -consume the slice instead of indexing `i+1…i+3`, which had left four bounds -checks per iteration in the amd64 code; now there are none. - -**riscv64 and loong64 (2026-10-18).** RVV: `VSETIVLI $8, E32, M2` gives -vl = 8 on every VLEN ≥ 128, and `VFADDVV` adds a block to the two-register -group. LSX: the eight accumulators sit in two 128-bit registers, and -`VFADD.S` (`VADDF`) adds a block. Each runs behind the feature bit its float64 -kernels use. Both were run on the real hardware: the kernel tests passed -there, and a sabotaged kernel failed them, so the kernel path is the one -taken. Same A/B protocol, idle hosts: - -| float32 `Sum`, 2¹⁴ / 2²⁰ | change | controls | -|---|---:|---| -| SpacemiT X60 (cfarm95, RVV, VLEN 256) | −30% / −41% | float64, int32 ~ | -| Loongson 3C5000L (cfarm401, LSX) | −24% / −24% | float64, int32 ~ | - -The first loong64 kernel used one 256-bit LASX register for all eight -accumulators, and was **26–47% slower** than the scalar loop. Every block's -addition waited on the previous one, a single dependency chain, where the -scalar loop has eight. Two registers give two chains, as SSE2 does on amd64. - -**ppc64le (2026-10-18).** Go's assembler has no `xvaddsp`, so go-asmgen -emits it as a `WORD`. The encoding is pinned against GNU as 2.44 and llvm-mc -on a POWER9 (go-asmgen/asmgen#27). The cmd/asm spelling is -[CL 846285](https://go.dev/cl/846285), and go-asmgen's registry check -assembles every entry with that CL applied. The eight accumulators sit in two -VSX registers, loaded and stored with `LXVW4X`/`STXVW4X`, so every word keeps -its lane. VSX is the ppc64le baseline, so there is no feature check. The kernel -tests passed on the POWER9, and a sabotaged kernel (one `xvsubsp`) failed them. - -| float32 `Sum`, 2¹⁴ / 2²⁰ | change | controls | -|---|---:|---| -| POWER9 (cfarm29) | −36% / −48% | float64, int32 ~ | - -**s390x (2026-10-18).** Go's assembler lacks `vfasb` (vector-enhancements -facility 1, z14), which [CL 846286](https://go.dev/cl/846286) adds. go-asmgen -v0.19.0 emits it as `WORD`+`BYTE`+`BYTE`, the 6 bytes pinned against GNU as -(go-asmgen/asmgen#29). The eight accumulators sit in two vector registers; -`VL`/`VST` keep memory order on this big-endian machine. The kernel runs -behind the `useVXE` gate of max/min, so a z13 takes the scalar loop. The -kernel tests passed on the z machine, and a sabotaged kernel (one `vfssb`) -failed them. Same A/B protocol: - -| float32 `Sum`, 2¹⁴ / 2²⁰ | change | controls | -|---|---:|---| -| IBM Z, LinuxONE (vxe, vxe2) | −73% / −78% | int32 ~; float64 ~ / +2.0% | - -The float64 control at 2²⁰ moved +2.0% with its kernel byte-identical: the -change moved `sumVX` by 32 bytes (0x18f920 → 0x18f940). It is attributed to -layout, not proven. - -Every 64-bit target now adds the float32 block with SIMD. - -### float32 elementwise arithmetic (2026-10-18) - -Measured against NumPy 2.5.3 on one POWER9 core, float32 `Add` was -**7.1× slower** at 2¹⁴ (33.8 µs against 4.74 µs) and 5.0× slower at 2²⁰. A -profile put 57% of the time in the generic loop `binNum[float32]`: a scalar -add per element, with two bounds checks, because nothing told the compiler -that `x` and `y` were as long as `d`. - -Two changes: - -- **Bounds checks.** The elementwise loops reslice their operands to - `len(d)` once, so the compiler drops the per-element checks (verified with - `-d=ssa/check_bce`). On the POWER9, float32 and int32 `Add` gain 7–12%. -- **Kernels.** `kernels.Bin32` adds, subtracts, multiplies or divides 16 - floats per iteration on every 64-bit target: - - | target | instructions | - |---|---| - | amd64 | SSE2 `ADDPS`… | - | arm64 | NEON `.S4` | - | ppc64le | VSX `xvaddsp`… | - | s390x | z14 `vfasb`… | - | loong64 | LSX `vfadd.s`… | - | riscv64 | RVV E32, strip-mined | - - Each lane is one IEEE operation, so the result is the scalar loop's bit - for bit. The kernel tests (special values, every length to 70, in place) - and the PROT_NONE fence pass on Zen 3, Apple M, POWER9, IBM Z, SpacemiT X60 - and Loongson 3C5000L. A sabotaged kernel (one `vfsub` in `add32NEON`) - failed them. - -`AddInto` and the other `*Into` methods also wrote a typed result into a new -array and then copied it into `out`. When `out` has the result dtype they now -write into it directly. - -POWER9, one core, against NumPy 2.5.3: - -| float32 | before | after | NumPy | -|---|---:|---:|---:| -| `Add`, 2¹⁴ | 33.8 µs | 15.2 µs | 4.74 µs (`a+b`) | -| `Add`, 2²⁰ | 1.70 ms | 0.537 ms | 0.342 ms | -| `AddInto`, 2¹⁴ | — | **4.35 µs** | 4.23 µs (`out=`) | -| `AddInto`, 2²⁰ | — | **294 µs** | 366 µs | - -float64 `AddInto` against `np.add(out=)`: 5.70 against 7.75 µs (2¹⁴), and -724 against 768 µs (2²⁰). - -### float32 survey against NumPy, and `Sqrt` (2026-10-20) - -The rest of the float32 surface, on the same POWER9 core against NumPy 2.5.3: - -| | ours | NumPy | | -|---|---:|---:|---| -| float32 `Max`, 2¹⁴ / 2²⁰ | 84.7 µs / 4.86 ms | 7.13 µs / 0.324 ms | **11.9× / 15× slower** | -| float64 `Max`, 2¹⁴ / 2²⁰ | 51.6 µs / 3.33 ms | 11.8 µs / 0.615 ms | 4.4× / 5.4× slower | -| float32 `Sqrt`, 2¹⁴ / 2²⁰ | 100 µs / 6.3 ms | 14.7 µs / 0.835 ms | 6.8× / 7.5× slower | -| float64 `Sqrt`, 2¹⁴ / 2²⁰ | 52.5 µs / 2.7 ms | 37.4 µs / 2.3 ms | 1.4× / 1.2× slower | -| `MatMul` 256², float32 / float64 | 20.4 / 2.25 ms | 30.4 / 29.8 ms | NumPy here has no BLAS | - -Read each row against the one below it: - -- float32 `Max` has no kernel anywhere, and on ppc64le neither has float64: - `xvmaxdp` does not propagate NaN as NumPy does. -- float32 `Sqrt` called `math.Sqrt` through a function value for every - element. -- float32 `MatMul` is 9× slower than our own float64 `MatMul`: float32 has no - packed GEMM. - -`Sqrt` is fixed here. `kernels.Sqrt32` runs `SQRTPS`, NEON `.S4`, -`xvsqrtsp`, `vfsqsb` (z14), LSX `vfsqrt.s` or RVV E32. Each is the correctly -rounded float32 square root, so `float32(math.Sqrt(float64(x)))` bit for bit: -float64 carries more than twice float32's precision. - -The kernel tests (special values, every length to 70, in place) and the -PROT_NONE fence pass on Zen 3, Apple M, POWER9, IBM Z, SpacemiT X60 and -Loongson 3C5000L. Measured with fleettools' `abbench` (base and head -alternated, 8 rounds, pinned core, `--max-busy 15`): - -| float32 `Sqrt`, POWER9 | before | after | NumPy | -|---|---:|---:|---:| -| 2¹⁴ | 102.9 µs | **23.4 µs** (−77%) | 14.7 µs | -| 2²⁰ | 6.21 ms | **1.08 ms** (−83%) | 0.835 ms | - -The controls (float64 `Sqrt`, `Add`, `AddInto`) are flat. The gap left to -NumPy is the allocation, the open defect below. - -### float32 `Max`/`Min` (2026-10-26) - -Two changes: - -- **Every architecture.** float32 `Max`/`Min` converted the whole array to a - new float64 array and reduced that. They now read the float32 elements - (`kernels.Max32`/`Min32`), with four independent chains of Go's `max`/`min`. - The value is the same, since the conversion is exact. -- **amd64 and arm64: vector kernels.** - - **amd64:** `MAXPS`/`MINPS` with a `CMPPS`-unordered NaN mask. A zero - result goes back to the scalar loop for its sign, the v0.19.1 rule. - - **arm64:** NEON `VFMAX`/`VFMIN` on `.S4`, which propagate NaN and order - −0 below +0 themselves. - -Each is bit for bit one sequential pass of Go's `max`/`min`. That is tested -with NaN, real signed zeros and infinities at every position up to length 70, -and a sabotaged kernel fails it on both architectures. Measured with -`abbench` (alternated, 6 rounds, pinned core): - -| float32 `Max`, 2¹⁴ / 2²⁰ | change | control (float64) | -|---|---:|---| -| Zen 3 (SSE2) | **−92% / −88%** | ~ | -| Neoverse N1 (NEON) | **−95% / −93%** | ~ | -| POWER9 (Go loop, no kernel yet) | −42% / −34% | ~ | - -On the POWER9 float32 `Max` is still about 7× slower than NumPy, because -Go's NaN-correct `max` is scalar there. - -Still to do: -- ppc64le: done in v0.24.0, below, without a registry entry. -- loong64: done in v0.25.0, below, without a registry entry. - -### loong64 `Max`/`Min`, and negative NaNs (2026-10-26) - -Go's loong64 assembler has no vector float max, min or compare, so the LSX -kernels work entirely in integer arithmetic: - -- **Order:** `t = x XOR ((x >>a N−1) >>l 1)` flips the magnitude bits of the - negatives. The signed order of `t` is the float order, with −0 below +0 - (Go's rule), and the map is its own inverse. -- **Extremum:** `max(acc, t) = acc XOR ((acc XOR t) AND (acc < t))`, with - `vslt`. -- **NaN:** a lane is NaN iff `(x AND abs) >u +Inf's bits` (`vsltu`), - OR-accumulated. - -**What the sabotage run found.** A sabotaged kernel, with the NaN mask of -`max32LSX`'s first lane dropped, *passed* every test in CI's loong64 lane. In -that integer order a **positive** NaN sorts above +Inf, so a max finds it -without any NaN test. Every NaN in the tests was `math.NaN()`, which is -positive. A **negative** NaN sorts below everything, and only the NaN test -catches it. That is no corner case: x86's default NaN (from `0×∞` or `∞−∞`) -is negative. - -The extremum tests now cover both signs, for float32 and float64 on every -architecture. With them the sabotage failed CI's loong64 lane -(`special Max32 n=16: -0, sequential NaN`), and every other architecture's -kernels passed. - -**Validated under emulation only.** The kernels pass on qemu `la464` (LSX and -LASX, required since #67). The Loongson they would be measured on -(cfarm401) accepted TCP but never sent its SSH banner on 2026-10-26, so their -speed is not measured yet. - -### ppc64le `Max`/`Min` (2026-10-26) - -`xvmaxdp`/`xvmindp` (and the `sp` forms) do not propagate NaN as Go and -NumPy do, and Go's assembler has no VSX float compare to build a NaN mask -from. The mask is built in integer arithmetic instead: - -- A lane is NaN iff its magnitude bits (`x AND 0x7FF…F`) exceed +Inf's bits - as an unsigned integer. `vcmpgtud`/`vcmpgtuw` test that on the VMX - registers, against constants loaded from a read-only DATA table, and the - results are OR-accumulated. -- The kernel works only vertically: four accumulators folded into one vector - stored to memory, plus the NaN flag. -- Go folds the lanes and the tail with its `max`/`min`. A zero result goes to - the scalar oracle for its sign, as on amd64. - -| POWER9, `Max` (`abbench`) | 2¹⁴ | 2²⁰ | NumPy 2.5.3, one core | -|---|---:|---:|---| -| float64 | 53.5 → **7.28 µs** (−86%) | 3.48 → **0.472 ms** (−86%) | 11.8 µs / 0.615 ms | -| float32 | 49.9 → **4.58 µs** (−91%) | 3.13 → **0.289 ms** (−91%) | 7.13 µs / 0.324 ms | - -`Max` is now faster than NumPy on the POWER9 for both widths, and the `Sum` -controls are flat. A sabotaged kernel, with one of the four NaN-mask ORs -removed, fails the float32 and float64 oracle tests there. -- s390x: done in v0.23.0, below. - -### s390x float32 `Max`/`Min`, and the z14 float32 kernels measured (2026-10-26) - -The s390x kernels use `VFMAXSB`/`VFMINSB` in mode 1 (Java's Math.max/min): -a NaN operand gives NaN, and −0 orders below +0. Four four-lane accumulators -are folded by an 8-byte `VSLDB` rotation, then lane 1 onto lane 0, behind the -`useVXE` gate (z14). - -The IBM Z (LinuxONE) had looked unreachable since 2026-10-25, but the access -had not expired. After the macOS upgrade the SSH agent held no key, and the -`id_ed25519` the server accepts needs a passphrase. With the key loaded -again, everything was measured on the real machine (`abbench`, 6 rounds, -alternated, pinned core): - -| float32, IBM Z | 2¹⁴ | 2²⁰ | base | -|---|---:|---:|---| -| `Max` | **−98.3%** | **−97.2%** | v0.22.0 (Go loop) | -| `Add` | −80.2% | −85.3% | v0.17.0 | -| `AddInto` | −91.9% | −89.1% | v0.17.0 | -| `Sqrt` | −81.7% | −82.7% | v0.17.0 | - -The float64 controls are flat in both runs. - -On the machine, a sabotaged kernel (one `VFMINSB` lane in `max32VXE`) -fails the sequential-oracle test. The kernel suite passes with -`NDARRAY_REQUIRE_VXE=1`, and so does the root suite. Since #68 the s390x qemu -lane in CI also runs VX and VXE, on qemu's `max` CPU. -- riscv64: done in v0.22.0, below. - -### riscv64 `Max`/`Min` and a sum defect (2026-10-26) - -The RVV kernels for float64 and float32 work in three steps: - -- **Accumulate:** each strip is folded into a register group with `vfmax.vv` - (or `vfmin.vv`). The tail-undisturbed policy keeps a short last strip from - touching the other lanes. -- **Count NaNs:** RISC-V's `fmax` returns the non-NaN operand, so each strip - also counts its NaN lanes (`vmfne.vv x, x`, then `vcpop.m`), and a non-zero - count gives NaN. -- **Fold:** `vfredmax.vs` reduces the group at the end. `fmax` already orders - −0 below +0. - -On a SpacemiT X60 (`abbench`, 6 rounds, alternated): - -| `Max`, 2¹⁴ / 2²⁰ | change | -|---|---:| -| float64 | **−89% / −83%** | -| float32 | **−93% / −90%** | -| `Sum` (controls) | ~ | - -Running the root suite on that real X60 found a defect: -`MeanAxis(1)` of the int64 row `[MaxInt64, MinInt64, −3]` was 0, NumPy's −1. - -- **Cause:** the strip-mined sum kernel took even 3 elements as one vector, - and its unordered reduction added `MaxInt64 + (−3)` first. In float64 that - rounds back to `MaxInt64` and cancels. -- **Fix:** below 8 elements the sum is now sequential, the order NumPy and the - other targets' kernels (whose scalar tails take n < 8 whole) use there. -- **Why CI never saw it:** its riscv64 lane runs under qemu, whose default CPU - has no V, so no RVV kernel had ever run in CI. The lane now enables V - (`QEMU_CPU=rv64,v=true,vlen=256`). It also sets `NDARRAY_REQUIRE_RVV`, so a - CPU without V fails the lane instead of passing on the scalar paths. - -### float32 `MatMul` on the float64 GEMM (2026-10-26) - -float32 `MatMul` ran a generic triple loop, 9× slower than our float64 GEMM. -It now runs on the float64 GEMM: - -- the operands are widened, which is exact and costs O(n²); -- the products are accumulated in float64; -- each element is rounded once to float32. - -That is at least as accurate as a BLAS sgemm, which accumulates in float32, -and every architecture gets the packed, cache-blocked SIMD kernels at once. -With integer-valued operands the result is exact; with random ones it agrees -with the float32 triple loop to float32 precision (`TestMatMulFloat32`). - -| float32 `MatMul` (`abbench`, 5 rounds) | 64² | 256² | float64 control | -|---|---:|---:|---| -| POWER9 | −77% | **−87%** | ~ | -| Zen 3 | −77% | **−90%** | ~ | - -float32 is now within the widening of float64: 86.7 against 54.0 µs at 64², -2.56 against 2.25 ms at 256² on the POWER9. A native float32 GEMM, twice the -lanes per register and half the bytes, could go further; it is the next step -for products. - -### A native float32 GEMM on amd64 and arm64 (2026-10-26) - -The packed GEMM driver (packing, cache blocking, the parallel bands) is now -generic over float32 and float64. Each element type brings its micro-kernel, -its tile and its pack-buffer pools. float32 gets two kernels, generated by -go-asmgen: - -- **arm64:** `gemmMicro8x8S`, a NEON 8×8 tile. It has 16 accumulators and - uses the by-element `FMLA Vd.4S, Vn.4S, Vm.S[i]`, encoded as a `WORD` and - checked against `go tool objdump`. -- **amd64:** `gemmMicro6x16FMA`, the BLIS Haswell sgemm shape: a 6×16 tile, - `VBROADCASTSS` and `VFMADD231PS` into 12 YMM accumulators. It runs only - where the CPU has FMA. - -float32 `MatMul` (and `Dot`, which goes through it) now accumulates in -float32, as NumPy's sgemm does. Without a kernel (no FMA, or the other four -targets) it still widens to the float64 GEMM. The tests check it against a -float32 ikj oracle, bit for bit on integer data, in every block -configuration, on a transposed and offset B view. On random data they bound -it by k·eps32·Σ|ab|. A sabotaged kernel (a wrong A lane on arm64, a wrong A -row on amd64) failed every job that runs one. - -Profiling the first version at 64² found `packA` at 33% of the time, ahead -of the micro-kernel. It gathered A column by column and paid its loop and -bounds checks every MR elements. A row-major A is now read one contiguous -row at a time, which also speeds up float64. - -| `MatMul` (`abbench`, 10 rounds, 1 core) | 64² | 256² | 1024² | -|---|---:|---:|---:| -| Zen 3, float32 | **−61%** | **−56%** | **−55%** | -| Zen 3, float64 | −5.8% | −4.2% | ~ | -| Neoverse N1, float32 | **−64%** | **−60%** | **−57%** | -| Neoverse N1, float64 | ~ | ~ | ~ | - -Against NumPy 2.5.4 (OpenBLAS, `OPENBLAS_NUM_THREADS=1`, the same core): - -| | 64² | 256² | 1024² | -|---|---:|---:|---:| -| Zen 3 float32, ours / NumPy | 15.0 / 8.1 µs | 485 / 366 µs | 26.0 / 23.1 ms | -| Zen 3 float64 | 23.2 / 15.8 µs | 866 / 795 µs | 51.0 / 50.0 ms | -| N1 float32 | 30.4 / 16.2 µs | 1.19 / 0.94 ms | 68.2 / 60.6 ms | -| N1 float64 | 48.2 / 31.3 µs | 2.46 / 1.95 ms | 154 / 123 ms | - -v0.26.1 packs full B panels with one fixed-size array move per row (8 or 16 -elements, lowered to vector moves), where `packB` had copied element by -element. Profiling had put it at 21% of a 64² float32 product. - -| `MatMul` vs v0.26.0 (`abbench`, 10 rounds) | 64² | 256² | 1024² | -|---|---:|---:|---:| -| Zen 3, float32 | −11.6% | −7.1% | −1.7% | -| Zen 3, float64 | −4.4% | −2.3% | ~ | -| Neoverse N1, float32 | −16.4% | −8.9% | ~ | -| Neoverse N1, float64 | −9.5% | ~ | ~ | - -### Reading A in place (2026-10-27) - -v0.27.0 no longer packs a row-major A (unit column stride, positive row -stride). Unpacked-A kernels read it in place: the "rv" gemmsup kernels of -BLIS (`kernels/haswell/3/sup/bli_gemmsup_rv_haswell_asm_s6x16m.c`). B stays -packed, so its last panel is still zero-padded. There is one kernel per tile -height, so a band's bottom rows never read past A's last row. - -- **amd64 (FMA):** each A value is broadcast straight from its row through - a scaled index (`VBROADCASTSS (SI)(R8*2)`), as BLIS does. -- **arm64:** the same idea, but broadcast loads (`VLD1R`) made it 26–78% - slower on a Neoverse N1, apparently because they compete with the FMAs for - the vector pipes. What shipped instead loads 4 floats (2 doubles) of each - row with one plain vector load, and the next k steps read them by lane with - the by-element FMLA. - -| `MatMul` vs v0.26.1 (`abbench`, 10 rounds) | 64² | 256² | 1024² | -|---|---:|---:|---:| -| Zen 3, float32 | −28% | −9.5% | −3.9% | -| Zen 3, float64 | −17% | −5.5% | ~ | -| Neoverse N1, float32 | −20% | −7.7% | ~ | -| Neoverse N1, float64 | −12% | −4.5% | ~ | - -Against NumPy 2.5.4 (OpenBLAS, one core), the ratio ours / NumPy: - -| | 64² | 256² | 1024² | -|---|---:|---:|---:| -| Zen 3 float32 | 1.18 | 1.13 | 1.04 | -| Zen 3 float64 | 1.16 | 1.03 | **0.97** | -| N1 float32 | 1.20 | 1.06 | 1.09 | -| N1 float64 | 1.18 | 1.11 | 1.18 | - -### ppc64le float32 GEMM (2026-10-27) - -`gemmMicro8x16VSX` follows OpenBLAS's POWER9 sgemm -(`kernel/power/sgemm_macros_power9.S`, `LOAD8x16`) and uses no splats. Each -4-float A vector is rotated three ways (`XXSLDWI a, a, $v`), and every -rotation multiplies every B vector: 32 `XVMADDASP` per k step against 6 loads -and 6 rotations. The tile therefore accumulates in a rotated layout, and the -store untangles each 4×4 block with three `XXSEL`s per row. OpenBLAS rotates -B; rotating A costs half as many permutes, because A has two vectors per k -step and B four. - -| float32 `MatMul` vs v0.27.0 (`abbench`, 8 rounds, POWER9) | 64² | 256² | 1024² | -|---|---:|---:|---:| -| widened to the float64 GEMM → native | **−59%** | **−42%** | **−37%** | - -The NumPy 2.5.3 wheel on that host is no reference for products: x @ x takes -0.48 ms at 64² and 2.7 s at 1024², for float32 and float64 alike. That is -14–31× slower than us, consistent with a BLAS that has no POWER9 kernels. A -comparison with a tuned POWER9 OpenBLAS remains to be made. - -### s390x float32 GEMM (2026-10-27) - -`gemmMicro4x16VXE` is the float64 4×8 kernel at twice the lanes: sixteen -four-lane accumulators, the B row in one `VLM`, each A value replicated with -`VLREPF`, and the z14 fused multiply-add `VFMASB`. go-asmgen encodes that -instruction, because Go's assembler has no short-format vector float op. It -needs the vector-enhancements facility 1, so it is gated on `useVXE`, like -the other float32 kernels. Without the facility, float32 products widen to -the float64 GEMM. - -| float32 `MatMul` vs v0.28.0 (`abbench`, 8 rounds, IBM Z) | 64² | 256² | 1024² | -|---|---:|---:|---:| -| widened to the float64 GEMM → native | **−58%** | **−54%** | **−50%** | - -### Reading B in place too, for small products (2026-10-31) - -BLIS's sup kernels pack neither operand: the "rv" kernel advances B by its -row stride (`b += rs_b`) as it advances A by its column stride. v0.29.1 does -the same on the small-product path (below `GemmThreshold`, one worker). When -both operands are row-major, the A-in-place kernels read B's full panels in -place too, advancing B by `ldb` instead of the packed panel's width. Only a -partial last panel is still packed, for its zero padding. The parallel path, -used for larger products, still packs B once and shares it across workers. - -| `MatMul` vs v0.29.0 (`abbench`, 10 rounds) | 64² | 256² | 1024² | -|---|---:|---:|---:| -| Zen 3, float32 | **−16%** | ~ | ~ | -| Zen 3, float64 | **−10%** | ~ | ~ | -| Neoverse N1, float32 | −7.3% | ~ | ~ | -| Neoverse N1, float64 | ~ | ~ | ~ | - -The N1 host was contended (load 31, rows up to ±39%), so its "~" rows show -no regression but are not a precise measurement. On Zen 3, float32 64² now -takes 8.02 µs against NumPy's 8.14: **parity**. float64 takes 16.3 against -15.8 µs (1.03×). - -**Open defect: products are still up to 1.2× slower than OpenBLAS on one -core** at 256² and 1024² on N1, and float32 1024² on Zen 3 is at 1.04×. - -**Open defect: on a busy N1 host we slow down and OpenBLAS does not** -(measured 2026-10-31). The host was at load 21 on 64 cores. Back to back on -the same pinned core, NumPy's times were the usual ones, but ours were about -a third slower than on a quiet host. So the ratio to NumPy grew to: - -| N1, load 21, ours / NumPy | 64² | 256² | 1024² | -|---|---:|---:|---:| -| float64 | 1.27 | 1.40 | 1.54 | -| float32 | 1.39 | 1.25 | 1.27 | - -Ruled out on that host, by A/B: - -- a regression: v0.26.1, v0.27.0 and v0.29.1 are equally slow there; -- reading A in place: v0.26.1, which packs A, is no faster; -- the block sizes: OpenBLAS's `param.h` values (KC 320, MC 240) change - nothing, and NC = 128 or 256 (a B block well inside the 1 MiB L2) moves - float64 by 4–9% but float32 1024² by +4%. - -On the same host quiet again (load 2, 2026-11-01), measured back to back -with NumPy on the same pinned core, the ratio is: - -| N1, load 2, ours / NumPy | 64² | 256² | 1024² | -|---|---:|---:|---:| -| float64 | 1.09 | 1.07 | 1.16 | -| float32 | 1.08 | **1.00** | 1.07 | - -Our float64 1024² went from 192 ms busy to 137 ms quiet, and NumPy's from -124 to 118 ms. So the defect is the sensitivity to a busy host, not the -quiet-host speed. - -Hardware counters (`perf stat`, `perf record -e ll_cache_miss_rd`) locate -it. Per product, at about the same instruction count, we have 4.5× the -L1D refills, 1.6× the L2 refills and 1.7× the last-level read misses of -OpenBLAS (IPC 1.73 against 2.09). The last level is shared, so contention -there costs us more. The misses fall in the micro-kernel: 36.7M in -`gemmUA4x8D`, against 23.4M in OpenBLAS's kernel plus 2.1M in its A -packing and 1.7M in its B packing (about 10 products each). - -Six candidate causes were tested by A/B with counters, and none closes the -gap: - -- a regression; -- reading A in place; -- block sizes, alone (KC 320, MC 240; NC 128, 256, 4096); -- writing C (a fresh, zeroed C against a reused one: misses ±1%); -- software prefetch after `dgemm_kernel_8x4.S` (L2 refills 14.1M → 6.2M, - but last-level misses unchanged and the times worse); -- OpenBLAS's whole structure at once: packed A plus KC 320, MC 240, - NC 4096 (last-level misses 56M against OpenBLAS's 31M). - -What is left is how the micro-kernel itself touches memory. Closing it -would mean rewriting the N1 kernel after OpenBLAS's instruction by -instruction. - -On Zen 3, by contrast, float64 at 1024² is 3% faster than NumPy. - -The float32 kernels for riscv64 and loong64 are written (PRs #80 and #81) -but not yet measured on hardware. - -**Open: the allocating operations.** `Add` is still 1.5–3.2× slower than -NumPy's `a+b`, for float64 as well. The kernel is no longer the difference, -as `AddInto` shows; the allocation is. It splits like this, measured -2026-10-26 (float64 `Add`, 2¹⁴ elements, one POWER9 core, 3 runs each): - -| | time | what it adds | -|---|---:|---| -| `AddInto` (no allocation) | 5.7 µs | the kernel | -| `Add`, `GOGC=off` | 13.5–15.0 µs | +8 µs: a fresh 128 KiB, faulted in and zeroed | -| `Add`, `GOGC=400` | 15.7 µs | fewer GC cycles | -| `Add`, default GC | 23.5 µs | +9 µs of GC cycles | -| NumPy 2.5.3 `a+b` | 8.48 µs | its allocator returns the buffer just freed, still in cache | - -Neither part is the library's to remove. Go zeroes every new allocation, and -the GC's pace belongs to the program. With a live heap this small, a GC -starts every few dozen results, so the benchmark is the worst case. A program -that holds more data runs the GC far less often per operation. - -What does remove it: - -- **Reuse buffers:** the `*Into` methods with a reused `out`, or a - `Workspace` for chains of operations. -- **Tune the GC:** `GOGC` or `GOMEMLIMIT` for a program dominated by - short-lived results. - -### float32 `Exp` and `Log` on the float64 kernels (2026-11-01) - -float32 `Exp` and `Log` called `math.Exp` and `math.Log` per element: 6–14× -slower than NumPy's float32 SIMD on one Zen 3 core. They now run this -package's float64 kernels (the ports of Arm's optimized-routines, 0.504 ULP -worst case in float64). Elements go 256 at a time through a stack buffer: -widened (exact), computed in place, rounded once. Nothing is allocated. The -result is within 0.5 + 2⁻²⁹ float32 ULP, so correctly rounded except -next to a rounding midpoint. Overflow and underflow come from the rounding: -exp(89) is finite in float64 and rounds to +Inf. - -| float32, one Zen 3 core (`abbench`, 8 rounds) | before | after | NumPy 2.5.4 | after / NumPy | -|---|---:|---:|---:|---:| -| `Exp`, 16 Ki | 297 µs | 84 µs | 26 µs | 3.2 | -| `Log`, 16 Ki | 198 µs | 99 µs | 32 µs | 3.1 | -| `Exp`, 1 Mi | 18.8 ms | 5.2 ms | 1.73 ms | 3.0 | -| `Log`, 1 Mi | 12.4 ms | 6.1 ms | 2.02 ms | 3.0 | - -The tests hold the results to `float32(math.Exp/Log(x))`, within one ULP and -equal on at least 99.9% of 100 000 inputs. They also check the edges -(±0, ±Inf, NaN, overflow, the subnormals, log of a negative), every length -across the chunk boundaries, in-place use and the parallel split. - -#### Correctly rounded, proven over every float32 (2026-11-01) - -A float32 function has 2³² inputs, so correct rounding can be checked -exhaustively instead of by sampling. `TestExp32CorrectlyRounded` and -`TestLog32CorrectlyRounded` (set off by `NDARRAY_EXHAUSTIVE=1`; CI samples -every 65 537th input) run every bit pattern. Each result is certified one of -three ways: - -- the float64 intermediate, within 0.504 float64 ULP of the exact value, is - more than 2 float64 ULP from both float32 rounding midpoints; -- or it is far outside float32's range; -- or a 300-bit reference decides the rounding. - -The certifier is itself tested. Planted values on a midpoint, one ULP from -one, and at the overflow boundary are not certified; that last one was a bug, -caught by its test. A kernel sabotaged to round one ULP up fails 76% of exp -and 50% of log inputs. - -Results, identical on darwin/arm64, linux/amd64 (Zen 3), linux/ppc64le -(POWER9), linux/s390x (IBM Z) and linux/riscv64 (Spacemit X60), each run on -the real machine (loong64: CI's sampled run only, no hardware access): - -| | inputs | decided by the reference | not correctly rounded | -|---|---:|---:|---:| -| `Exp` | 4 294 967 296 | 2 | **0** | -| `Log` before the table | 4 294 967 296 | 24 | 5 | -| `Log` with the table | 4 294 967 296 | 24 | **0** | - -`Log`'s double rounding was wrong for 5 inputs, e.g. log(9.47263622): -`0x400fe5e8` instead of `0x400fe5e7`. The fix is the one correctly-rounded -libraries use. A result within 8 float64 ULP of a float32 midpoint (the 29 -dropped bits are 0x10000000 ± 8) is looked up in a table of the 24 inputs -that come within 2 ULP, each with its correctly rounded result from the -300-bit reference. The check costs nothing measurable (−4%, p = 0.07, on a -host at load 15). - -So float32 `Exp` and `Log` are **correctly rounded**: every result is the -float32 nearest the exact value. NumPy's float32 SIMD kernels are documented -at 2.52 and 3.83 ULP in their sources. - -#### float32 `Exp` in AVX2, correctly rounded and as fast as NumPy (2026-11-02) - -NumPy's float32 exp gets its speed in float32 SIMD and gives up accuracy to -do it: 2.52 ULP in its source. We wanted both. Arm's optimized-routines has a -vector `expf` that is correctly rounded (`math/aarch64/advsimd/cr_expf.c`, -MIT). It widens to float64 and uses a 64-entry table of 2^(i/64) plus a -degree-5 polynomial, cheap enough for SIMD. - -- **`expCR`** transcribes it one operation per line, every fused - multiply-add as `math.FMA`, so a kernel can repeat it bit for bit. Its - table is re-derived at 300 bits (`TestExpCRTable`). Its float64 error is - 1.006 ULP, so the exhaustive certifier uses a 4-ULP margin for it. - Over all 2³² float32 inputs: 7 decided by the 300-bit reference, - **0 incorrectly rounded**, with no exception table needed. -- **`exp32CRAVX2`** (go-asmgen, amd64 with AVX2 and FMA) runs the same - operations eight floats at a time. It widens with `VCVTPS2PD`, gathers the - table with `VPGATHERQQ`, assembles the exponent from `z << 46`, and rounds - once with `VCVTPD2PS`. Inputs are clamped to [−512, 512], which keeps the - float32 result: +Inf above, 0 below. NaN lanes are restored from the input. - It is bit-identical to `expCR` on 100 000 inputs, every special value and - every length 0–70, it is fenced, and on a Zen 3 the exhaustive test, which - runs it, finds **0 incorrectly rounded** of 4 294 967 296. -- Every architecture now runs `expCR`: the AVX2 kernel where there is one, - the Go transcription elsewhere. So the bits are the same everywhere. - -| float32 `Exp`, one Zen 3 core | v0.32.0 | AVX2 | NumPy 2.5.4 | AVX2 / NumPy | -|---|---:|---:|---:|---:| -| 16 Ki | 90 µs | **26.9 µs** | 27.1 µs | **0.99** | -| 1 Mi | 5.8 ms | **1.48 ms** | 1.66 ms | **0.89** | - -These are +235% and +289% in throughput (`abbench`, 8 rounds, p = 0.000). The -host was at load 59 on 128 cores; NumPy ran in the same session on the same -pinned core. - -#### float32 `Log` in AVX2, correctly rounded and as fast as NumPy (2026-11-02) - -float32 `Log` was 3× slower than NumPy (`simd_log_f32`, 3.83 ULP in its -source). optimized-routines has no correctly rounded logf; CORE-MATH has one -(`src/binary32/log/logf.c`, Alexei Sibidanov and Paul Zimmermann, MIT). - -- **`logCR`** transcribes it. A 64-entry table and a degree-3 polynomial in - float64 give a lower bound r of log x, with r + 0x1.f06p-33 an upper - bound; when both round to the same float32, that is the answer. Otherwise - a degree-8 polynomial decides: 762 750 of the 2³¹ positive inputs, about - 1 in 2800. Every product is converted to float64 explicitly, which Go - defines as rounding it, so no architecture fuses it into an FMA. -- **Proof, two ways, over all 2³² inputs:** `logCR` equals v0.33.0's `log32` - (widened float64 `Log` plus a 24-entry exception table, no shared code) on - every input, and `Log32` is held to the 300-bit reference by - `TestLog32CorrectlyRounded`: **0 incorrectly rounded**, on darwin/arm64 - (Go) and on Zen 3 (AVX2). -- **`log32CRAVX2`** (go-asmgen, amd64 with AVX2) runs the fast path on eight - floats at a time. The integer part is done in 32-bit lanes, the tables are - gathered with `VGATHERDPD`, and the arithmetic is `VMULPD`/`VADDPD` in - `logCR`'s order, never fused. A block holding a lane that is not a positive - normal float32, or whose bounds round apart, is not stored; it is handed - back to `logCR`. x = 1 gives +0 in the vector. The kernel is bit-identical - to `logCR` on 300 000 inputs and every length 0–70, in place too, and it - is fenced. -- The old via64 `log32` and its exception table now live in a test file as - the oracle; the 24 hardest inputs stay a regression test. - -| float32 `Log`, 16 Ki in [0.25, 24.25), one Zen 3 core, load 0.35 | time | -|---|---:| -| v0.33.0 (via64) | 91.5 µs | -| `logCR` in Go | 82 µs | -| **`log32CRAVX2`** | **31.1 µs** | -| NumPy 2.5.4, `np.log(a, out=o)` | 31.0 µs | - -Through the API (`BenchmarkDTypeExpLog`, allocating), against v0.33.0, -`abbench` 8 interleaved rounds with load at most 8: −65% at 16 Ki (104.6 → -37.1 µs) and −68% at 1 Mi (6.74 → 2.16 ms), p = 0.000. - -The four gathers per block are not the bottleneck: replacing the `tl` gather -by a register copy saves at most 8%. Doing better than NumPy needs a -different algorithm, not a tweak of this one. - -#### Through the API, float32 results come from the Workspace (2026-11-07) - -The table above compares kernels. Through the API, `Log` on a Float32 array -was still 1.25× NumPy at 16 Ki and 1.16× at 1 Mi (35.5 µs against 28.3 µs, -2.07 ms against 1.79 ms; `BenchmarkDTypeExpLog`'s data, 0.25 + (i%97)·0.25, -allocating on both sides). The difference is the result's allocation: -`make` alone costs 4.1 µs at 16 Ki and 130 µs at 1 Mi on that core, because Go -takes a large span from the heap and zeroes it, where NumPy reuses an -unzeroed buffer. On this data the kernel hands no block back to `logCR` -(`handed-blocks/op` in `BenchmarkLog32Kernels`). - -- **Float32 in a Workspace.** The Workspace used to give only float64 - memory, so every Float32 result went to the heap even inside one. - `Workspace.alloc32` now carves float32 results from the same blocks (two - per float64 element, 64-byte aligned, cap = len). It is used for `Exp`, - `Log`, the other float32 ufuncs, arithmetic, `Power`, `Clip` and the - float32 GEMM (zeroed there, since the GEMM accumulates). - `TestWorkspace32MatchesHeap` holds every one of them to the heap result - bit for bit, also after the arena is filled with NaN bits; setting the - GEMM's allocation to unzeroed makes it fail. -- **The kernel's range check** moved to the end of the block: one wrapping - add and one compare, ux + 0x7f800000 ≤ −0x01000001 as int32, select - exactly the positive normals. The separate test and branch before the - arithmetic are gone: −2.4% (`abbench`, 8 rounds, p ≤ 0.03 on three of four - sizes). - -| float32, one Zen 3 core, load ≤ 4.2 | 16 Ki | 1 Mi | -|---|---:|---:| -| `Exp`, allocating | 24.2 µs | 1.39 ms | -| `Exp` in a Workspace | **19.4 µs** | **1.23 ms** | -| NumPy 2.5.4, `np.exp(a)` | 22.0–22.3 µs | 1.37–1.47 ms | -| `Log`, allocating | 34.5 µs | 2.06 ms | -| `Log` in a Workspace | **29.9 µs** | **1.90 ms** | -| NumPy 2.5.4, `np.log(a)` | 28.1–28.8 µs | 1.77–1.82 ms | - -Ours are medians of six runs in two interleaved rounds; NumPy's ranges are -the medians of nine of every quiet round of the day (one 1 Mi `exp` round at -2.47 ms and two 16 Ki `log` rounds at 31–34 µs are left out, which favours -NumPy). In a Workspace, float32 `Exp` is about 12% faster than NumPy and -`Log` about 5% slower; NumPy computes both in float32, at 2.52 and 3.83 ULP. -Allocating, Go's zeroing of each result adds 4 µs at 16 Ki. - -#### float64 `Log` in AVX2: 1.55× faster than NumPy (2026-11-07) - -float64 `Log` was 1.23× slower than NumPy at 16 Ki on Zen 3 (85 µs against -69 µs). NumPy's 4.2 ns per element is glibc's scalar log, which is -optimized-routines' log.c, the code ours ports; the gap was our scalar Go. - -**`logAVX2`** (go-asmgen, AVX2 and FMA) runs `Log`'s table path on four -doubles at a time, with `Log`'s operations in its order: the one FMA it has -(r = z·invc − 1) as `VFMADD231PD`, every other product and sum as a separate -`VMULPD`/`VADDPD`, which is how Go compiles them on amd64. -- `invc` and `logc` are gathered from the 16-byte table rows with - `VPGATHERQQ`. -- AVX2 has no 64-bit arithmetic shift and no int64→float64 conversion, so - the exponent kd = int64(tmp)>>52 is built exactly from u = tmp>>52: - the float64 with bits 0x4330000000000000 | (u ^ 0x800), minus 2^52 + 2048. -- A block holding a lane that is not a positive normal, or one within 2⁻⁴ - of 1 (`Log`'s polynomial path), is not stored; it is handed back to the Go - loop. - -`TestLogAVX2MatchesGo` holds it to the Go loop bit for bit on 300 000 inputs -covering every exponent and table index, near 1, the special values, every -length 0–70 and in place. Dropping the `+ kd·ln2lo` term fails it, while -`TestLogULP` alone (0.5078 ULP worst, unchanged) would not see it. Fenced; -the constant block's layout is tested. - -| float64 `Log`, one Zen 3 core, load ≤ 3.7 | 16 Ki | 1 Mi | -|---|---:|---:| -| v0.35.0 | 83.9 µs | 5.06 ms | -| **AVX2** | **44.4 µs** | **2.59 ms** | -| NumPy 2.5.4, `np.log(a)` | 68.9–69.4 µs | 4.33–4.41 ms | - -−47% and −49% against v0.35.0 (`abbench`, 8 interleaved rounds, p = 0.000), -both allocating, on `BenchmarkDTypeExpLog`'s data. - -Since v0.37.0 these kernels live in go-simd/floats (`floats.Exp`, `Log`, -`Float32Exp`, `Float32Log`, go-simd/floats#29), with NEON versions of the -float32 pair for arm64; ndarray calls them (`internal/kernels/explog.go`). -The NEON kernels are proven correctly rounded over all 2³² inputs on -darwin/arm64 but were not benchmarked: every arm64 host available was at -load 48–100. ppc64le, s390x, riscv64 and loong64 still run the Go -transcriptions. - -#### float64 `Exp` on 16 cores: the allocation, not the kernel (2026-11-08) - -The README listed float64 `Exp` below 256 Ki as slower than NumPy (0.82×), -measured with 16 cores. On one core it is faster at every size. With -GOMAXPROCS = 16 the same serial code (below `ParThreshold`, 32 Ki, nothing -is split) got slower than with 1: 89 µs against 73 at 16 Ki. The same holds -for `Add`, which goes from 14 to 23–26 µs, while `AddInto`, which allocates -nothing, costs 4.3 µs at both. Each result's allocation costs more with more -Ps: every garbage-collector cycle starts and stops work on all of them. In a -`Workspace` (`BenchmarkExpWS`) the difference disappears: - -| float64 `Exp`, Zen 3, load ≤ 5 | 1 Ki | 16 Ki | 256 Ki | 4 Mi | -|---|---:|---:|---:|---:| -| NumPy 2.5.4, `np.exp(a)` (one thread) | 6.0 µs | 85.5 µs | 1.32 ms | 25.0 ms | -| ours, 1 P, allocating | 4.9 µs | 73 µs | 1.11 ms | 19.3 ms | -| ours, 16 P, allocating | 5.8–6.6 µs | 83–95 µs | 0.63–0.78 ms | 4.8–6.7 ms | -| ours, 16 P, `Workspace` | **4.1 µs** | **64 µs** | **0.18 ms** | **1.47 ms** | - -`BenchmarkExp` and `BenchmarkExpWS` (`(i%97)·0.5`, the README's data), two -rounds each at `-cpu 1` and `-cpu 16` on cores 0–15; NumPy medians of nine -on one pinned core. In a `Workspace` on 16 cores, float64 `Exp` is 1.3–1.5× -NumPy up to 16 Ki and 7–17× from 256 Ki. Allocating, it is at parity up to -16 Ki, and at 4 Mi the allocation of each 32 MB result takes about 3.3 ms of -the 4.8. - -## SIMD coverage - -- **amd64 (SSE2)** ships hand-vectorized `sum` (4-accumulator `ADDPD`), `sqrt` - (packed `SQRTPD`), `max`/`min` (`MAXPD`/`MINPD` + `CMPPD` NaN scan), the - **elementwise `add`/`sub`/`mul`/`div`** (packed `ADD/SUB/MUL/DIVPD`, 8 - doubles/iter + scalar tail), all at the GOAMD64=v1 baseline, and the **GEMM - micro-kernel**: `gemmMicro6x8FMA` (6×8, 12 YMM accumulators, `VBROADCASTSD` + - `VFMADD231PD`, C and A prefetched) when go-asmgen's CPUID probe reports FMA - with the OS saving YMM state, else a pair of SSE2 6×4 `MULPD`+`ADDPD` tiles - over the same packing. Generated by go-asmgen and validated per-arch in CI; - the FMA kernel on real Cascade Lake and Zen 3 hosts. float32 products have - their own FMA kernel, `gemmMicro6x16FMA` (6×16, `VFMADD231PS`). -- **arm64 (NEON)** ships hand-vectorized `sum`, `dot`, **packed `sqrt`** - (`VFSQRT`), the **elementwise `add`/`sub`/`mul`/`div`** (`VFADD`/`VFSUB`/ - `VFMUL`/`VFDIV`), **`max`/`min`** (`VFMAX`/`VFMIN`, four D2 accumulators from - 8 elements up) and the **GEMM micro-kernel** (`gemmMicro4x8`: a 4×8 tile, 16 D2 - accumulators, using the **by-lane FMLA** `FMLA Vd.2D, Vn.2D, Vm.D[i]`), with - a float32 one, `gemmMicro8x8S` (8×8, `FMLA Vd.4S, Vn.4S, Vm.S[i]`). Only - the indexed-element FMLA is still a raw `WORD` (16 of them): Go's assembler - does not name it yet (pending as CL 764224). - - ⚠ **Corrected in v0.5.2.** Up to v0.5.1 this paragraph said Go's arm64 - assembler had no vector `FSQRT`, no plain vector FP add, and no vector form for - `div`, so `add`/`sub`/`mul` went through an exact FMA against `1.0`, `sqrt` was - a `WORD`, and `div` and `max`/`min` stayed scalar. Go 1.27 (the floor since - v0.5.0) names all of them; the ISA always had them. Measured on one core, - interleaved against v0.5.1 (8 rounds, median; `Sum`, `SqrtInto` and `AddInto`, - unchanged, measured at 1.00× as the control): - - | one core | `DivInto` | `Max` | `MulInto` | - |---|---|---|---| - | Apple M (local) | 1.34–2.19× | 1.87–2.24× | 1.21× at 1 Ki, 1.00× above | - | X-Gene (cfarm185) | 1.00× | 1.18–1.41× | 1.08–1.35× | - - X-Gene's vector divide is no faster than its scalar one. The sum, dot and - GEMM C-accumulate still fold with an exact `VFMLA` against `1.0`, written - before `VFADD` existed in Go; the result is the same. -- **ppc64le (VSX)**, since v0.2.3: sum, dot, sqrt, add/sub/mul/div and an 8×8 - GEMM micro-kernel; max/min stay scalar (the ISA's `xvmaxdp` NaN rule is not - NumPy's). See the ppc64le section above. -- **loong64 (LASX)**, since v0.2.5, when the kernel reports LASX in AT_HWCAP - (not every LoongArch CPU has it): sum, dot, sqrt, add/sub/mul/div and an 8×8 - GEMM micro-kernel; max/min stay scalar. See the loong64 section above. -- **riscv64 (RVV)**, since v0.7.0, when the kernel reports V in AT_HWCAP (the - vector extension is optional): sum, dot, sqrt and add/sub/mul/div, in - vector-length-agnostic loops (`VSETVLI` strip-mining, LMUL=8, no scalar - tail), every instruction a Go mnemonic. max/min stay scalar: RVV's - `vfmax`/`vfmin` implement IEEE 754-2019 maximumNumber, which drops a NaN - that NumPy propagates. Since v0.7.1 the GEMM micro-kernel is a 6×8 RVV tile - (see below). On a SpacemiT X60 (cfarm95, VLEN = 256), one core pinned to an idle CPU, 6 - interleaved rounds against the scalar code (median): - - | | 1 Ki | 16 Ki | 256 Ki | - |---|---|---|---| - | `Sum` | 5.81× | 2.51× | 1.84× | - | `AddInto` / `MulInto` | 3.2–3.3× | 1.11–1.13× | 1.07–1.12× | - | `DivInto` | 2.27× | 2.30× | 2.28× | - | `SqrtInto` | 2.43× | 2.44× | 2.47× | - - `MatVec` 1024² is 2.02× faster and `Dot` 2^20 1.29×. On a riscv64 CPU without V - (cfarm94, SiFive U74), the same build runs the scalar path and passes the same - tests. - - **GEMM micro-kernel (v0.7.1).** NR is fixed by the packing, while VLEN varies - between RISC-V cores. The V extension guarantees VLEN ≥ 128, so a group of - four registers (LMUL=4) holds at least eight float64, and `VSETIVLI $8` - gives vl = 8 on every core: a 6×8 tile, six accumulator groups, the B row - loaded once per step and each A value fused in with `vfmacc.vf`. Without V, - the same 6×8 tile runs in Go. Measured with `hostload -pick 1` to choose an - idle CPU on a host otherwise 64% busy, and `benchstat` over 8 interleaved runs - each against v0.7.0's scalar 4×4 (all p = 0.000): - - | one core, X60 | 64² | 128² | 256² | 512² | 1024² | - |---|---|---|---|---|---| - | `MatMul` time | −31% | −32% | −45% | −43% | −44% (1.79 s → 1.00 s) | - - `Inner` is −44%, and the small rectangular products are −22% to −38%. At about - 2.1 GFLOP/s on one core this is still far from the hardware: VLEN = 256 with - LMUL=4 uses half of each register group, which keeps one tile portable to - every VLEN at that cost. -- **s390x (vector facility)**, since v0.8.0, when AT_HWCAP reports it: - sum, dot, sqrt and add/sub/mul/div, eight float64 per loop in four - two-lane registers plus a scalar tail, every instruction a Go mnemonic. - Since v0.9.0 the GEMM micro-kernel is a 4×8 tile: sixteen two-lane - accumulators, the B row in one `VLM`, each A value replicated with `VLREPG` - and fused in with `VFMADB`. Since v0.9.1, max/min are vector too on a z14 - or later: `VFMAXDB`/`VFMINDB` in mode 1, Java's Math.max/min, which gives NaN - for a NaN operand and orders −0 below +0, NumPy's rule. They come with the - vector-enhancements facility 1 (AT_HWCAP VXRS_EXT), beyond the z13 that Go - also supports, where the scalar reducers remain. On the z17, one core, - `benchstat` over 8 interleaved runs, all at p = 0.000: `Max` takes 92% to 96% - less time (1 Ki: 2.58 µs → 112 ns; 4 Mi: 10.7 ms → 0.81 ms), and `MaxAxis(1)` - 94% less. `Sum`, untouched, measured within ±0.6%: the control. A sabotaged copy in - mode 0 (IEEE maxNum, which drops a NaN) failed `TestMaxMinSIMD` on the z17. - The operand order of - `VFSDB`/`VFDDB`/`VFMADB` was **measured**, not read: each run once on known - inputs (`VFSDB V1, V2, V3` is V3 = V2 − V1). AT_HWCAP is read in the - machine's byte order, which on big-endian s390x is not the little-endian the - parser first assumed. - - v0.8.0 shipped these kernels validated under emulation only. Since then they - run on **real hardware**: an IBM z17 (Type 9175), through the LinuxONE - Community Cloud, as a z/VM 7.3.0 guest with 8 vCPUs in a shared LPAR. There - the whole suite passes, as do the fence tests (a kernel reading one element - too far faults), `TestParallelStress` (27,752 calls) and two sabotaged GEMM - and elementwise kernels, which fail as they should. Measured with `hostload` - (0% busy, 0% steal) and `benchstat`, 8 interleaved runs each, every - difference at p = 0.000 unless marked: - - | IBM z17, time vs the scalar code | 1 core | 8 cores | - |---|---|---| - | `Sum` | −85% to −87% | −82% to −87% | - | `AddInto` / `MulInto` | −44% to −61% | −36% to −60% | - | `DivInto` | −6% to −18% | −6% to −22% | - | `SqrtInto` | −2% to −11% | −5% to −9% at 1–16 Ki, ~ above | - | `MatVec` 1024² | −44% | −40% | - | `Dot` 2^20 | −11% | ~ (p = 0.08) | - | `MatMul` 1024² (v0.9.0 GEMM) | 340 → 70 ms (−79%) | 43 → 10 ms (−76%) | - | GEMM geomean, 11 shapes | −74% | −66% | - - `Exp`, which none of this touches, measured within ±0.3% on one core: the - control. -- The 32-bit targets keep - the validated scalar oracles, using the same four-accumulator max/min, direct - sqrt loop, and a **scalar 4×4 GEMM micro-kernel** over the packed panels, and - still get the **packing + cache blocking + multicore** structure (they have - not been measured against NumPy). What the Go assembler offers them, checked - on Go 1.26.4 and 1.27.1 by assembling and disassembling (2026-10-04): - **ppc64le** has no vector-double arithmetic (no `XVADDDP`/`XVMADDADP`), which - go-asmgen v0.13.0 now encodes as `WORD`s, so ppc64le has kernels; - **loong64** has vector-double add/sub/mul/div (`VADDD` assembles to - `vfadd.d`, `VMULD` to `vfmul.d`, `XVADDD` to `xvfadd.d`) and the broadcast - load (`XVMOVQ off(R), X.V4` = `xvldrepl.d`), but no vector FMA, which - go-asmgen v0.14.0 encodes (transitionally), so loong64 has kernels; - **s390x** has them, FMA included (`VFADB`, `VFMADB`); **riscv64** has them - (`VFADDVV`, `VFMACCVV`) and has had kernels since v0.7.0, s390x since v0.8.0. - (s390x additionally exercises the big-endian path in CI.) - -All six are exercised in CI (native amd64/arm64 + qemu for the rest); each -per-arch job regenerates the committed `.s`, fails if it is stale, vets -(asmdecl), builds (cmd/asm encodes), and runs the bit/NaN-correctness suite. The -multicore path and the packed/cache-blocked GEMM driver are -architecture-independent; only the GEMM micro-kernel is per-arch (NEON 4×8 on -arm64, AVX2/FMA 6×8 or SSE2 on amd64, scalar 4×4 on the other four), each held to -the scalar ikj oracle in CI: exactly on integer-valued data, within tolerance on -general data, since above KC the block partials regroup the sum and the FMA -kernels round once per step where the scalar oracle may round twice. +# Performance: go-ndarray against NumPy + +The table is [perf-table.md](perf-table.md), generated by +[refbench](https://github.com/go-scikit/refbench) (go-scikit/refbench v0.1.2) +from [perf.json](perf.json); nothing in it is edited by hand. The rules are +those of the go-scikit performance campaign +([go-scikit/roadmap#27](https://github.com/go-scikit/roadmap/issues/27)): + +- **One core on both sides.** `GOMAXPROCS=1` for Go, and the reference worker + runs with `OMP_NUM_THREADS`, `OPENBLAS_NUM_THREADS`, `MKL_NUM_THREADS`, + `VECLIB_MAXIMUM_THREADS` at 1. `GOMAXPROCS=1` must also be in the + environment of the test binary: `ndarray/fft` rests on go-fft/fft, which reads + it once at init + ([go-fft/fft#107](https://github.com/go-fft/fft/issues/107)). +- **The pinned reference**: NumPy 2.5.4 (`bench/refs.txt`, hashes included), + Python 3.12.12 through `uv`. The earlier page of this repository compared + multicore Go with single-threaded NumPy 2.2.4 and is kept as + [perf-history.md](perf-history.md) for its kernel-level analysis only. +- **Interleaved rounds, one statistic**: a round is one sample per side, + alternating which runs first; the verdict is on the median with quartiles. +- **Load gate**: no timing verdict at a 1-minute load of 8 or more + (`-perf.run` refuses and writes nothing). +- **Same data**: every array argument is `lo + (hi-lo) * frac(i * 0.618...)`, + built in Go and in NumPy; `TestDataParity` compares the SHA-256 of both sides' + bytes (24 distinct argument sets, complex ones included, all equal), and + `TestRefSmoke` runs every NumPy call once. +- **One case per public function** (a function with several shapes of use has + several rows, `Array.SumAxis/axis0` and `/axis1`), or a named exemption with + its reason in [perf-exempt.md](perf-exempt.md). CI fails on a public function + with neither. + +## What a row times + +Each row calls the Go function once on a vector of 100 000 elements (a 1000 x +100 matrix for the axis reductions, 128 x 128 for the matrix products, 64 x 64 +for `linalg`, 65 536 points or 256 x 256 or 32 x 32 x 32 for `fft`), and the +NumPy call on the same data. The result is allocated by the call on both sides; +the `*Into` rows write into a preallocated array on both sides (`out=`). The +`random` rows draw 100 000 values from a generator built once outside the timed +loop (`default_rng(1)` / `DefaultRNG(1)`, `RandomState(1)`, `np.random.seed(1)`), +so the constructor is not in the interval. Views (`Reshape`, `Slice`, +`Transpose`, `Ravel`) are views in NumPy; where Go copies, the row says so. + +`fft` rows include the plan on both sides: `ndarray/fft` builds or fetches a +plan per call, `numpy.fft` keeps its own plan cache warm after calibration. The +scipy.fft comparison of the transforms themselves is +[go-fft/fft's](https://github.com/go-fft/fft/blob/main/docs/perf.md). + +Value parity is not checked here: each package's own tests compare its results +with NumPy's fixtures (`random/numpy_test.go`, `linalg/testdata`, +`fft/testdata`). + +## Running it + +```sh +cd bench +GOMAXPROCS=1 go test -run TestPerf -cpu 1 . # CI mode: allocations, table, public-function inventory +GOMAXPROCS=1 go test -run TestPerf -cpu 1 -perf.run . # measure; writes docs/perf.json and docs/perf-table.md +GOMAXPROCS=1 go test -run TestPerf -perf.record . # allocation counts only (status: deferred) +go test -run 'TestDataParity|TestRefSmoke' -perf.parity . # needs uv +``` + +## Status + +The table at this commit is **deferred**: the host was at a 1-minute load of 8 +to 80 all day, so only the allocation counts are recorded (exact under any +load). There is no timing verdict, and no row is claimed faster or slower. The +first `-perf.run` at a load below 8 replaces it, and `refbench issues` drafts +one issue per slower row. + +Defects visible from the allocation counts alone are filed: +[#103](https://github.com/go-ndarray/ndarray/issues/103) (`Choice`: 200 013 +allocations for 100 000 draws) and +[#104](https://github.com/go-ndarray/ndarray/issues/104) (`linalg` +decompositions: 90 to 276 allocations per 64 x 64 call).