From fc528b3e401390828a4728e46ce27c356f9a897f Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Sat, 10 Oct 2026 21:06:13 -0700 Subject: [PATCH 1/4] experiments/aot/arrays: the sum-arr ladder for typed-array lowering Measures what aget/aset over a double-array costs when lowered to Go today (rt.CoreAgetf per element, boxed index and result) against the three Go shapes a kind-aware lowering brackets: TypedArray.Get direct, the slice hoisted once, and plain []float64. Adds the module to the go-modules CI matrix. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 +- experiments/aot/arrays/README.md | 42 ++++++++++ experiments/aot/arrays/arrays_test.go | 113 ++++++++++++++++++++++++++ experiments/aot/arrays/go.mod | 7 ++ experiments/aot/arrays/go.sum | 6 ++ experiments/aot/arrays/sum_arr.lg | 23 ++++++ 6 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 experiments/aot/arrays/README.md create mode 100644 experiments/aot/arrays/arrays_test.go create mode 100644 experiments/aot/arrays/go.mod create mode 100644 experiments/aot/arrays/go.sum create mode 100644 experiments/aot/arrays/sum_arr.lg diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c6d3a3..46c6564 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,7 +106,7 @@ jobs: strategy: fail-fast: false matrix: - module: [experiments/cueplay, experiments/aot/native] + module: [experiments/cueplay, experiments/aot/native, experiments/aot/arrays] defaults: run: working-directory: ${{ matrix.module }} diff --git a/experiments/aot/arrays/README.md b/experiments/aot/arrays/README.md new file mode 100644 index 0000000..aa695a6 --- /dev/null +++ b/experiments/aot/arrays/README.md @@ -0,0 +1,42 @@ +# Typed arrays under AOT lowering: the sum-arr ladder + +The array half of [nooga/let-go#358](https://github.com/nooga/let-go/issues/358): +what `aget`/`aset` over a `double-array` costs today when lowered to Go, and +what a kind-aware lowering would recover. `sum_arr.lg` is the kernel; +`arrays_test.go` holds the three Go shapes it brackets. + +## Measured 2026-10-10 + +let-go `main` at `ff1e6dac`, Go 1.27.1, Apple M2, 1M `double-array`, 50 reps, +three runs. Ratios are the point; absolutes are this machine. + +| Variant | ms/rep | vs AOT today | +|---|---|---| +| Bytecode VM (`lg -e "(require 'sum-arr) (sum-arr/-main)"`) | 232-263 | | +| AOT today (`lg compile`: `rt.CoreAgetf`, `rt.AddValue`) | 66-67 | 1x | +| Go, `arr.Get(i)` direct, still boxed (`BenchmarkGetBoxed`) | 18-23 | ~3x | +| Go, kind asserted once, slice hoisted (`BenchmarkHoisted`) | 1.6-2.3 | ~30-40x | +| Hand-written Go over `[]float64` (`BenchmarkPlain`) | 1.4-1.8 | ~40x | + +Reproduce the Go rows with `go test -bench=. -benchtime=2s` here; the module +pins the let-go release in `go.mod` and needs no checkout. The first two rows +need an `lg` built from the commit above: run the `.lg` as shown, and +`lg compile -work sum_arr.lg` keeps the generated Go in ``. + +## What the lowered Go looks like today + +```go +func SumArr(ec *vm.ExecContext, arg0 vm.Value, arg1 int64) (vm.Value, error) { + var s vm.Value + ... + arg__1, callErr = rt.CoreAgetf(arg0, vm.Int(i)) + ... + s = rt.AddValue(s, arg__1) +``` + +The array is `vm.Value`, the index boxes per access, the element comes back +boxed, and so the accumulator and its add fall back to runtime calls. A +`^doubles a` hint is rejected with a warning. The hoisted shape in +`arrays_test.go` is what the lowering could emit instead, over the same +`*vm.TypedArray`: its kind check and slice assertion are loop-invariant, and +`TypedArray.Get` re-runs both per element. diff --git a/experiments/aot/arrays/arrays_test.go b/experiments/aot/arrays/arrays_test.go new file mode 100644 index 0000000..16ac483 --- /dev/null +++ b/experiments/aot/arrays/arrays_test.go @@ -0,0 +1,113 @@ +// Three Go shapes of the sum-arr kernel in ../arrays/sum_arr.lg, over the +// runtime's real *vm.TypedArray. They bracket what an array-aware AOT +// lowering would emit (nooga/let-go#358, array half): +// +// sumGetBoxed what reaching TypedArray.Get directly buys: no var lookup, +// no trampoline, but the kind switch, slice assert and boxing +// still run per element. +// sumHoisted the kind asserted once, the slice hoisted once, a[i] in the +// loop; the param is still a vm.Value, so this is the shape a +// guarded lowering can emit. +// sumPlain hand-written Go over []float64, the floor. +package arrays + +import ( + "fmt" + "testing" + + "github.com/nooga/let-go/pkg/vm" +) + +const n = 1_000_000 + +func sumPlain(a []float64, n int64) float64 { + s := 0.0 + for i := int64(0); i < n; i++ { + s += a[i] + } + return s +} + +func sumHoisted(arg0 vm.Value, arg1 int64) (vm.Value, error) { + arr, ok := arg0.(*vm.TypedArray) + if !ok || arr.Kind() != vm.ArrayFloat { + return nil, fmt.Errorf("not a double-array: %v", arg0) + } + a := arr.Unbox().([]float64) + s := 0.0 + for i := int64(0); i < arg1; i++ { + s += a[i] + } + return vm.Float(s), nil +} + +func sumGetBoxed(arg0 vm.Value, arg1 int64) (vm.Value, error) { + arr, ok := arg0.(*vm.TypedArray) + if !ok { + return nil, fmt.Errorf("not an array: %v", arg0) + } + var s vm.Value = vm.Float(0) + for i := int64(0); i < arg1; i++ { + s = vm.Float(float64(s.(vm.Float)) + float64(arr.Get(int(i)).(vm.Float))) + } + return s, nil +} + +func filled() *vm.TypedArray { + arr := vm.NewFloatArray(n) + for i := 0; i < n; i++ { + if err := arr.Set(i, vm.Float(0.5*float64(i))); err != nil { + panic(err) + } + } + return arr +} + +// One rep; the .lg kernel prints 50 reps summed, 12499987500000.0. +const want = 249999750000.0 + +func TestShapesAgree(t *testing.T) { + arr := filled() + for name, f := range map[string]func(vm.Value, int64) (vm.Value, error){ + "hoisted": sumHoisted, "getBoxed": sumGetBoxed, + } { + v, err := f(arr, n) + if err != nil { + t.Fatal(name, err) + } + if got := float64(v.(vm.Float)); got != want { + t.Errorf("%s = %v, want %v", name, got, want) + } + } + if got := sumPlain(arr.Unbox().([]float64), n); got != want { + t.Errorf("plain = %v, want %v", got, want) + } + if _, err := sumHoisted(vm.Int(3), 1); err == nil { + t.Error("hoisted accepted a non-array; the guard must refuse so the lowering can trampoline") + } +} + +func BenchmarkPlain(b *testing.B) { + a := filled().Unbox().([]float64) + for b.Loop() { + sumPlain(a, n) + } +} + +func BenchmarkHoisted(b *testing.B) { + arr := filled() + for b.Loop() { + if _, err := sumHoisted(arr, n); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkGetBoxed(b *testing.B) { + arr := filled() + for b.Loop() { + if _, err := sumGetBoxed(arr, n); err != nil { + b.Fatal(err) + } + } +} diff --git a/experiments/aot/arrays/go.mod b/experiments/aot/arrays/go.mod new file mode 100644 index 0000000..2b6d2bd --- /dev/null +++ b/experiments/aot/arrays/go.mod @@ -0,0 +1,7 @@ +// Standalone module: the three Go shapes of sum-arr over let-go's TypedArray. +// go test -bench=. -benchtime=2s +module aot-arrays + +go 1.27 + +require github.com/nooga/let-go v1.13.0 diff --git a/experiments/aot/arrays/go.sum b/experiments/aot/arrays/go.sum new file mode 100644 index 0000000..2af3b76 --- /dev/null +++ b/experiments/aot/arrays/go.sum @@ -0,0 +1,6 @@ +github.com/nooga/let-go v1.13.0 h1:ZZWiwv87ZMQ4xYuHVkzRKL96SHXo0CnRRi4fryQOA8E= +github.com/nooga/let-go v1.13.0/go.mod h1:dfqIT0HFb4N/98IYsedXG+1nYZ6po7MNumkLqLcYGLM= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= diff --git a/experiments/aot/arrays/sum_arr.lg b/experiments/aot/arrays/sum_arr.lg new file mode 100644 index 0000000..0a6b44e --- /dev/null +++ b/experiments/aot/arrays/sum_arr.lg @@ -0,0 +1,23 @@ +(ns sum-arr) + +(defn fill [a n] + (loop [i 0] + (when (< i n) + (aset a i (* 0.5 i)) + (recur (inc i))))) + +(defn sum-arr [a n] + (loop [i 0 s 0.0] + (if (>= i n) s (recur (inc i) (+ s (aget a i)))))) + +(defn -main [& args] + (let [n 1000000 + reps 50 + a (double-array n)] + (fill a n) + (let [t0 (System/nanoTime) + r (loop [k 0 acc 0.0] + (if (>= k reps) acc (recur (inc k) (+ acc (sum-arr a n))))) + t1 (System/nanoTime)] + (println "result" r) + (println "ms/rep" (/ (/ (- t1 t0) 1e6) reps))))) From 1e7886817b06784a4e3b9c935c246694457a60f9 Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Sat, 10 Oct 2026 21:59:20 -0700 Subject: [PATCH 2/4] experiments/aot/arrays: the after row, from nooga/let-go#1057 Co-Authored-By: Claude Fable 5.1 --- experiments/aot/arrays/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/experiments/aot/arrays/README.md b/experiments/aot/arrays/README.md index aa695a6..fe8be76 100644 --- a/experiments/aot/arrays/README.md +++ b/experiments/aot/arrays/README.md @@ -17,6 +17,12 @@ three runs. Ratios are the point; absolutes are this machine. | Go, `arr.Get(i)` direct, still boxed (`BenchmarkGetBoxed`) | 18-23 | ~3x | | Go, kind asserted once, slice hoisted (`BenchmarkHoisted`) | 1.6-2.3 | ~30-40x | | Hand-written Go over `[]float64` (`BenchmarkPlain`) | 1.4-1.8 | ~40x | +| AOT with [nooga/let-go#1057](https://github.com/nooga/let-go/pull/1057) (`^doubles` hint) | 0.95-1.03 | ~30x | + +The last row is the same kernel with `^doubles a` on both fns, compiled by the +PR's branch (load under 7, same session as the hand-Go row's low-load run of +0.93-0.99). Its loop is `arg__1, callErr = arg0.AtFloat64(i)` and +`s = s + arg__1` on `arg0 *vm.TypedArray`, `s float64`. Reproduce the Go rows with `go test -bench=. -benchtime=2s` here; the module pins the let-go release in `go.mod` and needs no checkout. The first two rows From f7492331b71c03ac9a5ccfabcfb81b3a7a1cbce2 Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Sun, 18 Oct 2026 08:09:53 -0700 Subject: [PATCH 3/4] experiments/aot/arrays: re-measure after nooga/let-go#1057 merged The README described the lowering as a proposal and timed the hinted row on the PR branch. Re-timed on main at 9981aa04 with both kernels: the hinted one is now a checked-in file, sum_arr_doubles.lg, and the lowered-Go section shows both outputs. The VM row picks up OP_AGET from #1058. Co-Authored-By: Claude Opus 5.5 --- experiments/aot/arrays/README.md | 90 +++++++++++++++-------- experiments/aot/arrays/sum_arr_doubles.lg | 23 ++++++ 2 files changed, 84 insertions(+), 29 deletions(-) create mode 100644 experiments/aot/arrays/sum_arr_doubles.lg diff --git a/experiments/aot/arrays/README.md b/experiments/aot/arrays/README.md index fe8be76..7e95ea9 100644 --- a/experiments/aot/arrays/README.md +++ b/experiments/aot/arrays/README.md @@ -1,35 +1,51 @@ # Typed arrays under AOT lowering: the sum-arr ladder -The array half of [nooga/let-go#358](https://github.com/nooga/let-go/issues/358): -what `aget`/`aset` over a `double-array` costs today when lowered to Go, and -what a kind-aware lowering would recover. `sum_arr.lg` is the kernel; -`arrays_test.go` holds the three Go shapes it brackets. +[nooga/let-go#1055](https://github.com/nooga/let-go/issues/1055), the array +half of [nooga/let-go#358](https://github.com/nooga/let-go/issues/358): what +`aget`/`aset` over a `double-array` costs when lowered to Go, with and without +a kind hint. `sum_arr.lg` is the kernel; `sum_arr_doubles.lg` is the same +kernel with `^doubles a` on both fns. `arrays_test.go` holds the three Go +shapes they bracket. -## Measured 2026-10-10 +The kind-aware lowering landed in +[nooga/let-go#1057](https://github.com/nooga/let-go/pull/1057), with +`OP_AGET`/`OP_ASET` for the VM in +[nooga/let-go#1058](https://github.com/nooga/let-go/pull/1058) and qualified +core heads in [nooga/let-go#1060](https://github.com/nooga/let-go/pull/1060), +all merged 2026-10-17. -let-go `main` at `ff1e6dac`, Go 1.27.1, Apple M2, 1M `double-array`, 50 reps, -three runs. Ratios are the point; absolutes are this machine. +## Measured 2026-10-18 -| Variant | ms/rep | vs AOT today | +let-go `main` at `9981aa04`, Go 1.27.1, Apple M2, 1M `double-array`, 50 reps. +Two passes of four interleaved rounds, the first round of each dropped as +cold. Load was 6-9, so absolutes run about 20% above a quiet machine (the +hand-Go row was 1.4-1.8 on 2026-10-10); the ratios held across both passes. +These are indicative runs, not ratchet results. + +| Variant | ms/rep | vs unhinted AOT | |---|---|---| -| Bytecode VM (`lg -e "(require 'sum-arr) (sum-arr/-main)"`) | 232-263 | | -| AOT today (`lg compile`: `rt.CoreAgetf`, `rt.AddValue`) | 66-67 | 1x | -| Go, `arr.Get(i)` direct, still boxed (`BenchmarkGetBoxed`) | 18-23 | ~3x | -| Go, kind asserted once, slice hoisted (`BenchmarkHoisted`) | 1.6-2.3 | ~30-40x | -| Hand-written Go over `[]float64` (`BenchmarkPlain`) | 1.4-1.8 | ~40x | -| AOT with [nooga/let-go#1057](https://github.com/nooga/let-go/pull/1057) (`^doubles` hint) | 0.95-1.03 | ~30x | - -The last row is the same kernel with `^doubles a` on both fns, compiled by the -PR's branch (load under 7, same session as the hand-Go row's low-load run of -0.93-0.99). Its loop is `arg__1, callErr = arg0.AtFloat64(i)` and -`s = s + arg__1` on `arg0 *vm.TypedArray`, `s float64`. +| Bytecode VM (`lg -e "(require 'sum-arr) (sum-arr/-main)"`) | 125-187 | | +| AOT, no hint (`rt.CoreAgetf`, `rt.AddValue`) | 50-76 | 1x | +| Go, `arr.Get(i)` direct, still boxed (`BenchmarkGetBoxed`) | 19-22 | ~3x | +| Go, kind asserted once, slice hoisted (`BenchmarkHoisted`) | 2.1-2.4 | ~30x | +| Hand-written Go over `[]float64` (`BenchmarkPlain`) | 1.9-2.2 | ~30x | +| AOT, `^doubles` (`sum_arr_doubles.lg`) | 1.8-2.7 | ~25-30x | + +The hinted kernel lands on the hand-written Go row. The VM row was 232-263 on +2026-10-10 at `ff1e6dac`, before `OP_AGET`; the hint does not change it. Reproduce the Go rows with `go test -bench=. -benchtime=2s` here; the module -pins the let-go release in `go.mod` and needs no checkout. The first two rows -need an `lg` built from the commit above: run the `.lg` as shown, and -`lg compile -work sum_arr.lg` keeps the generated Go in ``. +pins the let-go release in `go.mod` and needs no checkout. v1.13.0 predates +#1057, so the VM and AOT rows need an `lg` built from `main` at or after +`ff9f31a9`: run either `.lg` as shown (`sum-arr-doubles` for the hinted one), +and `lg compile -work sum_arr_doubles.lg` keeps the generated Go in +``. -## What the lowered Go looks like today +## What the lowered Go looks like + +Without a hint, the array is `vm.Value`, the index boxes per access, the +element comes back boxed, and the accumulator and its add fall back to +runtime calls: ```go func SumArr(ec *vm.ExecContext, arg0 vm.Value, arg1 int64) (vm.Value, error) { @@ -40,9 +56,25 @@ func SumArr(ec *vm.ExecContext, arg0 vm.Value, arg1 int64) (vm.Value, error) { s = rt.AddValue(s, arg__1) ``` -The array is `vm.Value`, the index boxes per access, the element comes back -boxed, and so the accumulator and its add fall back to runtime calls. A -`^doubles a` hint is rejected with a warning. The hoisted shape in -`arrays_test.go` is what the lowering could emit instead, over the same -`*vm.TypedArray`: its kind check and slice assertion are loop-invariant, and -`TypedArray.Get` re-runs both per element. +With `^doubles a`, the parameter is `*vm.TypedArray`, the element and the +accumulator are `float64`, and the access is an inlinable accessor: + +```go +func SumArr(ec *vm.ExecContext, arg0 *vm.TypedArray, arg1 int64) (float64, error) { + var s float64 + ... + if rt.NativePrimsIntact() { + arg__1, callErr = arg0.AtFloat64(i) + ... + } else { + tb16, callErr = ec.Invoke2(rt.CachedVarFnEC(ec, &__v_clojure_core_aget, "clojure.core", "aget"), arg0, vm.Int(i)) + ... + } + i = i + 1 + s = s + arg__1 +``` + +The `else` arm is the trampoline: if `aget` has been redefined, the lowered +code calls the var and checks the result's kind, so lowered and bytecode agree +on every input. `AtFloat64` returns an error on a wrong-kind array or a bad +index rather than panicking. diff --git a/experiments/aot/arrays/sum_arr_doubles.lg b/experiments/aot/arrays/sum_arr_doubles.lg new file mode 100644 index 0000000..7962c5d --- /dev/null +++ b/experiments/aot/arrays/sum_arr_doubles.lg @@ -0,0 +1,23 @@ +(ns sum-arr-doubles) + +(defn fill [^doubles a n] + (loop [i 0] + (when (< i n) + (aset a i (* 0.5 i)) + (recur (inc i))))) + +(defn sum-arr [^doubles a n] + (loop [i 0 s 0.0] + (if (>= i n) s (recur (inc i) (+ s (aget a i)))))) + +(defn -main [& args] + (let [n 1000000 + reps 50 + a (double-array n)] + (fill a n) + (let [t0 (System/nanoTime) + r (loop [k 0 acc 0.0] + (if (>= k reps) acc (recur (inc k) (+ acc (sum-arr a n))))) + t1 (System/nanoTime)] + (println "result" r) + (println "ms/rep" (/ (/ (- t1 t0) 1e6) reps))))) From 53b8fa5c1cd5d7cba32d40badbed66d76a5860a6 Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Sun, 18 Oct 2026 11:09:54 -0700 Subject: [PATCH 4/4] experiments/aot/arrays: retime with no contending jobs The f749233 rows were taken while another session's jobs held the CPU and read about 2x slow. Retimed on the same lg at load 5-6 with nothing else timing; ratios unchanged, absolutes back in line with the 2026-10-10 run. Co-Authored-By: Claude Opus 5.5 --- experiments/aot/arrays/README.md | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/experiments/aot/arrays/README.md b/experiments/aot/arrays/README.md index 7e95ea9..4ccf334 100644 --- a/experiments/aot/arrays/README.md +++ b/experiments/aot/arrays/README.md @@ -16,20 +16,19 @@ all merged 2026-10-17. ## Measured 2026-10-18 -let-go `main` at `9981aa04`, Go 1.27.1, Apple M2, 1M `double-array`, 50 reps. -Two passes of four interleaved rounds, the first round of each dropped as -cold. Load was 6-9, so absolutes run about 20% above a quiet machine (the -hand-Go row was 1.4-1.8 on 2026-10-10); the ratios held across both passes. -These are indicative runs, not ratchet results. +let-go `main` at `9981aa04`, Go 1.27.1, Apple M2, 1M `double-array`, 50 reps, +five or six runs per row at load 5-6, no other timing jobs running. Ratios are +the point; absolutes are this machine. These are indicative runs, not ratchet +results. One `BenchmarkPlain` run at 1.28 was dropped as noise. | Variant | ms/rep | vs unhinted AOT | |---|---|---| -| Bytecode VM (`lg -e "(require 'sum-arr) (sum-arr/-main)"`) | 125-187 | | -| AOT, no hint (`rt.CoreAgetf`, `rt.AddValue`) | 50-76 | 1x | -| Go, `arr.Get(i)` direct, still boxed (`BenchmarkGetBoxed`) | 19-22 | ~3x | -| Go, kind asserted once, slice hoisted (`BenchmarkHoisted`) | 2.1-2.4 | ~30x | -| Hand-written Go over `[]float64` (`BenchmarkPlain`) | 1.9-2.2 | ~30x | -| AOT, `^doubles` (`sum_arr_doubles.lg`) | 1.8-2.7 | ~25-30x | +| Bytecode VM (`lg -e "(require 'sum-arr) (sum-arr/-main)"`) | 67-83 | | +| AOT, no hint (`rt.CoreAgetf`, `rt.AddValue`) | 28-30 | 1x | +| Go, `arr.Get(i)` direct, still boxed (`BenchmarkGetBoxed`) | 7.4-8.4 | ~3.5x | +| Go, kind asserted once, slice hoisted (`BenchmarkHoisted`) | 0.98-1.0 | ~29x | +| Hand-written Go over `[]float64` (`BenchmarkPlain`) | 0.92-0.94 | ~30x | +| AOT, `^doubles` (`sum_arr_doubles.lg`) | 0.96-1.01 | ~29x | The hinted kernel lands on the hand-written Go row. The VM row was 232-263 on 2026-10-10 at `ff1e6dac`, before `OP_AGET`; the hint does not change it.