From 3a2e29490de5092e5340fdb7c49b8d2fa7f7e006 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 25 Aug 2026 21:11:11 +0100 Subject: [PATCH 01/16] docs(skills): add optimize-runtime playbook Adopt Wasmlight's benchmark-gated optimization wave process for GocciaScript bytecode work against QuickJS. Co-authored-by: Cursor --- .agents/skills/optimize-runtime/SKILL.md | 208 ++++++++++++++++++ .../optimize-runtime/agents/openai.yaml | 4 + AGENTS.md | 1 + 3 files changed, 213 insertions(+) create mode 100644 .agents/skills/optimize-runtime/SKILL.md create mode 100644 .agents/skills/optimize-runtime/agents/openai.yaml diff --git a/.agents/skills/optimize-runtime/SKILL.md b/.agents/skills/optimize-runtime/SKILL.md new file mode 100644 index 000000000..d5b3d2038 --- /dev/null +++ b/.agents/skills/optimize-runtime/SKILL.md @@ -0,0 +1,208 @@ +--- +name: optimize-runtime +description: >- + Runs benchmark-gated GocciaScript runtime optimization waves from a verified + baseline through profiling, isolated implementation lanes, serialized A/B + measurement, combined re-measurement, and interpreter/bytecode correctness + gates. Use when asked to optimize the bytecode VM, interpreter, startup, + calls, numeric, property access, allocation, or GC; close a QuickJS gap; + run another optimization wave; or retain only changes with a measured + positive impact. +--- + +# Optimize runtime + +Improve execution speed without trading away conformance. Treat every change +as a candidate until repeatable measurement and mode-identical correctness +accept it. + +Adapted from Wasmlight's `optimize-runtime` playbook. GocciaScript has no +JIT/AOT tiers: the two execution modes are the tree-walk interpreter and the +bytecode VM, sharing the same runtime objects and GC. + +## Establish the experiment + +1. Read `.agent/HANDOFF.md` when it exists, `VISION.md`, `CONTEXT.md`, the + hot-path policy in `docs/contributing/code-style.md` and + `docs/core-patterns.md`, `docs/testing.md`, `docs/benchmarks.md`, + `docs/profiling.md`, `docs/bytecode-vm.md`, and the ADRs governing the + affected seam (especially 0005, 0014, 0065, 0076, 0081, 0087, 0088, 0091). +2. Apply `git-workflow`. Require a clean tree, fetch the remote default, and + start from its exact tip on a focused branch. Never benchmark an unexplained + dirty or mixed-revision tree. +3. State the target workload, affected execution mode (bytecode unless the + user names the interpreter), expected invariant, guard workloads, platforms, + and non-goals before editing. +4. Use `--prod` builds for performance measurement. Use development builds as + an additional checked-build correctness gate, never as performance evidence. +5. Make every workload verify its result independently. Do not use the + interpreter as the bytecode VM's only oracle: run both modes against the + JavaScript suite, and require AWFY/probe workloads to check their own + computed result. + +Default wave target unless the user overrides it: close the bytecode-vs-QuickJS +gap until GocciaScript reaches **0.6×–0.8× of QuickJS speed** on the chosen +barometer (throughput or score). On AWFY that is `Goccia time / QuickJS time` +between about 1.25× and 1.67×; on JetStream invert the score ratio the same +way. Record the exact convention used in the wave handoff. Do not treat +beating V8/SpiderMonkey as in-scope (`VISION.md`). + +## Capture the baseline + +1. Build and retain a baseline binary from the exact starting commit with the + same compiler, dependencies, flags, and host used for candidates: + + ```sh + ./build.pas --prod loader benchmarkrunner + cp build/GocciaScriptLoader /tmp/goccia-baseline-$(git rev-parse --short HEAD) + ``` + +2. Stop competing benchmark processes. Serialize measurements through one + shared exclusive lock, conventionally `/tmp/gocciascript-perf-gate.lock`. +3. Discard at least one warm-up. Default to seven measured samples and report + the median plus the sample spread. Use fewer only for an expensive workload + and record why. AWFY/JetStream drivers default to fewer repetitions; raise + `--repetitions` for accept/reject decisions. +4. Measure the target and representative guards. Prefer: + - focused `perf/probes/` diagnostics for the suspected mechanism; + - object/call/numeric AWFY rows (`Richards`, `DeltaBlue`, `Bounce`, + `Storage`, `Mandelbrot`, `NBody`, `Sieve`, `Json`) as transfer guards; + - a JetStream workload when the change is likely to show there; + - `GocciaBenchmarkRunner` files only as supporting signal, never as the + sole merge criterion for a VM change. + Keep iteration counts large enough to escape timer quantization. +5. Record the exact commit, command, OS, architecture, execution mode, + workload size, warm-up count, sample count, order, median, spread, QuickJS + version, and verified result. +6. When comparing QuickJS, run an identical portable bundle and entry point + through `scripts/awfy-driver.js` / `scripts/jetstream-driver.js`, exclude + compilation from both sides where the driver already does so, verify + observable results, and run on the same host. Pin QuickJS to the version in + `.github/scripts/install-quickjs.sh`. Label emulated or virtualized Linux + results explicitly rather than presenting them as native hardware. + +Use interleaved `--goccia-baseline` / `--goccia-candidate` for Goccia-vs-Goccia +A/B. Do not compare sequential batches on a noisy laptop as accept evidence. + +## Find the bottleneck + +1. Profile a long-running version of the target workload. Combine: + - language-level VM profiles (`--profile=opcodes|functions|all`, see + `docs/profiling.md`); + - host samples of the `--prod` binary (`sample` on macOS, `perf` on Linux) + of the interpreter dispatch path. +2. Trace the dominant samples to source and state the suspected cost in + mechanism terms: dispatch, register-file traffic, helper crossings, frame + publication, boxing, property resolution, call/return, allocation, GC, or + another observed cause. +3. Form bounded candidate lanes only after the baseline and profile exist. + Prefer independent lanes with disjoint ownership and one primary hypothesis + each. +4. Preserve architectural invariants in every lane: evaluation stays pure; + `TGocciaScope` is created only through `CreateChild`; bytecode and + interpreter remain observationally identical; tagged `TGocciaRegister` + scalars stay unboxed until a runtime boundary; GC roots stay complete; + capability/sandbox defaults stay closed; ECMAScript semantics for proxies, + accessors, deletion, and prototype mutation stay correct. + +Rejected complexity that must not be revived without new transfer evidence: + +- broader read-side property inline caches (ADR 0088); +- value caches whose only win is allocation reduction on probes (ADR 0081); +- string interning on the universal `RuntimeCopy` path (`docs/core-patterns.md`). + +## Run isolated candidate lanes + +Use a bounded subagent fan-out when independent lanes can run concurrently. +Give every lane an isolated worktree and branch at the same exact baseline. +When subagents are unavailable, run the same lanes sequentially in isolated +worktrees. + +Require every lane to: + +- own a concrete bottleneck and a bounded set of files; +- capture its own serialized baseline before changing code; +- keep register, GC-root, call-frame, sandbox, and mode-parity invariants + explicit; +- measure the target immediately before and after the candidate under the + shared lock; +- run guard workloads and focused correctness tests; +- reject and fully revert experiments that regress, overlap noise, fail result + verification, or weaken an invariant; +- commit only an accepted candidate and return its exact hash, measurements, + guard results, correctness evidence, and rejected experiments; +- avoid editing `.agent/HANDOFF.md`; the integration owner records the wave. + +Do not let multiple lanes benchmark concurrently. Parallelize investigation, +implementation, builds, and correctness tests; serialize performance runs. + +Probe-only wins are diagnostics, not merge criteria. A candidate must transfer +to at least one representative AWFY (or JetStream) guard before integration. + +## Accept or reject a candidate + +1. Run an immediate same-load A/B comparison using retained baseline and + candidate binaries. Confirm in reverse order or an ABBA sequence. +2. Accept only a repeatable positive target delta that exceeds observed noise + and timer resolution. A single favorable sample or a one-millisecond shift + at one-millisecond resolution is not evidence. +3. Reject a target win if a representative guard materially regresses unless + the user explicitly accepts that trade-off after seeing both measurements. +4. Require identical verified results and relevant focused tests before + integration. Never turn benchmark numbers into test assertions. +5. Keep rejected work out of the accepted commit. Record why it lost so a later + wave does not unknowingly repeat it. + +## Re-measure combined integration + +1. Begin from the current accepted integration head, not the original baseline. +2. Merge one accepted lane at a time into a disposable integration branch or + worktree. Never rebase or force-push. +3. Rebuild and compare the combined candidate against the immediately previous + accepted head under the same serialized protocol. +4. Advance the delivery branch only when the combined state remains positive + and its guards remain flat. Leave a lane unintegrated when interaction with + earlier work erases its benefit or creates a regression. +5. After each accepted merge, treat that result as the next baseline. Do not + add isolated percentages to predict the combined outcome. + +## Prove correctness and report + +Run the smallest focused checks first, then the repository gates on the final +combined diff: + +```sh +./format.pas --check +./build.pas testrunner +./build/GocciaTestRunner tests +./build/GocciaTestRunner tests --mode=bytecode +./build.pas --prod loader +``` + +For VM, compiler, register, or GC changes also run the relevant native Pascal +tests as described in `docs/testing.md`. Use a clean build +(`./build.pas --clean `) after a merge or unexplained FPC error. + +Interpreter and bytecode suite results must match on the public JavaScript +tests: same pass/fail set, no new crashes. Do not weaken sandbox defaults or +capability policy to buy speed. + +Update `.agent/HANDOFF.md` with: + +- the exact before/after medians and method; +- accepted commits and their invariants; +- rejected experiments and measured reason; +- guard workloads and correctness gates; +- cross-architecture results and virtualization caveats; +- the remaining QuickJS gap and next profiled bottlenecks. + +Use `create-pr` when delivery is requested. Keep its PR draft until the +Definition of Ready is satisfied and exact-head CI is green, then mark it ready. + +## Stop conditions + +Stop and report rather than integrate when the baseline is unstable, the target +does not verify its result, the candidate's improvement is not repeatable, a +guard regresses materially, interpreter and bytecode diverge, a +cross-architecture gate fails, or the change depends on an unresolved register, +GC-root, call-frame, sandbox, or mode-parity assumption. diff --git a/.agents/skills/optimize-runtime/agents/openai.yaml b/.agents/skills/optimize-runtime/agents/openai.yaml new file mode 100644 index 000000000..a8db544e7 --- /dev/null +++ b/.agents/skills/optimize-runtime/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Optimize Runtime" + short_description: "Run benchmark-gated runtime optimization waves" + default_prompt: "Use $optimize-runtime to measure GocciaScript bytecode, investigate bottlenecks, and keep only verified performance improvements." diff --git a/AGENTS.md b/AGENTS.md index 0903227b2..af8496314 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,3 +64,4 @@ runtime command lists here. - **Contribution requirements:** [CONTRIBUTING.md](CONTRIBUTING.md) - **Engine shape:** [docs/architecture.md](docs/architecture.md), [docs/interpreter.md](docs/interpreter.md), [docs/bytecode-vm.md](docs/bytecode-vm.md), [docs/core-patterns.md](docs/core-patterns.md) - **Optional extended agent skills:** [.agents/skills/](.agents/skills/) (installable playbooks; not a substitute for CONTRIBUTING) +- **Runtime optimization waves:** [.agents/skills/optimize-runtime/SKILL.md](.agents/skills/optimize-runtime/SKILL.md) — benchmark-gated bytecode/interpreter speed work vs QuickJS; keep only measured wins From 56eb8849fd0918ee024034867f7b821b2c0ce98c Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 25 Aug 2026 21:15:41 +0100 Subject: [PATCH 02/16] docs(agent): record bytecode vs QuickJS wave-1 baseline Capture the 0.062x QuickJS AWFY geomean, local probe medians, and profile facts before isolated optimization lanes start. Co-authored-by: Cursor --- .agent/HANDOFF.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .agent/HANDOFF.md diff --git a/.agent/HANDOFF.md b/.agent/HANDOFF.md new file mode 100644 index 000000000..655cce808 --- /dev/null +++ b/.agent/HANDOFF.md @@ -0,0 +1,53 @@ +# Handoff + +Updated: 2026-08-25 (wave 1 established; lanes launching) + +## Experiment + +- **Goal:** Goccia bytecode at 0.6×–0.8× of QuickJS *speed* (AWFY `goccia_over_qjs` ≈ 1.25–1.67). +- **Current main CI** (`f4d403f0`, linux/x64 Azure): AWFY geomean `goccia_over_qjs` = **16.256** (speed **0.062×**). Need ~10–13×. +- **Delivery branch:** `perf/bytecode-quickjs-gap` @ `3a2e2949` (skill adoption on `origin/main` `f4d403f0`). +- **Baseline binary:** `/tmp/goccia-baseline-f4d403f0` (`--prod` loader, darwin/aarch64, FPC 3.2.2). +- **QuickJS:** 2026-06-04 at `/tmp/quickjs/bin/qjs`. +- **Lock:** `/tmp/gocciascript-perf-gate.lock`. +- **AWFY sources:** `/tmp/are-we-fast-yet` @ `74306fe`. +- **Invariant:** interpreter/bytecode observationally identical; no sandbox weakening. +- **Non-goals:** V8/SpiderMonkey parity; rejected read-PIC (ADR 0088); value caches (ADR 0081). + +## Local probe baseline (darwin/aarch64, 7 interleaved reps) + +| Probe | Goccia us | QJS us | g/qjs | speed | +| --- | ---: | ---: | ---: | ---: | +| loop-dispatch-floor | 129288 | 7130 | 18.13 | 0.055× | +| generic-plus-scalars | 63495 | 4874 | 13.03 | 0.077× | +| nbody-minimal | 66948 | 2581 | 25.94 | 0.039× | +| fib-recursive | 46752 | 7351 | 6.36 | 0.157× | +| propaccess-monomorphic | 17006 | 985 | 17.26 | 0.058× | +| fixed-arg-call | 36265 | 2488 | 14.58 | 0.069× | +| geomean | | | 14.63 | 0.068× | + +Fib is the best relative row because it already uses `OP_CALL_SELF_NUM` / `OP_SUB_NUM_IMM` / `OP_JUMP_IF_NUM_NOT_LTE_IMM`. Dispatch-floor is 18×: the interpreter tax. + +## Profile facts (function-wrapped equivalents) + +- `loop-dispatch-floor`: 38% `OP_GET_LOCAL`, 15% `OP_LOAD_INT`, 11% `OP_SET_LOCAL`, 8% `OP_ADD_FLOAT`. Loop compare is generic `OP_LT`. Increment is `i = i + 1` (not `++`), so existing `OP_INC` is unused. Number literals type as `sltFloat` (`ExpressionType` in `Goccia.Compiler.Statements.pas`). +- `nbody-minimal`: 31% `OP_GET_LOCAL`, 12% `OP_GET_PROP_CONST`, 10% `OP_LOAD_HOLE`, 8% `OP_MOVE`. Hot pair `GET_LOCAL → GET_PROP_CONST` (11%). Generic `OP_MUL`/`OP_ADD` with 100% scalar hit rate. +- Script-level `let` in a non-function profiled as `OP_GET_GLOBAL` (29% of opcodes) — not the AWFY/probe shape. + +## CI AWFY worst rows (linux/x64, time ratio) + +Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, Towers 18.91, Richards 16.21. Mandelbrot 5.58 (best). + +## Lanes launching + +1. `optimize/inc-assign` — compile `id = id + 1` as existing `OP_INC`. +2. `optimize/int-literals` — integer-valued number literals as `sltInteger`. +3. `optimize/add-num-imm` — `OP_ADD_NUM_IMM` mirroring `OP_SUB_NUM_IMM`. +4. `optimize/hot-dispatch-extract` — shrink register pressure in the VM loop. +5. `optimize/get-local-prop` — fuse `GET_LOCAL` + `GET_PROP_CONST`. + +## Rejected (do not retry) + +- Broader read-side PIC (ADR 0088) +- Value caches (ADR 0081) +- String interning on `RuntimeCopy` From 5e5ca3ad8c28aaffe6a168ce6b4518f51887a07b Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 25 Aug 2026 21:18:23 +0100 Subject: [PATCH 03/16] docs(agent): fold wave-1 investigation into the handoff Record write-IC, counted-for, and the rejected-retry list so later lanes do not revive ADR 0088/0081/0089 work. Co-authored-by: Cursor --- .agent/HANDOFF.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.agent/HANDOFF.md b/.agent/HANDOFF.md index 655cce808..ccf5f8b7b 100644 --- a/.agent/HANDOFF.md +++ b/.agent/HANDOFF.md @@ -1,6 +1,6 @@ # Handoff -Updated: 2026-08-25 (wave 1 established; lanes launching) +Updated: 2026-08-25 (investigation folded; write-IC + counted-for lanes added) ## Experiment @@ -43,11 +43,26 @@ Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, To 1. `optimize/inc-assign` — compile `id = id + 1` as existing `OP_INC`. 2. `optimize/int-literals` — integer-valued number literals as `sltInteger`. 3. `optimize/add-num-imm` — `OP_ADD_NUM_IMM` mirroring `OP_SUB_NUM_IMM`. -4. `optimize/hot-dispatch-extract` — shrink register pressure in the VM loop. +4. `optimize/hot-dispatch-extract` — shrink register pressure / strip cold preamble in the VM loop. 5. `optimize/get-local-prop` — fuse `GET_LOCAL` + `GET_PROP_CONST`. +6. `optimize/write-ic` — own writable-data write IC (ADR 0088 leftover; requires AWFY transfer). +7. `optimize/counted-for-assign` — widen `TryCompileCountedFor` to `i = i + 1`. + +## Investigation conclusions (do not contradict) + +- **NaN-box / tagged-pointer rewrite:** out of this wave (`TGocciaRegister` is a 16-byte fat union by design). +- **Numeric loops already unboxed** on the generic scalar arm; remaining tax is dispatch + property-boundary boxing on store. +- **Broader read-PIC:** still rejected (ADR 0088). Own+proto read ICs already ship. +- **Write-IC:** unimplemented; prior isolated 30× AWFY was Richards +8%, Bounce +5%, Storage +3%. Re-measure interleaved; do not bundle with read-PIC. +- **`OP_SET_PROP_CONST`** still calls full `AssignProperty` except literal-object fast path; `VMTrySetOwnWritableDataProperty` exists but is unused there. +- **Counted-for** only matches `i++`, so AWFY/probe `i = i + 1` misses `OP_ADD_INT` loop microcode. +- **CALL:** bytecode→bytecode already trampolines; `ExecuteClosureRegisters0–3` are native ingress only. Revisit `OP_CALL_METHOD` staging only with AWFY transfer (ADR 0089 previously noise). +- **Dispatch preamble:** ~10–15 predictable cold branches per opcode; dual prod/instrumented loop is the DISPATCH bet, not jumptable surgery. +- **ALLOC:** do not revive value caches. Property-store `RegisterToValue` boxing is the live allocation tax. ## Rejected (do not retry) - Broader read-side PIC (ADR 0088) - Value caches (ADR 0081) - String interning on `RuntimeCopy` +- July 2026 pooled-collection call bypass (ADR 0089) From e0f0bf8b497994bbbde8de04ee5b871d6c7bc220 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 25 Aug 2026 21:31:53 +0100 Subject: [PATCH 04/16] docs(agent): record hot-dispatch-extract rejection A sparse hot case plus nested cold helper was flat on the dispatch floor and several times slower on mixed-opcode probes. Co-authored-by: Cursor --- .agent/HANDOFF.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.agent/HANDOFF.md b/.agent/HANDOFF.md index ccf5f8b7b..652fa77af 100644 --- a/.agent/HANDOFF.md +++ b/.agent/HANDOFF.md @@ -1,6 +1,6 @@ # Handoff -Updated: 2026-08-25 (investigation folded; write-IC + counted-for lanes added) +Updated: 2026-08-25 (hot-dispatch-extract rejected) ## Experiment @@ -43,7 +43,7 @@ Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, To 1. `optimize/inc-assign` — compile `id = id + 1` as existing `OP_INC`. 2. `optimize/int-literals` — integer-valued number literals as `sltInteger`. 3. `optimize/add-num-imm` — `OP_ADD_NUM_IMM` mirroring `OP_SUB_NUM_IMM`. -4. `optimize/hot-dispatch-extract` — shrink register pressure / strip cold preamble in the VM loop. +4. `optimize/hot-dispatch-extract` — **rejected** (see below). 5. `optimize/get-local-prop` — fuse `GET_LOCAL` + `GET_PROP_CONST`. 6. `optimize/write-ic` — own writable-data write IC (ADR 0088 leftover; requires AWFY transfer). 7. `optimize/counted-for-assign` — widen `TryCompileCountedFor` to `i = i + 1`. @@ -57,9 +57,23 @@ Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, To - **`OP_SET_PROP_CONST`** still calls full `AssignProperty` except literal-object fast path; `VMTrySetOwnWritableDataProperty` exists but is unused there. - **Counted-for** only matches `i++`, so AWFY/probe `i = i + 1` misses `OP_ADD_INT` loop microcode. - **CALL:** bytecode→bytecode already trampolines; `ExecuteClosureRegisters0–3` are native ingress only. Revisit `OP_CALL_METHOD` staging only with AWFY transfer (ADR 0089 previously noise). -- **Dispatch preamble:** ~10–15 predictable cold branches per opcode; dual prod/instrumented loop is the DISPATCH bet, not jumptable surgery. +- **Dispatch preamble:** ~10–15 predictable cold branches per opcode. A remaining DISPATCH idea is a **prod vs instrumented dual loop** that keeps one `case` and only strips coverage/profiler/`AStopAtIP` on the measured path — not a hot/cold case split. - **ALLOC:** do not revive value caches. Property-store `RegisterToValue` boxing is the live allocation tax. +## Rejected this wave + +- **`optimize/hot-dispatch-extract`** (first-level hot `case` + `ExecuteColdOpcode` nested helper). Checksums matched; fully reverted; no commit. Medians vs `/tmp/goccia-baseline-f4d403f0`, 7 interleaved reps (`/tmp/lane-hot-dispatch-ab.json`): + + | Probe | Base µs | Cand µs | Ratio | + | --- | ---: | ---: | ---: | + | loop-dispatch-floor | 121819 | 123053 | 1.010 | + | generic-plus-scalars | 59864 | 455680 | 7.612 | + | nbody-minimal | 64089 | 233806 | 3.648 | + | fib-recursive | 44227 | 192440 | 4.351 | + | fixed-arg-call | 34331 | 35860 | 1.045 | + + Do not retry a sparse hot `case` plus nested cold helper. High-numbered opcodes (`OP_ADD`, `OP_MUL`, `OP_SUB_NUM_IMM`, `OP_LOAD_HOLE`) became much more expensive while the all-hot floor stayed flat. + ## Rejected (do not retry) - Broader read-side PIC (ADR 0088) From ad0023da2de9b23c3d9a9c3ed304ee4f9ab1ce36 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 25 Aug 2026 21:38:27 +0100 Subject: [PATCH 05/16] perf(bytecode): fuse proven Number plus Int16 as OP_ADD_NUM_IMM Skip the extra LOAD_INT and ADD_FLOAT dispatches on i+K loops while keeping generic + semantics for unproven and non-numeric operands. Co-authored-by: Cursor --- source/units/Goccia.Bytecode.OpCodeNames.pas | 1 + source/units/Goccia.Bytecode.pas | 9 ++++-- source/units/Goccia.Compiler.Expressions.pas | 24 ++++++++++++++ source/units/Goccia.Compiler.Test.pas | 31 +++++++++++++++++++ source/units/Goccia.VM.Test.pas | 30 ++++++++++++++++++ source/units/Goccia.VM.pas | 9 ++++++ .../expressions/addition/numeric-immediate.js | 20 ++++++++++++ 7 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 tests/language/expressions/addition/numeric-immediate.js diff --git a/source/units/Goccia.Bytecode.OpCodeNames.pas b/source/units/Goccia.Bytecode.OpCodeNames.pas index d702417a9..dc31840ba 100644 --- a/source/units/Goccia.Bytecode.OpCodeNames.pas +++ b/source/units/Goccia.Bytecode.OpCodeNames.pas @@ -223,6 +223,7 @@ function OpCodeName(const AOp: UInt8): string; OP_SUB_NUM_IMM: Result := 'OP_SUB_NUM_IMM'; OP_JUMP_IF_NUM_NOT_LTE_IMM: Result := 'OP_JUMP_IF_NUM_NOT_LTE_IMM'; OP_CALL_SELF_NUM: Result := 'OP_CALL_SELF_NUM'; + OP_ADD_NUM_IMM: Result := 'OP_ADD_NUM_IMM'; else Result := Format('OP_UNKNOWN_%d', [AOp]); end; diff --git a/source/units/Goccia.Bytecode.pas b/source/units/Goccia.Bytecode.pas index e43899bbd..08de66163 100644 --- a/source/units/Goccia.Bytecode.pas +++ b/source/units/Goccia.Bytecode.pas @@ -169,7 +169,8 @@ interface // v76 -> v77: debug info carries each function's declaration line and // column, so coverage reports a function at the line it is // declared on rather than at its first executed instruction. - GOCCIA_FORMAT_VERSION = 77; + // v77 -> v78: added OP_ADD_NUM_IMM. + GOCCIA_FORMAT_VERSION = 78; GOCCIA_BINARY_MAGIC: array[0..3] of Byte = (Ord('G'), Ord('B'), Ord('C'), 0); GOCCIA_NULLISH_MATCH_UNDEFINED = 0; GOCCIA_NULLISH_MATCH_NULL = 1; @@ -442,7 +443,9 @@ interface // A = destination, B = first contiguous argument register, // C = argument count (1..3). Valid only in a compiler-proven closed-world // numeric self-recursive template. - OP_CALL_SELF_NUM = 229 + OP_CALL_SELF_NUM = 229, + // A = destination, B = proven Number source, C = signed Int16 immediate. + OP_ADD_NUM_IMM = 230 ); function IsValidGocciaOpCode(const AOp: UInt8): Boolean; @@ -510,7 +513,7 @@ function GocciaOpCodeUsesRegisterB(const AOp: TGocciaOpCode): Boolean; OP_GET_WITH_BINDING_STRICT, OP_SET_WITH_BINDING, OP_SET_WITH_BINDING_LOOSE, OP_SUPER_SET, OP_SUPER_BASE, OP_SUPER_SET_BASE, OP_DEFINE_STATIC_PROP_DYNAMIC, - OP_CONSTRUCT_SPREAD, OP_SUB_NUM_IMM, OP_SET_UPVALUE_REF, + OP_CONSTRUCT_SPREAD, OP_SUB_NUM_IMM, OP_ADD_NUM_IMM, OP_SET_UPVALUE_REF, OP_DYNAMIC_IMPORT_OPTIONS, OP_DYNAMIC_IMPORT_SOURCE_OPTIONS, OP_DYNAMIC_IMPORT_DEFER_OPTIONS, OP_TO_NUMBER, OP_TO_STRING, OP_NEG, OP_BNOT, OP_EQ, OP_NEQ, OP_LOOSE_EQ, OP_LOOSE_NEQ, diff --git a/source/units/Goccia.Compiler.Expressions.pas b/source/units/Goccia.Compiler.Expressions.pas index 9f9a98f91..cf5d9537c 100644 --- a/source/units/Goccia.Compiler.Expressions.pas +++ b/source/units/Goccia.Compiler.Expressions.pas @@ -2046,6 +2046,30 @@ procedure CompileBinary(const ACtx: TGocciaCompilationContext; Exit; end; + if AExpr.Operator = gttPlus then + begin + if HasExactNumberProof(ACtx.Scope, AExpr.Left) and + TrySignedInt16NumberLiteral(AExpr.Right, Immediate) then + begin + RegB := ACtx.Scope.AllocateRegister; + ACtx.CompileExpression(AExpr.Left, RegB); + EmitInstruction(ACtx, EncodeABC(OP_ADD_NUM_IMM, ADest, RegB, + UInt16(Immediate))); + ACtx.Scope.FreeRegister; + Exit; + end; + if HasExactNumberProof(ACtx.Scope, AExpr.Right) and + TrySignedInt16NumberLiteral(AExpr.Left, Immediate) then + begin + RegB := ACtx.Scope.AllocateRegister; + ACtx.CompileExpression(AExpr.Right, RegB); + EmitInstruction(ACtx, EncodeABC(OP_ADD_NUM_IMM, ADest, RegB, + UInt16(Immediate))); + ACtx.Scope.FreeRegister; + Exit; + end; + end; + RegB := ACtx.Scope.AllocateRegister; RegC := ACtx.Scope.AllocateRegister; diff --git a/source/units/Goccia.Compiler.Test.pas b/source/units/Goccia.Compiler.Test.pas index 46b09ad8f..88cd304b8 100644 --- a/source/units/Goccia.Compiler.Test.pas +++ b/source/units/Goccia.Compiler.Test.pas @@ -98,6 +98,7 @@ TTestCompiler = class(TTestSuite) procedure TestClosedNumericScalarSelfCallArityLimit; procedure TestMixedOrEscapedCallsCancelNumericProof; procedure TestKnownNumericLocalUsesSubtractImmediate; + procedure TestKnownNumericLocalUsesAddImmediate; procedure TestGenericAdditionDefersToPrimitiveToOpcode; procedure TestAssignmentClearsStaleNumericHint; procedure TestGlobalBackedAssignmentClearsStaleNumericHint; @@ -168,6 +169,8 @@ procedure TTestCompiler.SetupTests; TestMixedOrEscapedCallsCancelNumericProof); Test('Known numeric local uses subtract immediate', TestKnownNumericLocalUsesSubtractImmediate); + Test('Known numeric local uses add immediate', + TestKnownNumericLocalUsesAddImmediate); Test('Generic addition defers ToPrimitive to opcode', TestGenericAdditionDefersToPrimitiveToOpcode); Test('Assignment clears stale numeric hint', TestAssignmentClearsStaleNumericHint); @@ -377,6 +380,7 @@ function TTestCompiler.CountArithmeticOps( CountOp(ATemplate, OP_DIV_FLOAT) + CountOp(ATemplate, OP_MOD_FLOAT); Result := Result + CountOp(ATemplate, OP_SUB_NUM_IMM); + Result := Result + CountOp(ATemplate, OP_ADD_NUM_IMM); end; function TTestCompiler.HasLoadInt(const ATemplate: TGocciaFunctionTemplate; @@ -1336,6 +1340,33 @@ procedure TTestCompiler.TestKnownNumericLocalUsesSubtractImmediate; end; end; +procedure TTestCompiler.TestKnownNumericLocalUsesAddImmediate; +var + Module: TGocciaBytecodeModule; +begin + Module := CompileSource('let i = 2; i + 3;', + False, False, False, False, False, False); + try + Expect(CountOp(Module.TopLevel, OP_ADD_NUM_IMM)).ToBe(1); + Expect(CountOp(Module.TopLevel, OP_ADD)).ToBe(0); + Expect(CountOp(Module.TopLevel, OP_ADD_FLOAT)).ToBe(0); + Expect(CountOp(Module.TopLevel, OP_ADD_INT)).ToBe(0); + finally + Module.Free; + end; + + Module := CompileSource('let i = 2; 3 + i;', + False, False, False, False, False, False); + try + Expect(CountOp(Module.TopLevel, OP_ADD_NUM_IMM)).ToBe(1); + Expect(CountOp(Module.TopLevel, OP_ADD)).ToBe(0); + Expect(CountOp(Module.TopLevel, OP_ADD_FLOAT)).ToBe(0); + Expect(CountOp(Module.TopLevel, OP_ADD_INT)).ToBe(0); + finally + Module.Free; + end; +end; + procedure TTestCompiler.TestGenericAdditionDefersToPrimitiveToOpcode; var Module: TGocciaBytecodeModule; diff --git a/source/units/Goccia.VM.Test.pas b/source/units/Goccia.VM.Test.pas index b1859a17b..e231aa1a4 100644 --- a/source/units/Goccia.VM.Test.pas +++ b/source/units/Goccia.VM.Test.pas @@ -36,6 +36,7 @@ TTestGocciaVM = class(TTestSuite) procedure TestExecuteConstString; procedure TestExecuteComparisons; procedure TestExecuteSubtractNumberImmediate; + procedure TestExecuteAddNumberImmediate; procedure TestExecuteNumberImmediateBranch; procedure TestExecuteArrayOps; procedure TestExecuteArrayPop; @@ -79,6 +80,7 @@ procedure TTestGocciaVM.SetupTests; Test('Execute constant string', TestExecuteConstString); Test('Execute comparisons', TestExecuteComparisons); Test('Execute Number subtract immediate', TestExecuteSubtractNumberImmediate); + Test('Execute Number add immediate', TestExecuteAddNumberImmediate); Test('Execute Number immediate branch', TestExecuteNumberImmediateBranch); Test('Execute array ops', TestExecuteArrayOps); Test('Execute array pop', TestExecuteArrayPop); @@ -241,6 +243,34 @@ procedure TTestGocciaVM.TestExecuteSubtractNumberImmediate; end; end; +procedure TTestGocciaVM.TestExecuteAddNumberImmediate; +var + Template: TGocciaFunctionTemplate; + VM: TGocciaVM; + ResultValue: TGocciaValue; + FloatIndex: UInt16; +begin + Template := TGocciaFunctionTemplate.Create('add-number-immediate'); + VM := TGocciaVM.Create; + try + Template.MaxRegisters := 2; + Template.EmitInstruction(EncodeAsBx(OP_LOAD_INT, 0, 7)); + Template.EmitInstruction(EncodeABC(OP_ADD_NUM_IMM, 1, 0, + UInt16(Int16(-2)))); + Template.EmitInstruction(EncodeABC(OP_RETURN, 1, 0, 0)); + ResultValue := VM.ExecuteFunction(Template); + Expect(ResultValue.ToNumberLiteral.Value).ToBe(5); + + FloatIndex := Template.AddConstantFloat(7.5); + Template.PatchInstruction(0, EncodeABx(OP_LOAD_CONST, 0, FloatIndex)); + ResultValue := VM.ExecuteFunction(Template); + Expect(ResultValue.ToNumberLiteral.Value).ToBe(5.5); + finally + VM.Free; + Template.Free; + end; +end; + procedure TTestGocciaVM.TestExecuteNumberImmediateBranch; var Template: TGocciaFunctionTemplate; diff --git a/source/units/Goccia.VM.pas b/source/units/Goccia.VM.pas index c58b4c406..0ae632916 100644 --- a/source/units/Goccia.VM.pas +++ b/source/units/Goccia.VM.pas @@ -15169,6 +15169,15 @@ function TGocciaVM.ExecuteClosureRegistersInternal( raise Exception.Create( 'Invalid non-numeric source for OP_SUB_NUM_IMM'); + OP_ADD_NUM_IMM: + if FRegisters[B].Kind = grkInt then + FRegisters[A] := VMIntResult(FRegisters[B].IntValue + Int16(C)) + else if FRegisters[B].Kind = grkFloat then + FRegisters[A] := VMNumberRegister(FRegisters[B].FloatValue + Int16(C)) + else + raise Exception.Create( + 'Invalid non-numeric source for OP_ADD_NUM_IMM'); + OP_MUL_INT: if (FRegisters[B].Kind = grkInt) and (FRegisters[C].Kind = grkInt) then FRegisters[A] := VMIntResult(FRegisters[B].IntValue * diff --git a/tests/language/expressions/addition/numeric-immediate.js b/tests/language/expressions/addition/numeric-immediate.js new file mode 100644 index 000000000..56a898c4c --- /dev/null +++ b/tests/language/expressions/addition/numeric-immediate.js @@ -0,0 +1,20 @@ +/*--- +description: Proven numeric locals add an Int16 immediate +features: [addition-operator] +---*/ + +test("adds an Int16 immediate to an integer local", () => { + let n = 4; + expect(n + 3).toBe(7); + expect(3 + n).toBe(7); + expect(n + -3).toBe(1); + expect(-3 + n).toBe(1); +}); + +test("adds an Int16 immediate to a float local", () => { + let n = 4.5; + expect(n + 3).toBe(7.5); + expect(3 + n).toBe(7.5); + expect(n + -3).toBe(1.5); + expect(-3 + n).toBe(1.5); +}); From 315bfd2861ae095369fbc402744de51ad68fe174 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 25 Aug 2026 21:39:12 +0100 Subject: [PATCH 06/16] perf(bytecode): compile numeric i = i + 1 as OP_INC_NUMERIC Proven-numeric identifier self-increment assignment now reuses the ++ opcode, cutting GET_LOCAL/LOAD_INT/ADD_FLOAT/SET_LOCAL dispatch on loop-heavy probes without changing string concatenation. Co-authored-by: Cursor --- source/units/Goccia.Compiler.Expressions.pas | 73 +++++++++++++++++++ .../arithmetic/self-increment-assignment.js | 66 +++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 tests/language/expressions/arithmetic/self-increment-assignment.js diff --git a/source/units/Goccia.Compiler.Expressions.pas b/source/units/Goccia.Compiler.Expressions.pas index 9f9a98f91..51ee1aefa 100644 --- a/source/units/Goccia.Compiler.Expressions.pas +++ b/source/units/Goccia.Compiler.Expressions.pas @@ -252,6 +252,45 @@ function TrySignedInt16NumberLiteral(const AExpr: TGocciaExpression; Result := True; end; +function IsNumberOneLiteral(const AExpr: TGocciaExpression): Boolean; +var + NumberValue: Double; +begin + Result := False; + if not (AExpr is TGocciaLiteralExpression) or + not (TGocciaLiteralExpression(AExpr).Value is + TGocciaNumberLiteralValue) then + Exit; + NumberValue := TGocciaNumberLiteralValue( + TGocciaLiteralExpression(AExpr).Value).Value; + Result := NumberValue = 1; +end; + +function IsNumericSelfIncrementByOne(const AScope: TGocciaCompilerScope; + const AName: string; const AValue: TGocciaExpression): Boolean; +var + Binary: TGocciaBinaryExpression; + IdentExpr: TGocciaExpression; +begin + Result := False; + if not (AValue is TGocciaBinaryExpression) then + Exit; + Binary := TGocciaBinaryExpression(AValue); + if Binary.Operator <> gttPlus then + Exit; + if (Binary.Left is TGocciaIdentifierExpression) and + (TGocciaIdentifierExpression(Binary.Left).Name = AName) and + IsNumberOneLiteral(Binary.Right) then + IdentExpr := Binary.Left + else if (Binary.Right is TGocciaIdentifierExpression) and + (TGocciaIdentifierExpression(Binary.Right).Name = AName) and + IsNumberOneLiteral(Binary.Left) then + IdentExpr := Binary.Right + else + Exit; + Result := HasExactNumberProof(AScope, IdentExpr); +end; + function IsAnonymousFunctionNameExpression( const AExpr: TGocciaExpression): Boolean; begin @@ -2492,6 +2531,40 @@ procedure CompileAssignment(const ACtx: TGocciaCompilationContext; end; end; + if (LocalIdx >= 0) and + IsNumericSelfIncrementByOne(ACtx.Scope, AExpr.Name, AExpr.Value) then + begin + Local := ACtx.Scope.GetLocal(LocalIdx); + if (not Local.IsGlobalBacked) and (not Local.IsImportBinding) then + begin + if Local.IsConst then + begin + if ShouldIgnoreNonStrictImmutableLocalAssignment(ACtx, Local) then + Exit; + EmitConstAssignmentError(ACtx); + Exit; + end; + Slot := Local.Slot; + if Local.IsCaptured then + EmitInstruction(ACtx, EncodeABx(OP_GET_LOCAL, Slot, UInt16(Slot))); + EmitInstruction(ACtx, EncodeABC(OP_INC_NUMERIC, Slot, Slot, 0)); + if Local.IsCaptured then + EmitInstruction(ACtx, EncodeABx(OP_SET_LOCAL, Slot, UInt16(Slot))); + if ADest <> Slot then + EmitInstruction(ACtx, EncodeABC(OP_MOVE, ADest, Slot, 0)); + EmitStrictLocalTypeCheck(ACtx, LocalIdx, ADest, + InferLocalType(AExpr.Value)); + EmitExportBindingUpdates(ACtx, Local.ExportNames, + Local.ExportNameCount, ADest); + if not Local.IsStrictlyTyped then + begin + ValueType := InferredExpressionType(ACtx.Scope, AExpr.Value); + SetNonStrictLocalTypeHint(ACtx, LocalIdx, ValueType); + end; + Exit; + end; + end; + if (UpvalIdx >= 0) and ExpressionContainsDirectEval(AExpr.Value) then TargetReg := PrepareUpvalueAssignmentReference(ACtx, UpvalIdx); diff --git a/tests/language/expressions/arithmetic/self-increment-assignment.js b/tests/language/expressions/arithmetic/self-increment-assignment.js new file mode 100644 index 000000000..3b81081a2 --- /dev/null +++ b/tests/language/expressions/arithmetic/self-increment-assignment.js @@ -0,0 +1,66 @@ +/*--- +description: Identifier self-increment assignment (id = id + 1) matches ++ for numeric locals +features: [assignment-operators] +---*/ + +test("self-increment assignment adds one", () => { + let i = 0; + i = i + 1; + expect(i).toBe(1); + i = i + 1; + expect(i).toBe(2); +}); + +test("commutative self-increment assignment adds one", () => { + let i = 0; + i = 1 + i; + expect(i).toBe(1); + i = 1 + i; + expect(i).toBe(2); +}); + +test("self-increment assignment produces the new value", () => { + let i = 4; + expect(i = i + 1).toBe(5); + expect(i).toBe(5); + expect(i = 1 + i).toBe(6); + expect(i).toBe(6); +}); + +test("self-increment assignment preserves a fractional part", () => { + let x = 1.5; + x = x + 1; + expect(x).toBe(2.5); + x = 1 + x; + expect(x).toBe(3.5); +}); + +test("string self-addition still concatenates", () => { + let i = "1"; + i = i + 1; + expect(i).toBe("11"); + i = 1 + i; + expect(i).toBe("111"); +}); + +test("self-increment assignment on captured local syncs upvalue cell", () => { + const f = () => { + let i = 0; + const get = () => i; + i = i + 1; + i = 1 + i; + return [get(), i]; + }; + expect(f()).toEqual([2, 2]); +}); + +test("self-increment assignment result can be captured after writes", () => { + const f = () => { + let i = 10; + const get = () => i; + const a = i = i + 1; + const b = i = 1 + i; + return [a, b, get()]; + }; + expect(f()).toEqual([11, 12, 12]); +}); From 15e8eca76e7cbc1bbe91d51968c275703c4e3b0a Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 25 Aug 2026 21:40:53 +0100 Subject: [PATCH 07/16] perf(bytecode): cache own writable property stores Monomorphic OP_SET_PROP_CONST sites now hit a shape-keyed write IC for ordinary own writable data, skipping the name hash on the hot store path used by Richards, Bounce, and Storage. Co-authored-by: Cursor --- docs/bytecode-vm.md | 5 +- source/units/Goccia.Bytecode.Chunk.pas | 59 +++++++ source/units/Goccia.VM.pas | 151 +++++++++++++++-- .../property-write-after-redefinition.js | 153 ++++++++++++++++++ 4 files changed, 352 insertions(+), 16 deletions(-) create mode 100644 tests/language/expressions/member-access/property-write-after-redefinition.js diff --git a/docs/bytecode-vm.md b/docs/bytecode-vm.md index 451e37f20..70e03b2dc 100644 --- a/docs/bytecode-vm.md +++ b/docs/bytecode-vm.md @@ -129,13 +129,14 @@ Recent VM cleanup and optimization work has focused on reducing per-instruction ### Inline Caches -Three per-site inline caches live on `TGocciaFunctionTemplate`, all indexed by the instruction's name-constant index, all runtime-only (never serialised to `.gbc`): +Four per-site inline caches live on `TGocciaFunctionTemplate`, all indexed by the instruction's name-constant index, all runtime-only (never serialised to `.gbc`): - **Global reads** (`OP_GET_GLOBAL`) — `TGocciaGlobalReadCacheEntry` validates `(scope identity, binding-map entry version)` and re-reads the binding by entry index, skipping the name hash. - **Own property reads** (`OP_GET_PROP_CONST`) — `TGocciaPropertyReadCacheEntry` validates the receiver's interned **shape** (`Goccia.Values.Shape`): same shape implies the same key at the cached entry index, so one site hits across many same-layout receivers. The descriptor kind is re-checked on every hit because data-to-accessor redefinition keeps the entry index. - **Prototype-resolved reads** (`OP_GET_PROP_CONST`, after an own miss) — `TGocciaProtoReadCacheEntry` proves continued *absence* of the name on the receiver and intermediate levels and *presence* at the holder, all by fresh shape identity per level, then re-reads the holder descriptor by entry index. The live chain is re-walked per hit, so `setPrototypeOf` is followed inherently; chain levels must be exact `TGocciaObjectValue`; chains deeper than two levels and accessor holders stay generic. Class instance methods (data properties on the class prototype object) are the dominant beneficiary. +- **Own property writes** (`OP_SET_PROP_CONST`) — `TGocciaPropertyWriteCacheEntry` is the write-side counterpart of the own-read cache: same `(shape, entry index)` validation, storing only own writable data properties. `Writable` and the descriptor kind are re-checked on every hit. Accessors, proxies, private fields, deletion, prototype mutation, non-writable descriptors, and non-ordinary receivers take `AssignProperty`. This cache is independent of the read/proto slot map so the read-side PIC is not expanded ([ADR 0088](adr/0088-reject-broader-property-inline-caches.md)). -Hits and fills serve only exact-class `TGocciaObjectValue` / `TGocciaVMLiteralObjectValue` / `TGocciaInstanceValue` receivers, so overridden lookup semantics (proxies, exotic objects, private names) always take the generic path. Shapes are computed lazily at fill time (`EnsureShape`), not eagerly at property-append time: a stale shape is a true prefix description of an append-only layout, so the hit path may read it raw and at worst misses. Delete/clear flip a map to dictionary mode (a sentinel shape that never matches a cache entry). A map also flips to dictionary mode when `EnsureShape` runs from a non-owner realm, so cross-realm property reads never intern one realm's layout into another realm's shape table. After `PROPERTY_READ_CACHE_POLYMORPHIC_LIMIT` consecutive misses-with-refill or fill declines a site is megamorphic: it stops probing and serves gated receivers through the uncached own-data fast path. +Hits and fills serve only exact-class `TGocciaObjectValue` / `TGocciaVMLiteralObjectValue` / `TGocciaInstanceValue` receivers, so overridden lookup semantics (proxies, exotic objects, private names) always take the generic path. Shapes are computed lazily at fill time (`EnsureShape`), not eagerly at property-append time: a stale shape is a true prefix description of an append-only layout, so the hit path may read it raw and at worst misses. Delete/clear flip a map to dictionary mode (a sentinel shape that never matches a cache entry). A map also flips to dictionary mode when `EnsureShape` runs from a non-owner realm, so cross-realm property reads never intern one realm's layout into another realm's shape table. After `PROPERTY_READ_CACHE_POLYMORPHIC_LIMIT` (reads) or `PROPERTY_WRITE_CACHE_POLYMORPHIC_LIMIT` (writes) consecutive misses-with-refill or fill declines a site is megamorphic: it stops probing and serves gated receivers through the uncached own-data fast path. Cached pointers (scope, shape) are compared for identity only and never dereferenced. Scope cache entries carry an entry-version stamp against allocator address reuse; shape entries need none, because shapes are never freed within an engine's lifetime, function templates never outlive their engine, and cross-realm maps stop shape tracking before a foreign realm can cache their owner layout. diff --git a/source/units/Goccia.Bytecode.Chunk.pas b/source/units/Goccia.Bytecode.Chunk.pas index 9f9d3801c..188b40ea7 100644 --- a/source/units/Goccia.Bytecode.Chunk.pas +++ b/source/units/Goccia.Bytecode.Chunk.pas @@ -135,6 +135,21 @@ TGocciaPropertyReadCacheEntry = record end; PGocciaPropertyReadCacheEntry = ^TGocciaPropertyReadCacheEntry; + // Runtime-only inline cache for OP_SET_PROP_CONST sites, indexed by the + // instruction's name-constant index. Same (shape, entry index) validation + // as the own-property read cache; hits store only own writable data + // properties. The descriptor kind and Writable flag are re-checked on + // every hit because data-to-accessor / writable-to-nonwritable + // redefinition keeps the entry index. Proxies, accessors, private + // fields, deletion, and non-ordinary receivers stay on AssignProperty. + // Not serialised to .gbc. + TGocciaPropertyWriteCacheEntry = record + Shape: Pointer; + EntryIndex: Integer; + MissStreak: Byte; + end; + PGocciaPropertyWriteCacheEntry = ^TGocciaPropertyWriteCacheEntry; + // Runtime-only inline cache for OP_GET_PROP_CONST sites that resolve on // the receiver's prototype chain (methods on class prototype objects are // the dominant case). Shapes[0] is the receiver's own shape — proving @@ -234,12 +249,19 @@ TGocciaFunctionTemplate = class FPropertyReadCaches: array of TGocciaPropertyReadCacheEntry; FProtoReadCaches: array of TGocciaProtoReadCacheEntry; FPropertyReadSlotCount: Integer; + // Write-IC slots are independent of the read/proto map so a GET of a + // name that is never written does not allocate a write entry, and the + // read-side PIC is not expanded (ADR 0088). + FPropertyWriteSlotMap: array of UInt32; + FPropertyWriteCaches: array of TGocciaPropertyWriteCacheEntry; + FPropertyWriteSlotCount: Integer; // Runtime-only, appended in ascending PC order by the compiler and never // serialised to .gbc — a module loaded from binary bytecode simply falls // back to the runtime-type-name form of the "is not a function" message. FCallSites: array of TGocciaCallSiteEntry; FCallSiteCount: Integer; function PropertyReadSlot(const AConstIndex: Integer): Integer; + function PropertyWriteSlot(const AConstIndex: Integer): Integer; function GetFunctionCount: Integer; public constructor Create(const AName: string); @@ -284,6 +306,8 @@ TGocciaFunctionTemplate = class const AConstIndex: Integer): PGocciaPropertyReadCacheEntry; {$IFDEF FPC}inline;{$ENDIF} function ProtoReadCacheSlot( const AConstIndex: Integer): PGocciaProtoReadCacheEntry; {$IFDEF FPC}inline;{$ENDIF} + function PropertyWriteCacheSlot( + const AConstIndex: Integer): PGocciaPropertyWriteCacheEntry; {$IFDEF FPC}inline;{$ENDIF} function AddFunction(const AFunction: TGocciaFunctionTemplate): UInt16; procedure AddUpvalueDescriptor(const AIsLocal: Boolean; const AIndex: UInt16; const AName: string = ''); @@ -823,6 +847,41 @@ function TGocciaFunctionTemplate.ProtoReadCacheSlot( Result := @FProtoReadCaches[Slot]; end; +function TGocciaFunctionTemplate.PropertyWriteSlot( + const AConstIndex: Integer): Integer; +var + NewCapacity: Integer; +begin + if (AConstIndex < 0) or (AConstIndex >= FConstantCount) then + Exit(-1); + if AConstIndex >= Length(FPropertyWriteSlotMap) then + SetLength(FPropertyWriteSlotMap, FConstantCount); + if FPropertyWriteSlotMap[AConstIndex] = 0 then + begin + if FPropertyWriteSlotCount >= Length(FPropertyWriteCaches) then + begin + NewCapacity := Length(FPropertyWriteCaches) * 2; + if NewCapacity < 4 then + NewCapacity := 4; + SetLength(FPropertyWriteCaches, NewCapacity); + end; + Inc(FPropertyWriteSlotCount); + FPropertyWriteSlotMap[AConstIndex] := UInt32(FPropertyWriteSlotCount); + end; + Result := Integer(FPropertyWriteSlotMap[AConstIndex]) - 1; +end; + +function TGocciaFunctionTemplate.PropertyWriteCacheSlot( + const AConstIndex: Integer): PGocciaPropertyWriteCacheEntry; +var + Slot: Integer; +begin + Slot := PropertyWriteSlot(AConstIndex); + if Slot < 0 then + Exit(nil); + Result := @FPropertyWriteCaches[Slot]; +end; + function TGocciaFunctionTemplate.AddFunction( const AFunction: TGocciaFunctionTemplate): UInt16; begin diff --git a/source/units/Goccia.VM.pas b/source/units/Goccia.VM.pas index c58b4c406..fd6f72339 100644 --- a/source/units/Goccia.VM.pas +++ b/source/units/Goccia.VM.pas @@ -1652,8 +1652,13 @@ function VMTrySetOwnWritableDataProperty(const AObject: TGocciaObjectValue; // OrdinarySetWithOwnDescriptor with Receiver = O. Exact class checks keep // exotic/overridden assignment semantics on the virtual fallback, while // exact descriptor checks keep lazy properties on their materializing path. + // TGocciaInstanceValue and TGocciaVMLiteralObjectValue share that ordinary + // own writable-data store (their AssignProperty overrides still handle + // accessors, inherited non-writable, and creation). Result := Assigned(AObject) and - (AObject.ClassType = TGocciaObjectValue) and + ((AObject.ClassType = TGocciaObjectValue) or + (AObject.ClassType = TGocciaVMLiteralObjectValue) or + (AObject.ClassType = TGocciaInstanceValue)) and AObject.Properties.TryGetValue(AName, Descriptor) and (Descriptor.ClassType = TGocciaPropertyDescriptorData) and Descriptor.Writable; @@ -1756,10 +1761,12 @@ function VMValueToRegisterFast(const AValue: TGocciaValue): TGocciaRegister; {$I Result := RegisterObject(AValue); end; -// Receivers whose own plain-data property reads are ordinary map lookups, -// so the OP_GET_PROP_CONST inline cache may serve them without going -// through their virtual GetProperty path. Exact-class checks exclude every -// subclass with overridden lookup semantics (proxies, exotic objects). +// Receivers whose own plain-data property reads and own writable-data +// writes are ordinary map lookups, so the OP_GET_PROP_CONST / +// OP_SET_PROP_CONST inline caches may serve them without going through +// their virtual GetProperty / AssignProperty paths. Exact-class checks +// exclude every subclass with overridden lookup or assignment semantics +// (proxies, exotic objects). function VMPropertyReadCacheableReceiver(const AObject: TObject): Boolean; {$IFDEF FPC}inline;{$ENDIF} begin Result := (AObject.ClassType = TGocciaObjectValue) or @@ -1809,6 +1816,8 @@ function VMTryGetCachedOwnDataProperty(const AObject: TGocciaObjectValue; // is treated as megamorphic: the cache stops being rewritten and reads use // the uncached own-data fast path instead. PROPERTY_READ_CACHE_POLYMORPHIC_LIMIT = 16; + // Same saturation rule for OP_SET_PROP_CONST write-IC sites. + PROPERTY_WRITE_CACHE_POLYMORPHIC_LIMIT = 16; type @@ -1861,6 +1870,52 @@ procedure VMPrimeOwnPropertyCache(const AObject: TGocciaObjectValue; ACache^.EntryIndex := AEntryIndex; end; +// Validate a property-write inline cache entry against the receiver's shape +// and store through the live map. Same shape-identity contract as +// VMTryGetCachedOwnDataProperty; Writable is re-checked because freeze / +// defineProperty can clear it without changing layout. +function VMTrySetCachedOwnWritableDataProperty( + const AObject: TGocciaObjectValue; + const ACache: PGocciaPropertyWriteCacheEntry; + const AValue: TGocciaValue): Boolean; {$IFDEF FPC}inline;{$ENDIF} +var + Descriptor: TGocciaPropertyDescriptor; +begin + Result := (ACache^.Shape = Pointer( + TGocciaShapedPropertyMap(AObject.Properties).Shape)) and + (ACache^.Shape <> nil) and + AObject.Properties.TryGetValueAtEntry(ACache^.EntryIndex, Descriptor) and + (Descriptor.ClassType = TGocciaPropertyDescriptorData) and + Descriptor.Writable; + if Result then + begin + TGocciaPropertyDescriptorData(Descriptor).Value := AValue; + if ACache^.MissStreak <> 0 then + ACache^.MissStreak := 0; + end; +end; + +procedure VMPrimeOwnPropertyWriteCache(const AObject: TGocciaObjectValue; + const AEntryIndex: Integer; const ACache: PGocciaPropertyWriteCacheEntry); +var + ReceiverShape: TGocciaShape; +begin + ReceiverShape := TGocciaShapedPropertyMap(AObject.Properties).EnsureShape; + if ReceiverShape = DictionaryShapeSentinel then + begin + Inc(ACache^.MissStreak); + Exit; + end; + if (not Assigned(ReceiverShape)) or + (AEntryIndex >= ReceiverShape.Depth) then + Exit; + if (ACache^.Shape <> nil) and + (ACache^.Shape <> Pointer(ReceiverShape)) then + Inc(ACache^.MissStreak); + ACache^.Shape := Pointer(ReceiverShape); + ACache^.EntryIndex := AEntryIndex; +end; + // Presence probe for the holder level: pointer identity suffices — a // prefix shape's covered entries stay valid as the holder map grows, and // the descriptor is re-read by entry index on every hit. @@ -14337,6 +14392,7 @@ function TGocciaVM.ExecuteClosureRegistersInternal( GlobalReadCache: PGocciaGlobalReadCacheEntry; DebugLine, DebugColumn: Integer; PropertyReadCache: PGocciaPropertyReadCacheEntry; + PropertyWriteCache: PGocciaPropertyWriteCacheEntry; ProtoReadCache: PGocciaProtoReadCacheEntry; AttributeType: string; SpecifierString: string; @@ -15615,21 +15671,88 @@ // ES2022 §15.7.14: execute static block closure with this = class OP_SET_PROP_CONST: if (FRegisters[A].Kind = grkObject) and Assigned(FRegisters[A].ObjectValue) then begin - GlobalName := Template.GetConstantUnchecked(B).StringValue; RightValue := RegisterToValue(FRegisters[C]); if FRegisters[A].ObjectValue is TGocciaVMClassValue then SetBytecodeHomeObject(RightValue, RegisterToValue(FRegisters[A])); - if IsBytecodePrivateKey(GlobalName) then - SetPropertyValue(FRegisters[A].ObjectValue, GlobalName, RightValue) - else if FRegisters[A].ObjectValue is TGocciaVMLiteralObjectValue then + // Own writable-data write IC: (shape, entry index), same receiver + // gate as the read cache. Hits skip the name hash. Accessors, + // proxies, private fields, deletion, and non-writable descriptors + // fall through to SetPropertyValue / AssignProperty. + PropertyWriteCache := Template.PropertyWriteCacheSlot(B); + if not (Assigned(PropertyWriteCache) and + (PropertyWriteCache^.MissStreak < + PROPERTY_WRITE_CACHE_POLYMORPHIC_LIMIT) and + VMPropertyReadCacheableReceiver(FRegisters[A].ObjectValue) and + VMTrySetCachedOwnWritableDataProperty( + TGocciaObjectValue(FRegisters[A].ObjectValue), + PropertyWriteCache, RightValue)) then begin - if not TGocciaVMLiteralObjectValue(FRegisters[A].ObjectValue) - .TrySetLiteralDataPropertyFast(GlobalName, RightValue) then + GlobalName := Template.GetConstantUnchecked(B).StringValue; + if IsBytecodePrivateKey(GlobalName) then + SetPropertyValue(FRegisters[A].ObjectValue, GlobalName, RightValue) + else if VMPropertyReadCacheableReceiver(FRegisters[A].ObjectValue) then + begin + if Assigned(PropertyWriteCache) and + (PropertyWriteCache^.MissStreak < + PROPERTY_WRITE_CACHE_POLYMORPHIC_LIMIT) then + begin + case VMProbeOwnProperty( + TGocciaObjectValue(FRegisters[A].ObjectValue), GlobalName, + KeyIndex, PrivateDescriptor) of + oppData: + if (PrivateDescriptor.ClassType = + TGocciaPropertyDescriptorData) and + PrivateDescriptor.Writable then + begin + TGocciaPropertyDescriptorData(PrivateDescriptor).Value := + RightValue; + VMPrimeOwnPropertyWriteCache( + TGocciaObjectValue(FRegisters[A].ObjectValue), + KeyIndex, PropertyWriteCache); + end + else + SetPropertyValue(FRegisters[A].ObjectValue, GlobalName, + RightValue); + oppNonData: + begin + Inc(PropertyWriteCache^.MissStreak); + SetPropertyValue(FRegisters[A].ObjectValue, GlobalName, + RightValue); + end; + else + if FRegisters[A].ObjectValue is TGocciaVMLiteralObjectValue then + begin + if not TGocciaVMLiteralObjectValue( + FRegisters[A].ObjectValue) + .TrySetLiteralDataPropertyFast(GlobalName, RightValue) then + SetPropertyValue(FRegisters[A].ObjectValue, GlobalName, + RightValue); + end + else + SetPropertyValue(FRegisters[A].ObjectValue, GlobalName, + RightValue); + end; + end + else if not VMTrySetOwnWritableDataProperty( + TGocciaObjectValue(FRegisters[A].ObjectValue), GlobalName, + RightValue) then + begin + if FRegisters[A].ObjectValue is TGocciaVMLiteralObjectValue then + begin + if not TGocciaVMLiteralObjectValue(FRegisters[A].ObjectValue) + .TrySetLiteralDataPropertyFast(GlobalName, RightValue) then + SetPropertyValue(FRegisters[A].ObjectValue, GlobalName, + RightValue); + end + else + SetPropertyValue(FRegisters[A].ObjectValue, GlobalName, + RightValue); + end; + end + else SetPropertyValue(FRegisters[A].ObjectValue, GlobalName, RightValue); - end - else - SetPropertyValue(FRegisters[A].ObjectValue, GlobalName, RightValue); + end; end else SetPropertyValue(GetRegister(A), diff --git a/tests/language/expressions/member-access/property-write-after-redefinition.js b/tests/language/expressions/member-access/property-write-after-redefinition.js new file mode 100644 index 000000000..27374db04 --- /dev/null +++ b/tests/language/expressions/member-access/property-write-after-redefinition.js @@ -0,0 +1,153 @@ +/*--- +description: Repeated property writes through one call site observe accessors, deletion, prototype mutation, non-writable, and proxies +features: [Object.defineProperty, Object.freeze, Proxy, property-descriptors] +---*/ + +test("repeated writes through one site update an own writable data property", () => { + const obj = { x: 0 }; + const writeX = (o, value) => { + o.x = value; + }; + + writeX(obj, 1); + writeX(obj, 2); + expect(obj.x).toBe(2); + writeX(obj, 3); + expect(obj.x).toBe(3); +}); + +test("repeated writes through one site still invoke a later setter", () => { + const obj = { x: 1 }; + const writeX = (o, value) => { + o.x = value; + }; + const received = []; + + writeX(obj, 2); + writeX(obj, 3); + Object.defineProperty(obj, "x", { + set(value) { + received.push(value); + }, + get() { + return received[received.length - 1]; + }, + configurable: true, + }); + writeX(obj, 10); + writeX(obj, 11); + expect(received).toEqual([10, 11]); +}); + +test("repeated writes through one site observe deletion then re-addition", () => { + const obj = { x: "first" }; + const writeX = (o, value) => { + o.x = value; + }; + + writeX(obj, "second"); + expect(obj.x).toBe("second"); + delete obj.x; + expect(Object.prototype.hasOwnProperty.call(obj, "x")).toBe(false); + writeX(obj, "third"); + expect(obj.x).toBe("third"); + expect(Object.prototype.hasOwnProperty.call(obj, "x")).toBe(true); +}); + +test("own write through one site does not mask a later prototype setter", () => { + const proto = {}; + const writeX = (o, value) => { + o.x = value; + }; + const own = Object.create(proto); + own.x = "own"; + const received = []; + + writeX(own, "warm"); + delete own.x; + Object.defineProperty(proto, "x", { + set(value) { + received.push(value); + }, + configurable: true, + }); + writeX(own, "from setter"); + expect(received).toEqual(["from setter"]); + expect(Object.prototype.hasOwnProperty.call(own, "x")).toBe(false); +}); + +test("warmed write of a non-writable own data property throws TypeError", () => { + const obj = { x: 1 }; + const writeX = (o, value) => { + o.x = value; + }; + + writeX(obj, 2); + Object.defineProperty(obj, "x", { + value: 2, + writable: false, + configurable: true, + }); + expect(() => { + writeX(obj, 3); + }).toThrow(TypeError); + expect(obj.x).toBe(2); +}); + +test("warmed write after Object.freeze throws TypeError", () => { + const obj = { x: 1 }; + const writeX = (o, value) => { + o.x = value; + }; + + writeX(obj, 2); + Object.freeze(obj); + expect(() => { + writeX(obj, 3); + }).toThrow(TypeError); + expect(obj.x).toBe(2); +}); + +test("one site writing many ordinary receivers still traps a later proxy", () => { + const writeX = (o, value) => { + o.x = value; + }; + const log = []; + Array.from({ length: 64 }, (_, i) => ({ x: i })).forEach((o, i) => { + writeX(o, i + 1); + expect(o.x).toBe(i + 1); + }); + const target = { x: 0 }; + const proxy = new Proxy(target, { + set(t, prop, value) { + log.push(String(prop) + "=" + value); + t[prop] = value; + return true; + }, + }); + writeX(proxy, 99); + expect(log).toEqual(["x=99"]); + expect(target.x).toBe(99); +}); + +test("one site writing class instances updates per-instance fields", () => { + class Point { + x; + constructor(x) { + this.x = x; + } + } + const writeX = (o, value) => { + o.x = value; + }; + const a = new Point(1); + const b = new Point(2); + + writeX(a, 10); + writeX(b, 20); + expect(a.x).toBe(10); + expect(b.x).toBe(20); + writeX(a, 11); + expect(a.x).toBe(11); + expect(b.x).toBe(20); +}); From ddcd4b40ca7cd692cbba8761d811809c7541d918 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 25 Aug 2026 21:41:19 +0100 Subject: [PATCH 08/16] docs(agent): record accepted OP_ADD_NUM_IMM merge Combined re-measure stayed faster than the wave baseline; next free opcode is 231 so later format bumps do not collide. Co-authored-by: Cursor --- .agent/HANDOFF.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/.agent/HANDOFF.md b/.agent/HANDOFF.md index 652fa77af..c856cb1a4 100644 --- a/.agent/HANDOFF.md +++ b/.agent/HANDOFF.md @@ -1,12 +1,14 @@ # Handoff -Updated: 2026-08-25 (hot-dispatch-extract rejected) +Updated: 2026-08-25 (OP_ADD_NUM_IMM accepted) ## Experiment - **Goal:** Goccia bytecode at 0.6×–0.8× of QuickJS *speed* (AWFY `goccia_over_qjs` ≈ 1.25–1.67). - **Current main CI** (`f4d403f0`, linux/x64 Azure): AWFY geomean `goccia_over_qjs` = **16.256** (speed **0.062×**). Need ~10–13×. -- **Delivery branch:** `perf/bytecode-quickjs-gap` @ `3a2e2949` (skill adoption on `origin/main` `f4d403f0`). +- **Delivery branch:** `perf/bytecode-quickjs-gap` @ `d099bdf9` (skill adoption on `origin/main` `f4d403f0`; first accepted code merge). +- **Accepted code:** `ad0023da` `OP_ADD_NUM_IMM` (opcode **230**, format **v78**). Next free opcode **231**; `optimize/get-local-prop` must take 231 and bump format to v79 if it lands. +- **Combined candidate binary:** `/tmp/goccia-combined-d099bdf9`. - **Baseline binary:** `/tmp/goccia-baseline-f4d403f0` (`--prod` loader, darwin/aarch64, FPC 3.2.2). - **QuickJS:** 2026-06-04 at `/tmp/quickjs/bin/qjs`. - **Lock:** `/tmp/gocciascript-perf-gate.lock`. @@ -38,13 +40,28 @@ Fib is the best relative row because it already uses `OP_CALL_SELF_NUM` / `OP_SU Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, Towers 18.91, Richards 16.21. Mandelbrot 5.58 (best). +## Accepted this wave + +- **`optimize/add-num-imm`** `ad0023da`, merged at `d099bdf9`. `OP_ADD_NUM_IMM` fuses proven `Number + Int16` (including `1 + i`) the same way `OP_SUB_NUM_IMM` does. Checksums matched. Isolated report: `/tmp/lane-add-num-imm-ab.json`. Combined re-measure vs `/tmp/goccia-baseline-f4d403f0` (7 interleaved reps, `/tmp/combined-add-num-imm-ab.json`): + + | Probe | Base µs | Combined µs | Ratio | + | --- | ---: | ---: | ---: | + | loop-dispatch-floor | 126029 | 124766 | 0.990 | + | generic-plus-scalars | 63284 | 59013 | 0.933 | + | nbody-minimal | 67202 | 65455 | 0.974 | + | fib-recursive | 45487 | 44952 | 0.988 | + + Geomean combined/base **0.971**. Isolated lane had loop-dispatch-floor 0.967 / generic-plus-scalars 0.970. AWFY `i++` loops are not expected to hit this opcode; transfer is the `i + K` / `1 + i` probes. Treat `d099bdf9` as the next combined baseline for later merges — do not add isolated percentages. + + Overlap: `optimize/inc-assign` still owns whole-assignment `id = id + 1` → `OP_INC`. `OP_ADD_NUM_IMM` fires on the binary `+` expression. If both land, keep assignment matching first so `i = i + 1` stays one increment rather than add-immediate plus store. + ## Lanes launching 1. `optimize/inc-assign` — compile `id = id + 1` as existing `OP_INC`. 2. `optimize/int-literals` — integer-valued number literals as `sltInteger`. -3. `optimize/add-num-imm` — `OP_ADD_NUM_IMM` mirroring `OP_SUB_NUM_IMM`. +3. `optimize/add-num-imm` — **accepted** (see above). 4. `optimize/hot-dispatch-extract` — **rejected** (see below). -5. `optimize/get-local-prop` — fuse `GET_LOCAL` + `GET_PROP_CONST`. +5. `optimize/get-local-prop` — fuse `GET_LOCAL` + `GET_PROP_CONST` (must use opcode **231** / format **v79** if it lands after this merge). 6. `optimize/write-ic` — own writable-data write IC (ADR 0088 leftover; requires AWFY transfer). 7. `optimize/counted-for-assign` — widen `TryCompileCountedFor` to `i = i + 1`. From 8e3ed9251a987c8a1e30299ccb9c0a88415e6151 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 25 Aug 2026 21:43:09 +0100 Subject: [PATCH 09/16] docs(agent): record accepted numeric i = i + 1 increment merge Combined re-measure still beats the ADD_NUM_IMM head on the dispatch floor; assignment matching keeps the increment ahead of add-immediate. Co-authored-by: Cursor --- .agent/HANDOFF.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/.agent/HANDOFF.md b/.agent/HANDOFF.md index c856cb1a4..56af64a6b 100644 --- a/.agent/HANDOFF.md +++ b/.agent/HANDOFF.md @@ -1,14 +1,14 @@ # Handoff -Updated: 2026-08-25 (OP_ADD_NUM_IMM accepted) +Updated: 2026-08-25 (inc-assign accepted on top of ADD_NUM_IMM) ## Experiment - **Goal:** Goccia bytecode at 0.6×–0.8× of QuickJS *speed* (AWFY `goccia_over_qjs` ≈ 1.25–1.67). - **Current main CI** (`f4d403f0`, linux/x64 Azure): AWFY geomean `goccia_over_qjs` = **16.256** (speed **0.062×**). Need ~10–13×. -- **Delivery branch:** `perf/bytecode-quickjs-gap` @ `d099bdf9` (skill adoption on `origin/main` `f4d403f0`; first accepted code merge). -- **Accepted code:** `ad0023da` `OP_ADD_NUM_IMM` (opcode **230**, format **v78**). Next free opcode **231**; `optimize/get-local-prop` must take 231 and bump format to v79 if it lands. -- **Combined candidate binary:** `/tmp/goccia-combined-d099bdf9`. +- **Delivery branch:** `perf/bytecode-quickjs-gap` @ `1c2e0412` (skill adoption on `origin/main` `f4d403f0`). +- **Accepted code:** `ad0023da` `OP_ADD_NUM_IMM` (opcode **230**, format **v78**); `315bfd28` numeric `i = i + 1` → `OP_INC_NUMERIC`. Next free opcode **231**; `optimize/get-local-prop` must take 231 and bump format to v79 if it lands. +- **Combined candidate binary:** `/tmp/goccia-combined-1c2e0412`. Previous combined head: `/tmp/goccia-combined-d099bdf9`. - **Baseline binary:** `/tmp/goccia-baseline-f4d403f0` (`--prod` loader, darwin/aarch64, FPC 3.2.2). - **QuickJS:** 2026-06-04 at `/tmp/quickjs/bin/qjs`. - **Lock:** `/tmp/gocciascript-perf-gate.lock`. @@ -53,11 +53,22 @@ Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, To Geomean combined/base **0.971**. Isolated lane had loop-dispatch-floor 0.967 / generic-plus-scalars 0.970. AWFY `i++` loops are not expected to hit this opcode; transfer is the `i + K` / `1 + i` probes. Treat `d099bdf9` as the next combined baseline for later merges — do not add isolated percentages. - Overlap: `optimize/inc-assign` still owns whole-assignment `id = id + 1` → `OP_INC`. `OP_ADD_NUM_IMM` fires on the binary `+` expression. If both land, keep assignment matching first so `i = i + 1` stays one increment rather than add-immediate plus store. + Overlap with inc-assign is resolved: assignment matching runs first, so `i = i + 1` emits `OP_INC_NUMERIC` rather than add-immediate plus store. `OP_ADD_NUM_IMM` still covers `i + K` for K ≠ 1 and non-assignment uses. + +- **`optimize/inc-assign`** `315bfd28`, merged at `1c2e0412`. Proven-numeric `id = id + 1` / `id = 1 + id` emits `OP_INC_NUMERIC`. Isolated vs original baseline: loop-dispatch-floor 0.930. Combined re-measure vs `/tmp/goccia-combined-d099bdf9` (7 interleaved reps, `/tmp/combined-inc-assign-ab.json`): + + | Probe | Prev µs | Combined µs | Ratio | + | --- | ---: | ---: | ---: | + | loop-dispatch-floor | 117529 | 113682 | 0.967 | + | generic-plus-scalars | 57182 | 56515 | 0.988 | + | nbody-minimal | 65105 | 65914 | 1.012 | + | fib-recursive | 44025 | 45288 | 1.029 | + + Geomean combined/prev **0.999** (fib/nbody overlap noise). Target still faster after ADD_NUM_IMM; checksums matched. Treat `1c2e0412` as the next combined baseline. ## Lanes launching -1. `optimize/inc-assign` — compile `id = id + 1` as existing `OP_INC`. +1. `optimize/inc-assign` — **accepted** (see above). 2. `optimize/int-literals` — integer-valued number literals as `sltInteger`. 3. `optimize/add-num-imm` — **accepted** (see above). 4. `optimize/hot-dispatch-extract` — **rejected** (see below). From e6f3e177021c140905ad9c3954501ece2f747132 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 25 Aug 2026 21:43:27 +0100 Subject: [PATCH 10/16] perf(bytecode): compile counted for-loops with i = i + 1 as integer step Co-authored-by: Cursor --- source/units/Goccia.Compiler.Statements.pas | 227 +++++++++++++++++--- tests/language/for-loop/basic-counter.js | 77 +++++++ 2 files changed, 270 insertions(+), 34 deletions(-) diff --git a/source/units/Goccia.Compiler.Statements.pas b/source/units/Goccia.Compiler.Statements.pas index 0595b7b97..19244a72a 100644 --- a/source/units/Goccia.Compiler.Statements.pas +++ b/source/units/Goccia.Compiler.Statements.pas @@ -3175,6 +3175,158 @@ function ExpressionNeedsPerIterationEnvironment( ExpressionCreatesClosureBoundary(AExpr); end; +function IsUnitNumberLiteral(const AExpr: TGocciaExpression): Boolean; +var + NumberValue: Double; +begin + Result := False; + if not Assigned(AExpr) or not (AExpr is TGocciaLiteralExpression) or + not (TGocciaLiteralExpression(AExpr).Value is TGocciaNumberLiteralValue) then + Exit; + NumberValue := TGocciaNumberLiteralValue( + TGocciaLiteralExpression(AExpr).Value).Value; + Result := NumberValue = 1; +end; + +function CountedForLimitHasNumberProof(const AScope: TGocciaCompilerScope; + const AExpr: TGocciaExpression): Boolean; +var + LocalIndex: Integer; +begin + if IsKnownNumeric(ExpressionType(AScope, AExpr)) then + Exit(True); + if AExpr is TGocciaIdentifierExpression then + begin + LocalIndex := AScope.ResolveLocal( + TGocciaIdentifierExpression(AExpr).Name); + if LocalIndex >= 0 then + Exit(AScope.GetLocal(LocalIndex).IsCallProvenNumeric); + end; + Result := False; +end; + +function TryMatchCountedForStep(const AUpdate: TGocciaExpression; + const ALoopName: string; const AIsAscending: Boolean; + out AStepOpcode: TGocciaOpCode): Boolean; +var + IncExpr: TGocciaIncrementExpression; + Assign: TGocciaAssignmentExpression; + Compound: TGocciaCompoundAssignmentExpression; + Binary: TGocciaBinaryExpression; + ExpectedInc: TGocciaTokenType; + ExpectedBinary: TGocciaTokenType; + ExpectedCompound: TGocciaTokenType; +begin + Result := False; + if AIsAscending then + begin + ExpectedInc := gttIncrement; + ExpectedBinary := gttPlus; + ExpectedCompound := gttPlusAssign; + AStepOpcode := OP_ADD_INT; + end + else + begin + ExpectedInc := gttDecrement; + ExpectedBinary := gttMinus; + ExpectedCompound := gttMinusAssign; + AStepOpcode := OP_SUB_INT; + end; + + if AUpdate is TGocciaIncrementExpression then + begin + IncExpr := TGocciaIncrementExpression(AUpdate); + if not (IncExpr.Operand is TGocciaIdentifierExpression) then + Exit; + if TGocciaIdentifierExpression(IncExpr.Operand).Name <> ALoopName then + Exit; + if IncExpr.Operator <> ExpectedInc then + Exit; + Exit(True); + end; + + if AUpdate is TGocciaAssignmentExpression then + begin + Assign := TGocciaAssignmentExpression(AUpdate); + if Assign.Name <> ALoopName then + Exit; + if not (Assign.Value is TGocciaBinaryExpression) then + Exit; + Binary := TGocciaBinaryExpression(Assign.Value); + if Binary.Operator <> ExpectedBinary then + Exit; + if not (Binary.Left is TGocciaIdentifierExpression) then + Exit; + if TGocciaIdentifierExpression(Binary.Left).Name <> ALoopName then + Exit; + if not IsUnitNumberLiteral(Binary.Right) then + Exit; + Exit(True); + end; + + if AUpdate is TGocciaCompoundAssignmentExpression then + begin + Compound := TGocciaCompoundAssignmentExpression(AUpdate); + if Compound.Name <> ALoopName then + Exit; + if Compound.Operator <> ExpectedCompound then + Exit; + if not IsUnitNumberLiteral(Compound.Value) then + Exit; + Exit(True); + end; +end; + +function TryMatchCountedForLimit(const ACtx: TGocciaCompilationContext; + const ALimit: TGocciaExpression; const ABody: TGocciaASTNode; + const ALoopName: string; out AUseIntCompare: Boolean): Boolean; +var + LimitIdent: TGocciaIdentifierExpression; + LocalIndex: Integer; + LimitLocal: TGocciaCompilerLocal; +begin + Result := False; + AUseIntCompare := False; + + if ALimit is TGocciaLiteralExpression then + begin + if not (TGocciaLiteralExpression(ALimit).Value is TGocciaNumberLiteralValue) then + Exit; + if Frac(TGocciaNumberLiteralValue( + TGocciaLiteralExpression(ALimit).Value).Value) <> 0 then + Exit; + AUseIntCompare := True; + Exit(True); + end; + + if not (ALimit is TGocciaIdentifierExpression) then + Exit; + LimitIdent := TGocciaIdentifierExpression(ALimit); + if LimitIdent.Name = ALoopName then + Exit; + LocalIndex := ACtx.Scope.ResolveLocal(LimitIdent.Name); + if LocalIndex < 0 then + Exit; + LimitLocal := ACtx.Scope.GetLocal(LocalIndex); + // ES2026 §14.7.4.4 evaluates the test each iteration. Snapshotting LimitReg + // is valid only when the binding cannot change during the loop. Direct + // writes are rejected; mutable captured bindings and bodies that create + // closures (which can assign through the capture) fall back too. const + // bindings cannot be assigned, so they remain snapshot-safe even when the + // body creates closures that capture the loop index. + if ForBodyAssignsIdentifier(ABody, LimitIdent.Name) then + Exit; + if not LimitLocal.IsConst then + begin + if LimitLocal.IsCaptured then + Exit; + if StatementNeedsPerIterationEnvironment(ABody) then + Exit; + end; + AUseIntCompare := CountedForLimitHasNumberProof(ACtx.Scope, ALimit); + Result := True; +end; + function TryCompileCountedFor(const ACtx: TGocciaCompilationContext; const AStmt: TGocciaForStatement): Boolean; var @@ -3186,8 +3338,6 @@ function TryCompileCountedFor(const ACtx: TGocciaCompilationContext; StartInt: Integer; CondExpr: TGocciaBinaryExpression; CondLeftIdent: TGocciaIdentifierExpression; - IncExpr: TGocciaIncrementExpression; - IncOperandIdent: TGocciaIdentifierExpression; StartReg, LimitReg, OneReg, CmpReg: UInt16; Slot, OuterSlot: UInt16; LoopStart, ExitJump, I: Integer; @@ -3195,7 +3345,8 @@ function TryCompileCountedFor(const ACtx: TGocciaCompilationContext; ClosedCount: Integer; LoopControl: TLoopControlState; IsAscending: Boolean; - ExitOpcode, StepOpcode: TGocciaOpCode; + UseIntCompare: Boolean; + ExitOpcode, ExitJumpOp, StepOpcode: TGocciaOpCode; begin Result := False; @@ -3243,45 +3394,53 @@ function TryCompileCountedFor(const ACtx: TGocciaCompilationContext; if CondLeftIdent.Name <> LoopName then Exit; // ES2026 §14.7.4.4 evaluates the test expression each iteration. The fast - // path snapshots LimitReg once before the loop, so anything that can change - // between iterations would diverge from the spec. Restrict to integer-valued - // numeric literals only — `ForBodyAssignsIdentifier` doesn't see writes - // through IIFEs/callbacks/property setters, so a bare-identifier RHS is - // unsafe; and the emitted compare uses OP_GTE_INT/OP_LTE_INT, so a - // non-integer literal like `i < 3.5` would round in surprising ways - // relative to the spec's IEEE 754 compare. - if not (CondExpr.Right is TGocciaLiteralExpression) then - Exit; - if not (TGocciaLiteralExpression(CondExpr.Right).Value is TGocciaNumberLiteralValue) then - Exit; - if Frac(TGocciaNumberLiteralValue( - TGocciaLiteralExpression(CondExpr.Right).Value).Value) <> 0 then + // path snapshots LimitReg once before the loop. Integer-valued numeric + // literals are immutable. Identifier limits are accepted only when the + // binding is snapshot-safe (see TryMatchCountedForLimit). Proven Number + // limits use OP_GTE_INT/OP_LTE_INT; untyped stable locals use generic + // OP_LT/OP_GT so mixed BigInt comparison keeps spec TypeError/compare + // behavior instead of RegisterToDouble. + if not TryMatchCountedForLimit(ACtx, CondExpr.Right, AStmt.Body, LoopName, + UseIntCompare) then Exit; case CondExpr.Operator of - gttLess: begin IsAscending := True; ExitOpcode := OP_GTE_INT; end; - gttGreater: begin IsAscending := False; ExitOpcode := OP_LTE_INT; end; + gttLess: + begin + IsAscending := True; + if UseIntCompare then + begin + ExitOpcode := OP_GTE_INT; + ExitJumpOp := OP_JUMP_IF_TRUE; + end + else + begin + ExitOpcode := OP_LT; + ExitJumpOp := OP_JUMP_IF_FALSE; + end; + end; + gttGreater: + begin + IsAscending := False; + if UseIntCompare then + begin + ExitOpcode := OP_LTE_INT; + ExitJumpOp := OP_JUMP_IF_TRUE; + end + else + begin + ExitOpcode := OP_GT; + ExitJumpOp := OP_JUMP_IF_FALSE; + end; + end; else Exit; end; - if not Assigned(AStmt.Update) or - not (AStmt.Update is TGocciaIncrementExpression) then - Exit; - IncExpr := TGocciaIncrementExpression(AStmt.Update); - if not (IncExpr.Operand is TGocciaIdentifierExpression) then - Exit; - IncOperandIdent := TGocciaIdentifierExpression(IncExpr.Operand); - if IncOperandIdent.Name <> LoopName then + if not Assigned(AStmt.Update) then Exit; - if IsAscending and (IncExpr.Operator <> gttIncrement) then + if not TryMatchCountedForStep(AStmt.Update, LoopName, IsAscending, StepOpcode) then Exit; - if (not IsAscending) and (IncExpr.Operator <> gttDecrement) then - Exit; - if IsAscending then - StepOpcode := OP_ADD_INT - else - StepOpcode := OP_SUB_INT; if ForBodyAssignsIdentifier(AStmt.Body, LoopName) then Exit; @@ -3310,7 +3469,7 @@ function TryCompileCountedFor(const ACtx: TGocciaCompilationContext; LoopStart := CurrentCodePosition(ACtx); EmitInstruction(ACtx, EncodeABC(ExitOpcode, CmpReg, StartReg, LimitReg)); - ExitJump := EmitJumpInstruction(ACtx, OP_JUMP_IF_TRUE, CmpReg); + ExitJump := EmitJumpInstruction(ACtx, ExitJumpOp, CmpReg); OuterSlot := StartReg; ACtx.Scope.BeginScope; diff --git a/tests/language/for-loop/basic-counter.js b/tests/language/for-loop/basic-counter.js index f88b7cf3c..eecd9ed17 100644 --- a/tests/language/for-loop/basic-counter.js +++ b/tests/language/for-loop/basic-counter.js @@ -9,12 +9,89 @@ test("counts up", () => { expect(result).toEqual([0, 1, 2, 3, 4]); }); +test("counts up with assign-add", () => { + const result = []; + for (let i = 0; i < 5; i = i + 1) result.push(i); + expect(result).toEqual([0, 1, 2, 3, 4]); +}); + +test("counts up with plus-assign", () => { + const result = []; + for (let i = 0; i < 5; i += 1) result.push(i); + expect(result).toEqual([0, 1, 2, 3, 4]); +}); + test("counts down", () => { const result = []; for (let i = 5; i > 0; i--) result.push(i); expect(result).toEqual([5, 4, 3, 2, 1]); }); +test("counts down with assign-subtract", () => { + const result = []; + for (let i = 5; i > 0; i = i - 1) result.push(i); + expect(result).toEqual([5, 4, 3, 2, 1]); +}); + +test("counts down with minus-assign", () => { + const result = []; + for (let i = 5; i > 0; i -= 1) result.push(i); + expect(result).toEqual([5, 4, 3, 2, 1]); +}); + +test("assign-add matches increment with identifier limit", () => { + const n = 5; + const incremented = []; + for (let i = 0; i < n; i++) incremented.push(i); + const assigned = []; + for (let i = 0; i < n; i = i + 1) assigned.push(i); + expect(assigned).toEqual(incremented); + expect(assigned).toEqual([0, 1, 2, 3, 4]); +}); + +test("assign-add with parameter limit", () => { + const run = (n) => { + const result = []; + for (let i = 0; i < n; i = i + 1) result.push(i); + return result; + }; + expect(run(4)).toEqual([0, 1, 2, 3]); +}); + +test("body write to counter matches increment without integer step", () => { + const incremented = []; + for (let i = 0; i < 5; i++) { + incremented.push(i); + if (i === 2) i = 10; + } + const assigned = []; + for (let i = 0; i < 5; i = i + 1) { + assigned.push(i); + if (i === 2) i = 10; + } + expect(assigned).toEqual(incremented); + expect(assigned).toEqual([0, 1, 2]); +}); + +test("body string write uses addition not integer step", () => { + const result = []; + for (let i = 0; i < 5; i = i + 1) { + result.push(i); + if (i === 1) i = "x"; + } + expect(result).toEqual([0, 1]); +}); + +test("mutating the limit in the body is visible", () => { + let n = 5; + const result = []; + for (let i = 0; i < n; i = i + 1) { + result.push(i); + if (i === 1) n = 2; + } + expect(result).toEqual([0, 1]); +}); + test("step by 2", () => { const result = []; for (let i = 0; i < 10; i += 2) result.push(i); From bc93d1951049dbef6b411e3dac6dd516b2186a46 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 25 Aug 2026 21:44:30 +0100 Subject: [PATCH 11/16] docs(agent): record accepted write-IC merge Combined AWFY still transfers on Richards, Bounce, and Storage against the inc-assign head; the dispatch-floor guard stayed flat. Co-authored-by: Cursor --- .agent/HANDOFF.md | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/.agent/HANDOFF.md b/.agent/HANDOFF.md index 56af64a6b..497ea09d1 100644 --- a/.agent/HANDOFF.md +++ b/.agent/HANDOFF.md @@ -1,14 +1,14 @@ # Handoff -Updated: 2026-08-25 (inc-assign accepted on top of ADD_NUM_IMM) +Updated: 2026-08-25 (write-IC accepted on top of inc-assign) ## Experiment - **Goal:** Goccia bytecode at 0.6×–0.8× of QuickJS *speed* (AWFY `goccia_over_qjs` ≈ 1.25–1.67). - **Current main CI** (`f4d403f0`, linux/x64 Azure): AWFY geomean `goccia_over_qjs` = **16.256** (speed **0.062×**). Need ~10–13×. -- **Delivery branch:** `perf/bytecode-quickjs-gap` @ `1c2e0412` (skill adoption on `origin/main` `f4d403f0`). -- **Accepted code:** `ad0023da` `OP_ADD_NUM_IMM` (opcode **230**, format **v78**); `315bfd28` numeric `i = i + 1` → `OP_INC_NUMERIC`. Next free opcode **231**; `optimize/get-local-prop` must take 231 and bump format to v79 if it lands. -- **Combined candidate binary:** `/tmp/goccia-combined-1c2e0412`. Previous combined head: `/tmp/goccia-combined-d099bdf9`. +- **Delivery branch:** `perf/bytecode-quickjs-gap` @ `0293229e` (skill adoption on `origin/main` `f4d403f0`). +- **Accepted code:** `ad0023da` `OP_ADD_NUM_IMM` (opcode **230**, format **v78**); `315bfd28` numeric `i = i + 1` → `OP_INC_NUMERIC`; `15e8eca7` own writable write IC. Next free opcode **231**; `optimize/get-local-prop` must take 231 and bump format to v79 if it lands. +- **Combined candidate binary:** `/tmp/goccia-combined-0293229e`. Previous combined heads: `/tmp/goccia-combined-1c2e0412` (inc-assign), `/tmp/goccia-combined-d099bdf9` (ADD_NUM_IMM). - **Baseline binary:** `/tmp/goccia-baseline-f4d403f0` (`--prod` loader, darwin/aarch64, FPC 3.2.2). - **QuickJS:** 2026-06-04 at `/tmp/quickjs/bin/qjs`. - **Lock:** `/tmp/gocciascript-perf-gate.lock`. @@ -32,7 +32,7 @@ Fib is the best relative row because it already uses `OP_CALL_SELF_NUM` / `OP_SU ## Profile facts (function-wrapped equivalents) -- `loop-dispatch-floor`: 38% `OP_GET_LOCAL`, 15% `OP_LOAD_INT`, 11% `OP_SET_LOCAL`, 8% `OP_ADD_FLOAT`. Loop compare is generic `OP_LT`. Increment is `i = i + 1` (not `++`), so existing `OP_INC` is unused. Number literals type as `sltFloat` (`ExpressionType` in `Goccia.Compiler.Statements.pas`). +- `loop-dispatch-floor`: 38% `OP_GET_LOCAL`, 15% `OP_LOAD_INT`, 11% `OP_SET_LOCAL`, 8% `OP_ADD_FLOAT` on the original baseline. Increment `i = i + 1` now compiles as `OP_INC_NUMERIC` (`315bfd28`); compare is still generic `OP_LT`. Number literals type as `sltFloat` (`ExpressionType` in `Goccia.Compiler.Statements.pas`). - `nbody-minimal`: 31% `OP_GET_LOCAL`, 12% `OP_GET_PROP_CONST`, 10% `OP_LOAD_HOLE`, 8% `OP_MOVE`. Hot pair `GET_LOCAL → GET_PROP_CONST` (11%). Generic `OP_MUL`/`OP_ADD` with 100% scalar hit rate. - Script-level `let` in a non-function profiled as `OP_GET_GLOBAL` (29% of opcodes) — not the AWFY/probe shape. @@ -66,6 +66,18 @@ Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, To Geomean combined/prev **0.999** (fib/nbody overlap noise). Target still faster after ADD_NUM_IMM; checksums matched. Treat `1c2e0412` as the next combined baseline. +- **`optimize/write-ic`** `15e8eca7`, merged at `0293229e`. Shape + entry-index write IC for own writable data on `OP_SET_PROP_CONST`; semantic misses still use `AssignProperty`. Isolated vs original baseline: Richards +21.14% speed, Bounce +8.42%, Storage +4.96%. Combined re-measure vs `/tmp/goccia-combined-1c2e0412` (7 interleaved reps, `/tmp/combined-write-ic-ab.json`): + + | Target | Prev | Combined | Time ratio | Speed | + | --- | ---: | ---: | ---: | ---: | + | Richards | 396.332 ms | 307.419 ms | 0.776 | +28.9% | + | Bounce | 8.892 ms | 7.698 ms | 0.866 | +15.5% | + | Storage | 14.663 ms | 13.882 ms | 0.947 | +5.6% | + | loop-dispatch-floor | 114.154 ms | 113.167 ms | 0.991 | flat | + | propaccess-monomorphic | 16.467 ms | 14.238 ms | 0.865 | +15.7% | + + Geomean combined/prev **0.886**. Checksums matched. Do not bundle with read-PIC (ADR 0088). Treat `0293229e` as the next combined baseline. + ## Lanes launching 1. `optimize/inc-assign` — **accepted** (see above). @@ -73,7 +85,7 @@ Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, To 3. `optimize/add-num-imm` — **accepted** (see above). 4. `optimize/hot-dispatch-extract` — **rejected** (see below). 5. `optimize/get-local-prop` — fuse `GET_LOCAL` + `GET_PROP_CONST` (must use opcode **231** / format **v79** if it lands after this merge). -6. `optimize/write-ic` — own writable-data write IC (ADR 0088 leftover; requires AWFY transfer). +6. `optimize/write-ic` — **accepted** (see above). 7. `optimize/counted-for-assign` — widen `TryCompileCountedFor` to `i = i + 1`. ## Investigation conclusions (do not contradict) @@ -81,9 +93,9 @@ Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, To - **NaN-box / tagged-pointer rewrite:** out of this wave (`TGocciaRegister` is a 16-byte fat union by design). - **Numeric loops already unboxed** on the generic scalar arm; remaining tax is dispatch + property-boundary boxing on store. - **Broader read-PIC:** still rejected (ADR 0088). Own+proto read ICs already ship. -- **Write-IC:** unimplemented; prior isolated 30× AWFY was Richards +8%, Bounce +5%, Storage +3%. Re-measure interleaved; do not bundle with read-PIC. -- **`OP_SET_PROP_CONST`** still calls full `AssignProperty` except literal-object fast path; `VMTrySetOwnWritableDataProperty` exists but is unused there. -- **Counted-for** only matches `i++`, so AWFY/probe `i = i + 1` misses `OP_ADD_INT` loop microcode. +- **Write-IC:** landed this wave (`15e8eca7`). Own writable-data stores on `OP_SET_PROP_CONST` hit a shape-keyed IC; misses still go through `AssignProperty`. Broader read-PIC remains rejected (ADR 0088). +- **`OP_SET_PROP_CONST`** uses the write IC for ordinary own writable data; `VMTrySetOwnWritableDataProperty` remains available for non-IC paths. +- **Counted-for** only matches `i++`, so AWFY/probe `i = i + 1` still misses `OP_ADD_INT` loop microcode. Assignment `i = i + 1` now emits `OP_INC_NUMERIC` outside that counted-for path. - **CALL:** bytecode→bytecode already trampolines; `ExecuteClosureRegisters0–3` are native ingress only. Revisit `OP_CALL_METHOD` staging only with AWFY transfer (ADR 0089 previously noise). - **Dispatch preamble:** ~10–15 predictable cold branches per opcode. A remaining DISPATCH idea is a **prod vs instrumented dual loop** that keeps one `case` and only strips coverage/profiler/`AStopAtIP` on the measured path — not a hot/cold case split. - **ALLOC:** do not revive value caches. Property-store `RegisterToValue` boxing is the live allocation tax. From 484af903b913c92d28700e246459227b7072eb0c Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 25 Aug 2026 21:46:29 +0100 Subject: [PATCH 12/16] docs(agent): record accepted counted-for i = i + 1 merge Combined remaining win on the dispatch floor is smaller than the isolated lane because increment assignment already landed first. Co-authored-by: Cursor --- .agent/HANDOFF.md | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/.agent/HANDOFF.md b/.agent/HANDOFF.md index 497ea09d1..b50f8ef78 100644 --- a/.agent/HANDOFF.md +++ b/.agent/HANDOFF.md @@ -1,14 +1,14 @@ # Handoff -Updated: 2026-08-25 (write-IC accepted on top of inc-assign) +Updated: 2026-08-25 (counted-for i=i+1 accepted) ## Experiment - **Goal:** Goccia bytecode at 0.6×–0.8× of QuickJS *speed* (AWFY `goccia_over_qjs` ≈ 1.25–1.67). - **Current main CI** (`f4d403f0`, linux/x64 Azure): AWFY geomean `goccia_over_qjs` = **16.256** (speed **0.062×**). Need ~10–13×. -- **Delivery branch:** `perf/bytecode-quickjs-gap` @ `0293229e` (skill adoption on `origin/main` `f4d403f0`). -- **Accepted code:** `ad0023da` `OP_ADD_NUM_IMM` (opcode **230**, format **v78**); `315bfd28` numeric `i = i + 1` → `OP_INC_NUMERIC`; `15e8eca7` own writable write IC. Next free opcode **231**; `optimize/get-local-prop` must take 231 and bump format to v79 if it lands. -- **Combined candidate binary:** `/tmp/goccia-combined-0293229e`. Previous combined heads: `/tmp/goccia-combined-1c2e0412` (inc-assign), `/tmp/goccia-combined-d099bdf9` (ADD_NUM_IMM). +- **Delivery branch:** `perf/bytecode-quickjs-gap` @ `7a31bc96` (skill adoption on `origin/main` `f4d403f0`). +- **Accepted code:** `ad0023da` `OP_ADD_NUM_IMM` (opcode **230**, format **v78**); `315bfd28` numeric `i = i + 1` → `OP_INC_NUMERIC`; `15e8eca7` own writable write IC; `e6f3e177` counted-for `i = i + 1` → `OP_ADD_INT`. Next free opcode **231**; `optimize/get-local-prop` must take 231 and bump format to v79 if it lands. +- **Combined candidate binary:** `/tmp/goccia-combined-7a31bc96`. Previous combined heads: `/tmp/goccia-combined-0293229e` (write-IC), `/tmp/goccia-combined-1c2e0412` (inc-assign), `/tmp/goccia-combined-d099bdf9` (ADD_NUM_IMM). - **Baseline binary:** `/tmp/goccia-baseline-f4d403f0` (`--prod` loader, darwin/aarch64, FPC 3.2.2). - **QuickJS:** 2026-06-04 at `/tmp/quickjs/bin/qjs`. - **Lock:** `/tmp/gocciascript-perf-gate.lock`. @@ -32,7 +32,7 @@ Fib is the best relative row because it already uses `OP_CALL_SELF_NUM` / `OP_SU ## Profile facts (function-wrapped equivalents) -- `loop-dispatch-floor`: 38% `OP_GET_LOCAL`, 15% `OP_LOAD_INT`, 11% `OP_SET_LOCAL`, 8% `OP_ADD_FLOAT` on the original baseline. Increment `i = i + 1` now compiles as `OP_INC_NUMERIC` (`315bfd28`); compare is still generic `OP_LT`. Number literals type as `sltFloat` (`ExpressionType` in `Goccia.Compiler.Statements.pas`). +- `loop-dispatch-floor`: original baseline was 38% `OP_GET_LOCAL`, 15% `OP_LOAD_INT`, 11% `OP_SET_LOCAL`, 8% `OP_ADD_FLOAT`. Counted-for now matches `i = i + 1` and emits `OP_ADD_INT` (`e6f3e177`); standalone assignment still uses `OP_INC_NUMERIC`. Compare stays generic `OP_LT` when the limit is an untyped parameter. Number literals type as `sltFloat`. - `nbody-minimal`: 31% `OP_GET_LOCAL`, 12% `OP_GET_PROP_CONST`, 10% `OP_LOAD_HOLE`, 8% `OP_MOVE`. Hot pair `GET_LOCAL → GET_PROP_CONST` (11%). Generic `OP_MUL`/`OP_ADD` with 100% scalar hit rate. - Script-level `let` in a non-function profiled as `OP_GET_GLOBAL` (29% of opcodes) — not the AWFY/probe shape. @@ -78,6 +78,17 @@ Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, To Geomean combined/prev **0.886**. Checksums matched. Do not bundle with read-PIC (ADR 0088). Treat `0293229e` as the next combined baseline. +- **`optimize/counted-for-assign`** `e6f3e177`, merged at `7a31bc96`. `TryCompileCountedFor` now matches `i = i + 1` / `i = i - 1` and `i += 1` / `i -= 1`; literal Number limits can use `OP_GTE_INT`/`OP_LTE_INT`. Isolated vs original baseline: loop-dispatch-floor 0.913. Combined remaining win is smaller because inc-assign already covers standalone `i = i + 1`. Combined re-measure vs `/tmp/goccia-combined-0293229e` (7 interleaved reps, `/tmp/combined-counted-for-ab.json`; repeat `/tmp/combined-counted-for-ab-repeat.json`): + + | Probe | Prev µs | Combined µs | Ratio | + | --- | ---: | ---: | ---: | + | loop-dispatch-floor | 113492 | 110868 | 0.977 | + | generic-plus-scalars | 55023 | 56309 | 1.023 | + | nbody-minimal | 60997 | 60213 | 0.987 | + | fib-recursive | 46475 | 46898 | 1.009 | + + Repeat loop-dispatch-floor 111446 → 108555 (**0.974**). Geomean combined/prev **0.999**. Checksums matched. Treat `7a31bc96` as the next combined baseline. + ## Lanes launching 1. `optimize/inc-assign` — **accepted** (see above). @@ -86,7 +97,7 @@ Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, To 4. `optimize/hot-dispatch-extract` — **rejected** (see below). 5. `optimize/get-local-prop` — fuse `GET_LOCAL` + `GET_PROP_CONST` (must use opcode **231** / format **v79** if it lands after this merge). 6. `optimize/write-ic` — **accepted** (see above). -7. `optimize/counted-for-assign` — widen `TryCompileCountedFor` to `i = i + 1`. +7. `optimize/counted-for-assign` — **accepted** (see above). ## Investigation conclusions (do not contradict) @@ -95,7 +106,7 @@ Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, To - **Broader read-PIC:** still rejected (ADR 0088). Own+proto read ICs already ship. - **Write-IC:** landed this wave (`15e8eca7`). Own writable-data stores on `OP_SET_PROP_CONST` hit a shape-keyed IC; misses still go through `AssignProperty`. Broader read-PIC remains rejected (ADR 0088). - **`OP_SET_PROP_CONST`** uses the write IC for ordinary own writable data; `VMTrySetOwnWritableDataProperty` remains available for non-IC paths. -- **Counted-for** only matches `i++`, so AWFY/probe `i = i + 1` still misses `OP_ADD_INT` loop microcode. Assignment `i = i + 1` now emits `OP_INC_NUMERIC` outside that counted-for path. +- **Counted-for** now matches `i = i + 1` / `i += 1` and minus (`e6f3e177`), emitting `OP_ADD_INT` in the loop template. Untyped parameter limits still use generic `OP_LT`. Standalone assignment `i = i + 1` remains `OP_INC_NUMERIC`. - **CALL:** bytecode→bytecode already trampolines; `ExecuteClosureRegisters0–3` are native ingress only. Revisit `OP_CALL_METHOD` staging only with AWFY transfer (ADR 0089 previously noise). - **Dispatch preamble:** ~10–15 predictable cold branches per opcode. A remaining DISPATCH idea is a **prod vs instrumented dual loop** that keeps one `case` and only strips coverage/profiler/`AStopAtIP` on the measured path — not a hot/cold case split. - **ALLOC:** do not revive value caches. Property-store `RegisterToValue` boxing is the live allocation tax. From db9567bd84dd82c3a6e8a55fcedb412d50d87482 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 25 Aug 2026 21:57:07 +0100 Subject: [PATCH 13/16] perf(bytecode): fuse local.ident reads into OP_GET_LOCAL_PROP_CONST nbody-minimal spends about 11% of pairs on GET_LOCAL then GET_PROP_CONST. One opcode removes that extra dispatch while reusing the existing shape-lite IC. Co-authored-by: Cursor --- docs/bytecode-vm.md | 5 +- source/units/Goccia.Bytecode.Binary.pas | 2 +- source/units/Goccia.Bytecode.Chunk.pas | 5 +- source/units/Goccia.Bytecode.OpCodeNames.pas | 1 + source/units/Goccia.Bytecode.pas | 17 +++- source/units/Goccia.Compiler.Expressions.pas | 25 ++++++ source/units/Goccia.Compiler.Test.pas | 45 ++++++++++ source/units/Goccia.VM.Test.pas | 68 +++++++++++++++ source/units/Goccia.VM.pas | 12 +++ .../member-access/local-const-property.js | 87 +++++++++++++++++++ 10 files changed, 258 insertions(+), 9 deletions(-) create mode 100644 tests/language/expressions/member-access/local-const-property.js diff --git a/docs/bytecode-vm.md b/docs/bytecode-vm.md index 451e37f20..b4c337a30 100644 --- a/docs/bytecode-vm.md +++ b/docs/bytecode-vm.md @@ -122,6 +122,7 @@ Recent VM cleanup and optimization work has focused on reducing per-instruction - execute compiler-proven closed-world numeric self-calls through `OP_CALL_SELF_NUM`: recursive calls with one to three scalar arguments use a compact register frame while sharing the generic entry frame's closure, lexical environment, local-cell and argument windows, realm, and execution context (see [ADR 0101](adr/0101-closed-numeric-scalar-self-call-frames.md)) - use unchecked template access in the dispatch loop where bounds are already guaranteed - fuse `Number - Int16` as `OP_SUB_NUM_IMM` and conditional `Number <= Int16` as `OP_JUMP_IF_NUM_NOT_LTE_IMM` only when the compiler proves the source is an ECMAScript Number; these instructions remove literal-load and branch dispatches rather than merely replacing a generic arithmetic dispatch +- fuse `local.ident` as `OP_GET_LOCAL_PROP_CONST`: one instruction that reads the local slot (including the `OP_GET_LOCAL` TDZ hole check) and then the existing `OP_GET_PROP_CONST` shape-lite IC; computed keys, optional chaining, `with` lookups, import bindings, and global-backed identifiers keep the unfused path - retain a static named import's linked module namespace in its local/upvalue slot: `OP_IMPORT` scales with declarations, while repeated identifier reads use `OP_GET_IMPORT_BINDING` to dereference the cached live binding identity without repeating module-loader lookup - read standalone `this` properties directly from a non-captured local register, preserving the derived-constructor guard while avoiding a temporary-register move; captured, top-level, and method-call receiver paths retain their existing lowering - keep fast register access limited to proven hot/simple paths; local-slot and complex property paths should only move to fast access when they stay correct and measurably improve throughput @@ -132,8 +133,8 @@ Recent VM cleanup and optimization work has focused on reducing per-instruction Three per-site inline caches live on `TGocciaFunctionTemplate`, all indexed by the instruction's name-constant index, all runtime-only (never serialised to `.gbc`): - **Global reads** (`OP_GET_GLOBAL`) — `TGocciaGlobalReadCacheEntry` validates `(scope identity, binding-map entry version)` and re-reads the binding by entry index, skipping the name hash. -- **Own property reads** (`OP_GET_PROP_CONST`) — `TGocciaPropertyReadCacheEntry` validates the receiver's interned **shape** (`Goccia.Values.Shape`): same shape implies the same key at the cached entry index, so one site hits across many same-layout receivers. The descriptor kind is re-checked on every hit because data-to-accessor redefinition keeps the entry index. -- **Prototype-resolved reads** (`OP_GET_PROP_CONST`, after an own miss) — `TGocciaProtoReadCacheEntry` proves continued *absence* of the name on the receiver and intermediate levels and *presence* at the holder, all by fresh shape identity per level, then re-reads the holder descriptor by entry index. The live chain is re-walked per hit, so `setPrototypeOf` is followed inherently; chain levels must be exact `TGocciaObjectValue`; chains deeper than two levels and accessor holders stay generic. Class instance methods (data properties on the class prototype object) are the dominant beneficiary. +- **Own property reads** (`OP_GET_PROP_CONST`, `OP_GET_LOCAL_PROP_CONST`) — `TGocciaPropertyReadCacheEntry` validates the receiver's interned **shape** (`Goccia.Values.Shape`): same shape implies the same key at the cached entry index, so one site hits across many same-layout receivers. The descriptor kind is re-checked on every hit because data-to-accessor redefinition keeps the entry index. +- **Prototype-resolved reads** (`OP_GET_PROP_CONST`, `OP_GET_LOCAL_PROP_CONST`, after an own miss) — `TGocciaProtoReadCacheEntry` proves continued *absence* of the name on the receiver and intermediate levels and *presence* at the holder, all by fresh shape identity per level, then re-reads the holder descriptor by entry index. The live chain is re-walked per hit, so `setPrototypeOf` is followed inherently; chain levels must be exact `TGocciaObjectValue`; chains deeper than two levels and accessor holders stay generic. Class instance methods (data properties on the class prototype object) are the dominant beneficiary. Hits and fills serve only exact-class `TGocciaObjectValue` / `TGocciaVMLiteralObjectValue` / `TGocciaInstanceValue` receivers, so overridden lookup semantics (proxies, exotic objects, private names) always take the generic path. Shapes are computed lazily at fill time (`EnsureShape`), not eagerly at property-append time: a stale shape is a true prefix description of an append-only layout, so the hit path may read it raw and at worst misses. Delete/clear flip a map to dictionary mode (a sentinel shape that never matches a cache entry). A map also flips to dictionary mode when `EnsureShape` runs from a non-owner realm, so cross-realm property reads never intern one realm's layout into another realm's shape table. After `PROPERTY_READ_CACHE_POLYMORPHIC_LIMIT` consecutive misses-with-refill or fill declines a site is megamorphic: it stops probing and serves gated receivers through the uncached own-data fast path. diff --git a/source/units/Goccia.Bytecode.Binary.pas b/source/units/Goccia.Bytecode.Binary.pas index 22bad86cf..77d5aaaff 100644 --- a/source/units/Goccia.Bytecode.Binary.pas +++ b/source/units/Goccia.Bytecode.Binary.pas @@ -256,7 +256,7 @@ procedure VerifyFunctionTemplate(const ATemplate: TGocciaFunctionTemplate; OP_DEFINE_STATIC_PROP_CONST, OP_DEFINE_STATIC_METHOD_CONST: RequireConstant(B); - OP_GET_PROP_CONST, OP_SETUP_AUTO_ACCESSOR_CONST, + OP_GET_PROP_CONST, OP_GET_LOCAL_PROP_CONST, OP_SETUP_AUTO_ACCESSOR_CONST, OP_SETUP_AUTO_ACCESSOR_DYNAMIC, OP_APPLY_ELEMENT_DECORATOR_CONST, OP_DEFINE_ACCESSOR_CONST, OP_THROW_TYPE_ERROR_CONST, OP_FINALIZE_ENUM, OP_SUPER_GET_CONST: diff --git a/source/units/Goccia.Bytecode.Chunk.pas b/source/units/Goccia.Bytecode.Chunk.pas index 9f9d3801c..f869adfa7 100644 --- a/source/units/Goccia.Bytecode.Chunk.pas +++ b/source/units/Goccia.Bytecode.Chunk.pas @@ -223,8 +223,9 @@ TGocciaFunctionTemplate = class FRegExpProgramCaches: array of TObject; FRegExpProgramCacheCount: Integer; FGlobalReadCaches: array of TGocciaGlobalReadCacheEntry; - // Property/proto read caches use DENSE slots: OP_GET_PROP_CONST name - // constants are a small subset of the constant pool, so a per-constant + // Property/proto read caches use DENSE slots: OP_GET_PROP_CONST and + // OP_GET_LOCAL_PROP_CONST name constants are a small subset of the + // constant pool, so a per-constant // UInt16 map (0 = unassigned, else dense slot + 1) assigns slots on // first use and the entry arrays grow only to the number of distinct // property-name constants actually read. Both tiers share one slot id diff --git a/source/units/Goccia.Bytecode.OpCodeNames.pas b/source/units/Goccia.Bytecode.OpCodeNames.pas index d702417a9..ca31744fa 100644 --- a/source/units/Goccia.Bytecode.OpCodeNames.pas +++ b/source/units/Goccia.Bytecode.OpCodeNames.pas @@ -223,6 +223,7 @@ function OpCodeName(const AOp: UInt8): string; OP_SUB_NUM_IMM: Result := 'OP_SUB_NUM_IMM'; OP_JUMP_IF_NUM_NOT_LTE_IMM: Result := 'OP_JUMP_IF_NUM_NOT_LTE_IMM'; OP_CALL_SELF_NUM: Result := 'OP_CALL_SELF_NUM'; + OP_GET_LOCAL_PROP_CONST: Result := 'OP_GET_LOCAL_PROP_CONST'; else Result := Format('OP_UNKNOWN_%d', [AOp]); end; diff --git a/source/units/Goccia.Bytecode.pas b/source/units/Goccia.Bytecode.pas index e43899bbd..4b3f7ec8b 100644 --- a/source/units/Goccia.Bytecode.pas +++ b/source/units/Goccia.Bytecode.pas @@ -169,7 +169,12 @@ interface // v76 -> v77: debug info carries each function's declaration line and // column, so coverage reports a function at the line it is // declared on rather than at its first executed instruction. - GOCCIA_FORMAT_VERSION = 77; + // v77 -> v78: added OP_GET_LOCAL_PROP_CONST (opcode 231), a fused + // decode of OP_GET_LOCAL + OP_GET_PROP_CONST for + // `local.ident` that reuses the existing shape-lite + // property-read IC. Opcode 230 is left unused so a sibling + // optimization lane can claim it without colliding. + GOCCIA_FORMAT_VERSION = 78; GOCCIA_BINARY_MAGIC: array[0..3] of Byte = (Ord('G'), Ord('B'), Ord('C'), 0); GOCCIA_NULLISH_MATCH_UNDEFINED = 0; GOCCIA_NULLISH_MATCH_NULL = 1; @@ -442,7 +447,11 @@ interface // A = destination, B = first contiguous argument register, // C = argument count (1..3). Valid only in a compiler-proven closed-world // numeric self-recursive template. - OP_CALL_SELF_NUM = 229 + OP_CALL_SELF_NUM = 229, + // A = destination, B = local slot holding the object, C = name-constant + // index. Fused OP_GET_LOCAL + OP_GET_PROP_CONST for `local.ident`. + // Opcode 230 is reserved for a sibling optimization lane. + OP_GET_LOCAL_PROP_CONST = 231 ); function IsValidGocciaOpCode(const AOp: UInt8): Boolean; @@ -475,7 +484,7 @@ function IsValidGocciaOpCode(const AOp: UInt8): Boolean; begin Result := (AOp >= Ord(Low(TGocciaOpCode))) and (AOp <= Ord(High(TGocciaOpCode))) and - not (AOp in [99, 144..166]); + not (AOp in [99, 144..166, 230]); end; function GocciaOpCodeUsesRegisterA(const AOp: TGocciaOpCode): Boolean; @@ -493,7 +502,7 @@ function GocciaOpCodeUsesRegisterB(const AOp: TGocciaOpCode): Boolean; OP_DIV_FLOAT, OP_MOD_FLOAT, OP_NEG_FLOAT, OP_EQ_INT, OP_NEQ_INT, OP_LT_INT, OP_GT_INT, OP_LTE_INT, OP_GTE_INT, OP_EQ_FLOAT, OP_NEQ_FLOAT, OP_LT_FLOAT, OP_GT_FLOAT, OP_LTE_FLOAT, OP_GTE_FLOAT, - OP_CONCAT, OP_GET_PROP_CONST, OP_GET_ITER, OP_ITER_NEXT, + OP_CONCAT, OP_GET_PROP_CONST, OP_GET_LOCAL_PROP_CONST, OP_GET_ITER, OP_ITER_NEXT, OP_CLASS_SET_SUPER, OP_CLASS_SET_FIELD_INITIALIZER, OP_CLASS_EXEC_STATIC_BLOCK, OP_UNPACK, OP_NOT, OP_TO_BOOL, OP_DEL_INDEX_LOOSE, OP_SET_INDEX_LOOSE, OP_GET_INDEX, OP_SET_INDEX, diff --git a/source/units/Goccia.Compiler.Expressions.pas b/source/units/Goccia.Compiler.Expressions.pas index 9f9a98f91..c5b33d73a 100644 --- a/source/units/Goccia.Compiler.Expressions.pas +++ b/source/units/Goccia.Compiler.Expressions.pas @@ -4684,6 +4684,7 @@ procedure CompileMember(const ACtx: TGocciaCompilationContext; EndJump, JumpIndex: Integer; NullishJumps: TGocciaCompilerJumpArray; NullishJumpCount: Integer; + IdentExpr: TGocciaIdentifierExpression; begin if AExpr.ObjectExpr is TGocciaSuperExpression then begin @@ -4729,6 +4730,30 @@ procedure CompileMember(const ACtx: TGocciaCompilationContext; Exit; end; + if (not AExpr.Computed) and (not AExpr.Optional) and + (AExpr.ObjectExpr is TGocciaIdentifierExpression) then + begin + IdentExpr := TGocciaIdentifierExpression(AExpr.ObjectExpr); + if not ShouldTryWithBinding(ACtx.Scope, IdentExpr.Name) then + begin + LocalIdx := ACtx.Scope.ResolveLocal(IdentExpr.Name); + if LocalIdx >= 0 then + begin + Local := ACtx.Scope.GetLocal(LocalIdx); + if (not Local.IsImportBinding) and (not Local.IsGlobalBacked) then + begin + PropIdx := ACtx.Template.AddConstantString(AExpr.PropertyName); + if PropIdx <= High(UInt8) then + begin + EmitInstruction(ACtx, EncodeABC(OP_GET_LOCAL_PROP_CONST, ADest, + Local.Slot, UInt16(PropIdx))); + Exit; + end; + end; + end; + end; + end; + NullishJumpCount := 0; ObjectRegisterAllocated := True; if AExpr.ObjectExpr is TGocciaThisExpression then diff --git a/source/units/Goccia.Compiler.Test.pas b/source/units/Goccia.Compiler.Test.pas index 46b09ad8f..a722bc2a9 100644 --- a/source/units/Goccia.Compiler.Test.pas +++ b/source/units/Goccia.Compiler.Test.pas @@ -76,6 +76,8 @@ TTestCompiler = class(TTestSuite) procedure TestCompileFunction; procedure TestThisPropertyReadUsesLocalRegister; procedure TestThisPropertyReadRetainsDerivedGuard; + procedure TestLocalPropertyReadUsesFusedOpcode; + procedure TestOptionalLocalPropertyReadSkipsFusedOpcode; procedure TestStaticImportLoadsScaleWithDeclarations; procedure TestBinaryRoundTrip; procedure TestBinaryRoundTripClosedNumericSelfCall; @@ -138,6 +140,10 @@ procedure TTestCompiler.SetupTests; TestThisPropertyReadUsesLocalRegister); Test('this property read retains derived-constructor guard', TestThisPropertyReadRetainsDerivedGuard); + Test('local property read uses fused opcode', + TestLocalPropertyReadUsesFusedOpcode); + Test('optional local property read skips fused opcode', + TestOptionalLocalPropertyReadSkipsFusedOpcode); Test('Static import loads scale with declarations', TestStaticImportLoadsScaleWithDeclarations); Test('Binary round-trip', TestBinaryRoundTrip); @@ -638,6 +644,42 @@ procedure TTestCompiler.TestThisPropertyReadRetainsDerivedGuard; end; end; +procedure TTestCompiler.TestLocalPropertyReadUsesFusedOpcode; +var + Module: TGocciaBytecodeModule; + Func: TGocciaFunctionTemplate; +begin + Module := CompileSource('const read = (a) => a.x;'); + try + Func := FindFunctionWithOp(Module.TopLevel, OP_GET_LOCAL_PROP_CONST); + Expect(Assigned(Func)).ToBe(True); + if Assigned(Func) then + begin + Expect(CountOp(Func, OP_GET_LOCAL_PROP_CONST)).ToBe(1); + Expect(CountOp(Func, OP_GET_PROP_CONST)).ToBe(0); + Expect(CountOp(Func, OP_GET_LOCAL)).ToBe(0); + end; + finally + Module.Free; + end; +end; + +procedure TTestCompiler.TestOptionalLocalPropertyReadSkipsFusedOpcode; +var + Module: TGocciaBytecodeModule; + Func: TGocciaFunctionTemplate; +begin + Module := CompileSource('const read = (a) => a?.x;'); + try + Expect(FindFunctionWithOp(Module.TopLevel, + OP_GET_LOCAL_PROP_CONST) = nil).ToBe(True); + Func := FindFunctionWithOp(Module.TopLevel, OP_GET_PROP_CONST); + Expect(Assigned(Func)).ToBe(True); + finally + Module.Free; + end; +end; + procedure TTestCompiler.TestBinaryRoundTrip; var Original, Loaded: TGocciaBytecodeModule; @@ -932,6 +974,9 @@ procedure TTestCompiler.TestBinaryRejectsMalformedArtifacts; Expect(IsValidGocciaOpCode(99)).ToBe(False); Expect(IsValidGocciaOpCode(144)).ToBe(False); Expect(IsValidGocciaOpCode(Ord(OP_CALL_SELF_NUM))).ToBe(True); + Expect(IsValidGocciaOpCode(Ord(OP_GET_LOCAL_PROP_CONST))).ToBe(True); + Expect(IsValidGocciaOpCode(230)).ToBe(False); + Expect(GocciaOpCodeUsesRegisterB(OP_GET_LOCAL_PROP_CONST)).ToBe(True); Expect(GocciaOpCodeUsesRegisterA(OP_CLOSE_UPVALUE)).ToBe(False); Expect(GocciaOpCodeUsesRegisterB(OP_DEFINE_DATA_PROP)).ToBe(True); Expect(GocciaOpCodeUsesRegisterB(OP_DEFINE_METHOD_PROP)).ToBe(True); diff --git a/source/units/Goccia.VM.Test.pas b/source/units/Goccia.VM.Test.pas index b1859a17b..299c4253a 100644 --- a/source/units/Goccia.VM.Test.pas +++ b/source/units/Goccia.VM.Test.pas @@ -40,6 +40,7 @@ TTestGocciaVM = class(TTestSuite) procedure TestExecuteArrayOps; procedure TestExecuteArrayPop; procedure TestExecuteObjectOps; + procedure TestExecuteLocalPropConst; procedure TestExecuteIndexedObjectOps; procedure TestExecuteClosureCall; procedure TestExecuteCapturedClosure; @@ -83,6 +84,7 @@ procedure TTestGocciaVM.SetupTests; Test('Execute array ops', TestExecuteArrayOps); Test('Execute array pop', TestExecuteArrayPop); Test('Execute object ops', TestExecuteObjectOps); + Test('Execute local property const', TestExecuteLocalPropConst); Test('Execute indexed object ops', TestExecuteIndexedObjectOps); Test('Execute closure call', TestExecuteClosureCall); Test('Execute captured closure', TestExecuteCapturedClosure); @@ -367,6 +369,72 @@ procedure TTestGocciaVM.TestExecuteObjectOps; end; end; +procedure TTestGocciaVM.TestExecuteLocalPropConst; +var + Template: TGocciaFunctionTemplate; + VM: TGocciaVM; + ResultValue: TGocciaValue; + NameIdx: UInt16; + RaisedExpected: Boolean; + ErrorObject: TGocciaObjectValue; +begin + Template := TGocciaFunctionTemplate.Create('local-prop-const'); + VM := TGocciaVM.Create; + try + Template.MaxRegisters := 2; + NameIdx := Template.AddConstantString('answer'); + Template.EmitInstruction(EncodeABx(OP_NEW_OBJECT, 0, 0)); + Template.EmitInstruction(EncodeAsBx(OP_LOAD_INT, 1, 42)); + Template.EmitInstruction(EncodeABC(OP_SET_PROP_CONST, 0, UInt8(NameIdx), 1)); + Template.EmitInstruction(EncodeABC(OP_GET_LOCAL_PROP_CONST, 1, 0, + UInt8(NameIdx))); + Template.EmitInstruction(EncodeABC(OP_RETURN, 1, 0, 0)); + + ResultValue := VM.ExecuteFunction(Template); + Expect(ResultValue.ToNumberLiteral.Value).ToBe(42); + finally + VM.Free; + Template.Free; + end; + + Template := TGocciaFunctionTemplate.Create('local-prop-const-tdz'); + VM := TGocciaVM.Create; + try + Template.MaxRegisters := 2; + NameIdx := Template.AddConstantString('answer'); + Template.EmitInstruction(EncodeABC(OP_LOAD_HOLE, 0, 0, 0)); + Template.EmitInstruction(EncodeABC(OP_GET_LOCAL_PROP_CONST, 1, 0, + UInt8(NameIdx))); + Template.EmitInstruction(EncodeABC(OP_RETURN, 1, 0, 0)); + + RaisedExpected := False; + try + VM.ExecuteFunction(Template); + except + on E: EGocciaBytecodeThrow do + if E.ThrownValue is TGocciaObjectValue then + begin + ErrorObject := TGocciaObjectValue(E.ThrownValue); + RaisedExpected := + ErrorObject.GetProperty(PROP_NAME).ToStringLiteral.Value = + 'ReferenceError'; + end; + on E: TGocciaThrowValue do + if E.Value is TGocciaObjectValue then + begin + ErrorObject := TGocciaObjectValue(E.Value); + RaisedExpected := + ErrorObject.GetProperty(PROP_NAME).ToStringLiteral.Value = + 'ReferenceError'; + end; + end; + Expect(RaisedExpected).ToBe(True); + finally + VM.Free; + Template.Free; + end; +end; + procedure TTestGocciaVM.TestExecuteIndexedObjectOps; var Template: TGocciaFunctionTemplate; diff --git a/source/units/Goccia.VM.pas b/source/units/Goccia.VM.pas index c58b4c406..2b37147ac 100644 --- a/source/units/Goccia.VM.pas +++ b/source/units/Goccia.VM.pas @@ -14298,6 +14298,8 @@ function TGocciaVM.ExecuteClosureRegistersInternal( const AArg0, AArg1, AArg2: TGocciaRegister; const AUseFixedArgs: Boolean; const APushExecutionContext: Boolean; const AStopAtIP: Integer; const AStopGenerator: TObject): TGocciaRegister; +label + LGetPropConstShared; var Frame: TGocciaVMCallFrame; SavedRegisterBase: Integer; @@ -15506,7 +15508,17 @@ // ES2022 §15.7.14: execute static block closure with this = class end; end; + OP_GET_LOCAL_PROP_CONST: + begin + FRegisters[A] := GetLocalRegister(B); + if FRegisters[A].Kind = grkHole then + ThrowReferenceError('Cannot access lexical binding before initialization'); + B := A; + goto LGetPropConstShared; + end; + OP_GET_PROP_CONST: + LGetPropConstShared: if (FRegisters[B].Kind = grkObject) and Assigned(FRegisters[B].ObjectValue) and (FRegisters[B].ObjectValue is TGocciaObjectValue) then begin diff --git a/tests/language/expressions/member-access/local-const-property.js b/tests/language/expressions/member-access/local-const-property.js new file mode 100644 index 000000000..78b63bfdc --- /dev/null +++ b/tests/language/expressions/member-access/local-const-property.js @@ -0,0 +1,87 @@ +/*--- +description: Constant property reads from a local object use fused local+prop decode +features: [Object, property-access, Object.defineProperty] +---*/ + +test("own data property on a local object", () => { + const obj = { x: 1, y: 2 }; + expect(obj.x).toBe(1); + expect(obj.y).toBe(2); + obj.x = 3; + expect(obj.x).toBe(3); +}); + +test("missing own property on a local object is undefined", () => { + const obj = { x: 1 }; + expect(obj.missing).toBeUndefined(); +}); + +test("inherited data property on a local object", () => { + const proto = { x: 7 }; + const obj = Object.create(proto); + expect(obj.x).toBe(7); +}); + +test("own accessor on a local object is invoked", () => { + let calls = 0; + const obj = { + get x() { + calls += 1; + return 42; + }, + }; + expect(obj.x).toBe(42); + expect(obj.x).toBe(42); + expect(calls).toBe(2); +}); + +test("inherited accessor on a local object uses the local receiver", () => { + const proto = { + get x() { + return this.y; + }, + }; + const obj = Object.create(proto); + obj.y = 9; + expect(obj.x).toBe(9); +}); + +test("nullish local base throws TypeError", () => { + const und = undefined; + const nul = null; + let undefinedError; + let nullError; + + try { + und.x; + } catch (error) { + undefinedError = error; + } + try { + nul.x; + } catch (error) { + nullError = error; + } + + expect(undefinedError instanceof TypeError).toBe(true); + expect(undefinedError.message).toBe( + "Cannot read properties of undefined (reading 'x')", + ); + expect(nullError instanceof TypeError).toBe(true); + expect(nullError.message).toBe( + "Cannot read properties of null (reading 'x')", + ); +}); + +test("parameter local property reads observe redefinition", () => { + const readX = (o) => o.x; + const obj = { x: 1 }; + expect(readX(obj)).toBe(1); + Object.defineProperty(obj, "x", { + get() { + return 99; + }, + configurable: true, + }); + expect(readX(obj)).toBe(99); +}); From ff9a0cda3e6d3bf3f6d1316037fdd56e3b891e05 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 25 Aug 2026 22:00:28 +0100 Subject: [PATCH 14/16] docs(agent): record accepted OP_GET_LOCAL_PROP_CONST merge Format is v79 so it does not collide with OP_ADD_NUM_IMM; combined nbody stayed faster and propaccess transferred. Co-authored-by: Cursor --- .agent/HANDOFF.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/.agent/HANDOFF.md b/.agent/HANDOFF.md index b50f8ef78..f7173971a 100644 --- a/.agent/HANDOFF.md +++ b/.agent/HANDOFF.md @@ -1,14 +1,14 @@ # Handoff -Updated: 2026-08-25 (counted-for i=i+1 accepted) +Updated: 2026-08-25 (GET_LOCAL_PROP_CONST accepted, format v79) ## Experiment - **Goal:** Goccia bytecode at 0.6×–0.8× of QuickJS *speed* (AWFY `goccia_over_qjs` ≈ 1.25–1.67). - **Current main CI** (`f4d403f0`, linux/x64 Azure): AWFY geomean `goccia_over_qjs` = **16.256** (speed **0.062×**). Need ~10–13×. -- **Delivery branch:** `perf/bytecode-quickjs-gap` @ `7a31bc96` (skill adoption on `origin/main` `f4d403f0`). -- **Accepted code:** `ad0023da` `OP_ADD_NUM_IMM` (opcode **230**, format **v78**); `315bfd28` numeric `i = i + 1` → `OP_INC_NUMERIC`; `15e8eca7` own writable write IC; `e6f3e177` counted-for `i = i + 1` → `OP_ADD_INT`. Next free opcode **231**; `optimize/get-local-prop` must take 231 and bump format to v79 if it lands. -- **Combined candidate binary:** `/tmp/goccia-combined-7a31bc96`. Previous combined heads: `/tmp/goccia-combined-0293229e` (write-IC), `/tmp/goccia-combined-1c2e0412` (inc-assign), `/tmp/goccia-combined-d099bdf9` (ADD_NUM_IMM). +- **Delivery branch:** `perf/bytecode-quickjs-gap` @ `dc9e958e` (skill adoption on `origin/main` `f4d403f0`). +- **Accepted code:** `ad0023da` `OP_ADD_NUM_IMM` (opcode **230**, format **v78**); `315bfd28` numeric `i = i + 1` → `OP_INC_NUMERIC`; `15e8eca7` own writable write IC; `e6f3e177` counted-for `i = i + 1` → `OP_ADD_INT`; `db9567bd` `OP_GET_LOCAL_PROP_CONST` (opcode **231**, format **v79**). Next free opcode **232**. +- **Combined candidate binary:** `/tmp/goccia-combined-dc9e958e`. Previous combined heads: `/tmp/goccia-combined-7a31bc96` (counted-for), `/tmp/goccia-combined-0293229e` (write-IC), `/tmp/goccia-combined-1c2e0412` (inc-assign), `/tmp/goccia-combined-d099bdf9` (ADD_NUM_IMM). - **Baseline binary:** `/tmp/goccia-baseline-f4d403f0` (`--prod` loader, darwin/aarch64, FPC 3.2.2). - **QuickJS:** 2026-06-04 at `/tmp/quickjs/bin/qjs`. - **Lock:** `/tmp/gocciascript-perf-gate.lock`. @@ -33,7 +33,7 @@ Fib is the best relative row because it already uses `OP_CALL_SELF_NUM` / `OP_SU ## Profile facts (function-wrapped equivalents) - `loop-dispatch-floor`: original baseline was 38% `OP_GET_LOCAL`, 15% `OP_LOAD_INT`, 11% `OP_SET_LOCAL`, 8% `OP_ADD_FLOAT`. Counted-for now matches `i = i + 1` and emits `OP_ADD_INT` (`e6f3e177`); standalone assignment still uses `OP_INC_NUMERIC`. Compare stays generic `OP_LT` when the limit is an untyped parameter. Number literals type as `sltFloat`. -- `nbody-minimal`: 31% `OP_GET_LOCAL`, 12% `OP_GET_PROP_CONST`, 10% `OP_LOAD_HOLE`, 8% `OP_MOVE`. Hot pair `GET_LOCAL → GET_PROP_CONST` (11%). Generic `OP_MUL`/`OP_ADD` with 100% scalar hit rate. +- `nbody-minimal`: original baseline was 31% `OP_GET_LOCAL`, 12% `OP_GET_PROP_CONST`, 10% `OP_LOAD_HOLE`, 8% `OP_MOVE`. Hot pair `GET_LOCAL → GET_PROP_CONST` (11%) now fuses as `OP_GET_LOCAL_PROP_CONST` (`db9567bd`). Generic `OP_MUL`/`OP_ADD` still 100% scalar hit rate. - Script-level `let` in a non-function profiled as `OP_GET_GLOBAL` (29% of opcodes) — not the AWFY/probe shape. ## CI AWFY worst rows (linux/x64, time ratio) @@ -89,13 +89,26 @@ Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, To Repeat loop-dispatch-floor 111446 → 108555 (**0.974**). Geomean combined/prev **0.999**. Checksums matched. Treat `7a31bc96` as the next combined baseline. +- **`optimize/get-local-prop`** `db9567bd`, merged at `dc9e958e`. `OP_GET_LOCAL_PROP_CONST` (opcode **231**, format **v79**) fuses `local.ident` and jumps into the existing `OP_GET_PROP_CONST` IC. Isolated lane used format v78 with 230 reserved; integrator kept `OP_ADD_NUM_IMM` as 230 and bumped format to v79. Isolated vs original baseline: nbody-minimal +1.75% (BA +2.28%), propaccess-monomorphic +4.2–5.5%. Combined re-measure vs `/tmp/goccia-combined-7a31bc96` (7 interleaved reps, `/tmp/combined-get-local-prop-ab.json`): + + | Probe | Prev µs | Combined µs | Ratio | Speed | + | --- | ---: | ---: | ---: | ---: | + | nbody-minimal | 58515 | 57705 | 0.986 | +1.4% | + | loop-dispatch-floor | 106733 | 107265 | 1.005 | flat | + | propaccess-monomorphic | 13500 | 12731 | 0.943 | +6.0% | + | generic-plus-scalars | 54287 | 53303 | 0.982 | +1.9% | + + Geomean combined/prev **0.979**. Checksums matched. Treat `dc9e958e` as the next combined baseline. + + Rejected along the way: extracting the property-read IC into a nested helper (nbody +15%, propaccess +35%). Do not retry a helper call on the IC hit path. + ## Lanes launching 1. `optimize/inc-assign` — **accepted** (see above). 2. `optimize/int-literals` — integer-valued number literals as `sltInteger`. 3. `optimize/add-num-imm` — **accepted** (see above). 4. `optimize/hot-dispatch-extract` — **rejected** (see below). -5. `optimize/get-local-prop` — fuse `GET_LOCAL` + `GET_PROP_CONST` (must use opcode **231** / format **v79** if it lands after this merge). +5. `optimize/get-local-prop` — **accepted** (see above). 6. `optimize/write-ic` — **accepted** (see above). 7. `optimize/counted-for-assign` — **accepted** (see above). From 9e0dfde8f87e99cce5bb09bb309fcc21d09a072c Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 25 Aug 2026 23:47:43 +0100 Subject: [PATCH 15/16] docs(agent): record stalled integer-literal typing lane The lane never produced a patch or A/B; counted-for already covers i = i + 1 as OP_ADD_INT, so a retry should aim at OP_LT_INT. Co-authored-by: Cursor --- .agent/HANDOFF.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.agent/HANDOFF.md b/.agent/HANDOFF.md index f7173971a..b21882ffd 100644 --- a/.agent/HANDOFF.md +++ b/.agent/HANDOFF.md @@ -1,6 +1,6 @@ # Handoff -Updated: 2026-08-25 (GET_LOCAL_PROP_CONST accepted, format v79) +Updated: 2026-08-25 (int-literals lane stalled, no commit) ## Experiment @@ -105,7 +105,7 @@ Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, To ## Lanes launching 1. `optimize/inc-assign` — **accepted** (see above). -2. `optimize/int-literals` — integer-valued number literals as `sltInteger`. +2. `optimize/int-literals` — **stalled** (see below). No commit, no A/B. 3. `optimize/add-num-imm` — **accepted** (see above). 4. `optimize/hot-dispatch-extract` — **rejected** (see below). 5. `optimize/get-local-prop` — **accepted** (see above). @@ -119,11 +119,15 @@ Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, To - **Broader read-PIC:** still rejected (ADR 0088). Own+proto read ICs already ship. - **Write-IC:** landed this wave (`15e8eca7`). Own writable-data stores on `OP_SET_PROP_CONST` hit a shape-keyed IC; misses still go through `AssignProperty`. Broader read-PIC remains rejected (ADR 0088). - **`OP_SET_PROP_CONST`** uses the write IC for ordinary own writable data; `VMTrySetOwnWritableDataProperty` remains available for non-IC paths. -- **Counted-for** now matches `i = i + 1` / `i += 1` and minus (`e6f3e177`), emitting `OP_ADD_INT` in the loop template. Untyped parameter limits still use generic `OP_LT`. Standalone assignment `i = i + 1` remains `OP_INC_NUMERIC`. +- **Counted-for** now matches `i = i + 1` / `i += 1` and minus (`e6f3e177`), emitting `OP_ADD_INT` in the loop template. Untyped parameter limits still use generic `OP_LT`. Standalone assignment `i = i + 1` remains `OP_INC_NUMERIC`. Integer-valued number literals still type as `sltFloat` (`ExpressionType`); that remaining lane stalled before a patch. - **CALL:** bytecode→bytecode already trampolines; `ExecuteClosureRegisters0–3` are native ingress only. Revisit `OP_CALL_METHOD` staging only with AWFY transfer (ADR 0089 previously noise). - **Dispatch preamble:** ~10–15 predictable cold branches per opcode. A remaining DISPATCH idea is a **prod vs instrumented dual loop** that keeps one `case` and only strips coverage/profiler/`AStopAtIP` on the measured path — not a hot/cold case split. - **ALLOC:** do not revive value caches. Property-store `RegisterToValue` boxing is the live allocation tax. +## Stalled this wave + +- **`optimize/int-literals`**. Subagent stopped after resume loops with no patch, no commit, and no A/B. Branch is still at baseline `56eb8849`. Hypothesis remains open: `ExpressionType` types every number literal as `sltFloat`, which can keep generic `OP_LT` / `OP_ADD_FLOAT` on integer-valued literals. Counted-for already emits `OP_ADD_INT` for `i = i + 1` updates, so a retry should target integer TypeHints and `OP_LT_INT` (especially literal limits and non-counted-for arithmetic), not re-measure the already-landed increment path. Not a reject — never measured. + ## Rejected this wave - **`optimize/hot-dispatch-extract`** (first-level hot `case` + `ExecuteColdOpcode` nested helper). Checksums matched; fully reverted; no commit. Medians vs `/tmp/goccia-baseline-f4d403f0`, 7 interleaved reps (`/tmp/lane-hot-dispatch-ab.json`): From 48dcbd9c92d5892ba80fe807856e4de8727b46ec Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Wed, 26 Aug 2026 02:15:28 +0100 Subject: [PATCH 16/16] docs(agent): launch wave-2 isolated optimization lanes Next lanes start from the stacked head; accepted ones will be combined, gated, and submitted as a native GitHub stack. Co-authored-by: Cursor --- .agent/HANDOFF.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.agent/HANDOFF.md b/.agent/HANDOFF.md index b21882ffd..b7d5d769d 100644 --- a/.agent/HANDOFF.md +++ b/.agent/HANDOFF.md @@ -1,14 +1,14 @@ # Handoff -Updated: 2026-08-25 (int-literals lane stalled, no commit) +Updated: 2026-08-26 (wave 2 lanes launching from stacked head) ## Experiment - **Goal:** Goccia bytecode at 0.6×–0.8× of QuickJS *speed* (AWFY `goccia_over_qjs` ≈ 1.25–1.67). - **Current main CI** (`f4d403f0`, linux/x64 Azure): AWFY geomean `goccia_over_qjs` = **16.256** (speed **0.062×**). Need ~10–13×. -- **Delivery branch:** `perf/bytecode-quickjs-gap` @ `dc9e958e` (skill adoption on `origin/main` `f4d403f0`). +- **Delivery branch:** `perf/bytecode-quickjs-gap` @ `9e0dfde8` (skill adoption on `origin/main` `f4d403f0`). Wave-2 lanes branch from this head. After they return: merge one accepted lane at a time, combined A/B vs `/tmp/goccia-combined-dc9e958e` (then the new combined head), full interpreter+bytecode JS gate, then submit accepted work as a **native `gh stack`** (wave-1 combined vs `main` as the bottom layer; each newly accepted wave-2 commit as its own layer). Do not reconstruct wave-1 as five force-pushed layers unless `gh stack` can do it from existing commits without a raw rebase. - **Accepted code:** `ad0023da` `OP_ADD_NUM_IMM` (opcode **230**, format **v78**); `315bfd28` numeric `i = i + 1` → `OP_INC_NUMERIC`; `15e8eca7` own writable write IC; `e6f3e177` counted-for `i = i + 1` → `OP_ADD_INT`; `db9567bd` `OP_GET_LOCAL_PROP_CONST` (opcode **231**, format **v79**). Next free opcode **232**. -- **Combined candidate binary:** `/tmp/goccia-combined-dc9e958e`. Previous combined heads: `/tmp/goccia-combined-7a31bc96` (counted-for), `/tmp/goccia-combined-0293229e` (write-IC), `/tmp/goccia-combined-1c2e0412` (inc-assign), `/tmp/goccia-combined-d099bdf9` (ADD_NUM_IMM). +- **Wave-2 A/B baseline binary:** `/tmp/goccia-combined-dc9e958e` (engine equivalent to `9e0dfde8`). Lanes must not overwrite it or `/tmp/goccia-baseline-f4d403f0`. - **Baseline binary:** `/tmp/goccia-baseline-f4d403f0` (`--prod` loader, darwin/aarch64, FPC 3.2.2). - **QuickJS:** 2026-06-04 at `/tmp/quickjs/bin/qjs`. - **Lock:** `/tmp/gocciascript-perf-gate.lock`. @@ -102,7 +102,16 @@ Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, To Rejected along the way: extracting the property-read IC into a nested helper (nbody +15%, propaccess +35%). Do not retry a helper call on the IC hit path. -## Lanes launching +## Wave 2 lanes launching + +Branch from delivery head `9e0dfde8`. A/B vs `/tmp/goccia-combined-dc9e958e`. Serialize perf on `/tmp/gocciascript-perf-gate.lock`. Do not edit this file. Next opcode **232** / format **v80** is reserved for `optimize/w2-jump-if-not-lt` if it adds an opcode. + +1. `optimize/w2-int-literals` — integer-valued number literals as `sltInteger` / TypeHints; target `OP_LT_INT` and non-counted-for integer arith. No new opcode. Retry of stalled `optimize/int-literals`. +2. `optimize/w2-prod-dispatch` — prod vs instrumented **dual loop** in `ExecuteRegisters`; keep **one** `case`; strip coverage/profiler/`AStopAtIP` (and hoist `PollInstructionLimit` when inactive) on the measured path. Do **not** split hot/cold opcodes. +3. `optimize/w2-jump-if-not-lt` — fuse loop/if `OP_LT` + `OP_JUMP_IF_FALSE` (numeric imm form mirroring `OP_JUMP_IF_NUM_NOT_LTE_IMM` where proven). Opcode **232**, format **v80**. +4. `optimize/w2-bit-eq-jump` — fuse `(x & K) === 0` style tests in `loop-dispatch-floor` / Richards (`OP_BAND` + `OP_EQ` + jump). No collision with 232: use **233** / format **v81** only if a new opcode is required; prefer existing ops if a compiler peephole suffices. + +## Lanes launching (wave 1, closed) 1. `optimize/inc-assign` — **accepted** (see above). 2. `optimize/int-literals` — **stalled** (see below). No commit, no A/B.