Skip to content

experiments/aot/arrays: the sum-arr ladder for typed-array lowering - #52

Merged
mparrett merged 4 commits into
mainfrom
aot-arrays-bench
Oct 18, 2026
Merged

mparrett merged 4 commits into
mainfrom
aot-arrays-bench

Conversation

@mparrett

@mparrett mparrett commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Bench and generated-Go evidence for nooga/let-go#1055, the array half of nooga/let-go#358. sum_arr.lg is the kernel and sum_arr_doubles.lg the same kernel with ^doubles hints; arrays_test.go holds the three Go shapes over the runtime's real *vm.TypedArray: Get direct but boxed, slice hoisted once, plain []float64. The README carries VM and lg compile rows measured on let-go main at 9981aa04, after nooga/let-go#1057, #1058 and #1060 merged: the hinted kernel runs at hand-written Go speed.

The module pins let-go v1.13.0 so the Go rows build without a checkout, and joins the go-modules CI matrix. v1.13.0 predates #1057, so the .lg rows need an lg built from main until the next release.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mparrett added a commit to nooga/let-go that referenced this pull request Oct 17, 2026
)

Closes #1055, the array half of #358.

A numeric kernel written the Clojure way, `double-array` plus `^doubles` plus `aget`/`aset`, lowered to Go that boxed the index on every access, got the element back boxed, and so ran its whole accumulator chain on `rt.AddValue`. The runtime already held the element type (`vm.TypedArray` is a kind plus a native slice); nothing in the lattice or the lowerer could see it. This gives the lattice an array kind and the lowerer a use for it.

## What changes

- **Lattice:** `[:array K]` for K in `:byte :int :float :object`, mirroring `vm.ArrayKind`. Non-maskable, joins like `:dtype`. The kind is declared, never inferred from elements: `typeinfer` types the six array constructors (`long-array` and `float-array` alias the 64-bit kinds they construct), and `aget` as the element type, `alength` as `:int`, `aset` as its value, `aclone` as its argument.
- **Hints:** `^doubles`, `^floats`, `^longs`, `^ints`, `^bytes`, `^objects` on params, next to `^double`/`^long` in `build.lg`. The "unsupported hint" warning names them.
- **Go type:** every array kind lowers to `*vm.TypedArray`. It implements `vm.Value`, so a local boxes by interface conversion and the same backing store is shared with any un-lowered code that holds the array.
- **Accessors on `vm.TypedArray`:** `AtFloat64`/`SetFloat64`, `AtInt64`/ `SetInt64`, `AtByte`/`SetByte`, `AtValue`/`SetValue`, each one unsigned bounds compare and the access, with the kind check folded in (a wrong kind finds a nil field) and all error construction in a cold path, so each inlines (cost under 80). To get there the struct keeps one typed slice per kind instead of `data any`; `Unbox()` still returns the active slice.
- **Lowering:** `aget`/`aset`/`alength` on a proven array kind with a native int index emit the accessor, `if rt.NativePrimsIntact() { typed } else { trampoline }` like the other native primitives. An int value into a double-array widens, as `TypedArray.Set` coerces. Anything that does not line up (unknown kind, boxed index, nested `aget`) falls through to today's path.
- **Calls into a `*vm.TypedArray` param:** the Go spec does not carry the kind, so direct-call registry entries now carry `:param-types` (lattice) beside `:param-specs`. A proven array of the same kind passes through; a different kind, or a proven non-array, bails the call to the trampoline, which is what bytecode does (the hint is ignored there and `aget` works on any array); an unproven `vm.Value` gets the scalar guard's shape with `&& tg.Kind() == vm.ArrayFloat` in the condition.
- **`rt.NativePrimsIntact`** now inlines (its `LG_GUARD_DEBUG` path moved to a cold function). It runs once per guarded site, which in these loops is once per element, so every guarded native-primitive call now pays one atomic load instead of a call.

## Measured

Indicative only: three runs each on one machine, not ratchet results. `sum-arr` over a 1M `double-array`, 50 reps, Apple M2, Go 1.27.1, load under 7:

| | ms/rep |
|---|---|
| Bytecode VM | 232-263 |
| AOT before (`rt.CoreAgetf`, `rt.AddValue`) | 27-31 |
| AOT after | 0.95-1.03 |
| Hand-written Go over `[]float64` | 0.93-0.99 |

The lowered loop is `arg__1, callErr = arg0.AtFloat64(i)`, `s = s + arg__1`, on `arg0 *vm.TypedArray` and `s float64`. The kernel, the three Go shapes it brackets and the generated Go before/after are in mparrett/let-go-lab#52.

## Verified

- New tests: `pkg/ir/lisp_lower_go_array_test.go` (hinted `aget` lowers to `AtFloat64` with no `AddValue`; `aset` widens an int value; the constructor types a local; `^longs` uses the int64 accessors; a `vm.Value` arg into an array param is guarded with the kind check; a long-array or a string into a `^doubles` sibling bails to the trampoline; an unhinted param keeps today's path) and `pkg/vm/typed_array_lowered_test.go` (accessors share the backing store with `Get`, bounds and kind errors are errors, not panics).
- An end-to-end program with out-of-bounds, negative, wrong-kind and non-array calls under `try` prints the same lines compiled as interpreted.
- `make generate`, `make check-generated`, `go test` over `pkg/vm`, `pkg/ir`, `pkg/rt/...`, `pkg/genmanifest`, `pkg/compiler/...`; gofmt; linux, js/wasm, plan9 and wasip1 builds; the scoped comment lint over the touched files reports only pre-existing lines.

## Wiki

`concepts/typed-arrays.md` describes the pre-PR state (the `data any` struct, "what the lowering does with an array: nothing", "why it stops there") and `concepts/go-backend.md` lists the proven types as scalars only. Both become stale on merge; I will send the wiki update once this lands.

## Not in this PR

- Hoisting the accessor's slice out of a loop. Once the accessors inline, the per-access and hoisted forms time the same, so there is nothing to hoist.
- A `vm.Value` index. The intrinsic needs a native int; a boxed index keeps the boxed path.
- Vectors, transients and strings stay on #358.
- Found on the way, independent of arrays: a `loop` whose body is `(when test (side-effecting-call) (recur …))` fails in legalize ("expected int64, got Keyword") on `main` too; the `if`/`do` spelling lowers. Filed separately.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
mparrett and others added 2 commits October 18, 2026 08:09
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@mparrett
mparrett merged commit 6f64b4a into main Oct 18, 2026
7 checks passed
@mparrett
mparrett deleted the aot-arrays-bench branch October 18, 2026 20:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant