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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
79 changes: 79 additions & 0 deletions experiments/aot/arrays/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Typed arrays under AOT lowering: the sum-arr ladder

[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.

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.

## Measured 2026-10-18

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)"`) | 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.

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. 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 <dir> sum_arr_doubles.lg` keeps the generated Go in
`<dir>`.

## 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) {
var s vm.Value
...
arg__1, callErr = rt.CoreAgetf(arg0, vm.Int(i))
...
s = rt.AddValue(s, arg__1)
```

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.
113 changes: 113 additions & 0 deletions experiments/aot/arrays/arrays_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
7 changes: 7 additions & 0 deletions experiments/aot/arrays/go.mod
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions experiments/aot/arrays/go.sum
Original file line number Diff line number Diff line change
@@ -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=
23 changes: 23 additions & 0 deletions experiments/aot/arrays/sum_arr.lg
Original file line number Diff line number Diff line change
@@ -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)))))
23 changes: 23 additions & 0 deletions experiments/aot/arrays/sum_arr_doubles.lg
Original file line number Diff line number Diff line change
@@ -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)))))
Loading