Skip to content

Algorithm codegen (new wasm test case): suspected coolant-operand drop + constant-div SR, .rodata relocations, regalloc spilling #210

Closed

Description

@avrabe

Context — expanding the wasm surface to a second function

Following the on-silicon success of the wasm-cross-LTO z_impl_k_sem_give (now running on a physical NUCLEO-G474RE, 907-cyc handoff, synth v0.11.15), we're widening the test surface as you suggested — compiling more of our benchmark through the clang→wasm-ld→loom→synth pipeline so there are more codegen paths to optimize. The first new one is the engine_control control algorithm (control_step), reshaped to a pure scalar signature:

uint32_t control_step_decide(uint32_t rpm, uint32_t load_pct,
                             int32_t coolant_c, uint32_t knock_retard);
// returns (spark_advance:i16 << 16) | (fuel_duration:u16)

It's pure integer math + two static lookup tables (int8[20][20], u16[20][20]). Good news: it compiles cleanly on v0.11.15 — synth compile control.loom.wasm --target cortex-m4f --all-exports --relocatable → 1 function, 376 bytes, 0 relocations. This is a new path vs decide: it has integer division and a read-only data segment, neither of which the sem primitive exercised.

Hand-decoded as Thumb-2 (via objcopy -O binary --only-section=.text + objdump -Mforce-thumb, since #170 / no $t symbols still applies). Findings below — one suspected correctness bug and three codegen-quality opportunities. The intent is that these become synth improvements; happy to retest each as you land it.


🔴 Suspected functional bug — coolant operand dropped (please verify on your unicorn harness)

The source computes enrich = (80 - coolant_c) * 300 / 80 on the mid-range path. synth emits:

ea: f240 0250   movw  r2, #80          ; r2 = 80
ee: 1a93        subs  r3, r2, r2       ; r3 = 80 - 80 = 0   <-- coolant_c operand missing
f0: f240 142c   movw  r4, #300
f4: fb03 f504   mul.w r5, r3, r4       ; 0 * 300
...             udiv  by 80            ; -> enrich == 0 always on this path

The second operand of the subtract should be the coolant_c parameter, but synth subtracts r2 from itself → enrich is forced to 0 whenever 0 < coolant_c < 80. This smells like the #193-class param/register threading issue resurfacing on a binary op. Concrete test vector (native reference build of the identical C):

rpm load coolant_c knock expected spark expected fuel
3000 50 90 0 33 2300
3000 50 40 0 33 2645
3000 50 0 0 33 2990
6000 80 40 3 34 2277

The coolant_c=40 row is the discriminator: correct = fuel 2645; if the coolant operand is dropped you'll get 2300 (enrich=0). Packed return for the correct 2645 case is 0x00210a55.


🟡 Opt 1 — constant-divisor strength reduction + guard elision

All four divisors are compile-time constants (/500, /5, /80, /1000), yet each emits a runtime udiv plus a div-by-zero guard that can never trip:

10: f240 15f4   movw  r5, #500
14: 2d00        cmp   r5, #0
16: d100        bne.n .+...
18: de00        udf   #0          ; dead: r5 is the constant 500
1a: fbb0 f6f5   udiv  r6, r0, r5

4× udiv + 4× udf in a 376-byte function. gcc/LLVM lower division-by-constant to a multiply-high + shift (no udiv, no guard). Recommendation: when a divisor is a known nonzero constant, (a) drop the cmp/bne/udf guard, and (b) strength-reduce to reciprocal-multiply. This is a large win for any table-binning / scaling code (very common in control loops).

🟡 Opt 2 — emit data segments as relocatable .rodata, not baked linmem addresses

The table reads are fp-relative into the wasm linear-memory image:

8a: f83b 6005   ldrh.w r6, [fp, r5]    ; fuel table, base materialized as 0x10190
aa: f91b 3002   ldrsb.w r3, [fp, r2]   ; spark table, base 0x10000

fp (r11) must equal the linear-memory base, and the tables sit at linmem 0x10000/0x10190 — i.e. there's a 64 KB gap (wasm's default stack/global region) before the data, and the .o carries no .rodata and 0 relocations, so the data segment has to be materialized separately at exactly the right offset and the fp-base contract honored. For a native drop-in that's awkward and wastes 64 KB.

Recommendation: emit the active data segment(s) as a real .rodata section in the ET_REL object, and reference them with R_ARM_THM_MOVW_ABS_NC / R_ARM_THM_MOVT_ABS relocations against .rodata symbols instead of baking the linear address. Then the host linker places the tables compactly and resolves the movw/movt — no fp-base contract for read-only data, no 64 KB gap. (This is the read-only-data analogue of the relocation work you already did for call targets.)

🟡 Opt 3 — register allocation: spilling with registers free

6 spill/reload pairs to [sp]/[sp,#4] while r9/r10 are never touched, plus no constant CSE (movw #19 materialized 4×, movw #65535 twice back-to-back at 0x4a/0x128, movw #80 twice). For a function this small the spills and re-materialized constants are most of the 376 bytes — gcc compiles the equivalent in ~100. Recommendation: prefer free callee-saved regs before spilling, and CSE repeated immediate materializations.


Reproduction

clang --target=wasm32-unknown-unknown -O2 -nostdlib -c control_wasm.c -o control_wasm.o
clang --target=wasm32-unknown-unknown -O2 -nostdlib -c tables.c       -o tables_wasm.o
wasm-ld --no-entry --export=control_step_decide --allow-undefined --gc-sections \
        control_wasm.o tables_wasm.o -o control_merged.wasm
loom optimize control_merged.wasm --passes inline --attestation false -o control.loom.wasm   # loom v1.1.5 (no-op: single fn)
synth compile control.loom.wasm --target cortex-m4f --all-exports --relocatable -o control_algo.o   # synth v0.11.15
# decode: objcopy -O binary --only-section=.text control_algo.o t.bin ; objdump -D -b binary -marm -Mforce-thumb t.bin

Source of control_wasm.c / tables.c available on request (it's the engine_control bench algorithm).

Priority / impact

  • The coolant-drop item is a correctness blocker for running this function on silicon (gates the second on-target data point we want to publish alongside the 907-cyc sem result).
  • Opt 1–3 are the difference between "compiles" and "competitive with LLVM-LTO" — the same gap we see on the sem primitive (907 cyc vs ~471 LLVM-LTO). Constant-division SR (Opt 1) and regalloc (Opt 3) in particular would move the macro number a lot, since real control code is full of binning divides and small temporaries.

Environment

  • synth v0.11.15 (--target cortex-m4f), loom v1.1.5, input from clang→wasm-ld
  • target NUCLEO-G474RE (Cortex-M4F @170 MHz); Zephyr SDK 1.0.1

Activity

  1. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    Adding evidence that isolates the suspected coolant-drop to synth's ARM lowering (not our wasm input or loom).

    I ran the exact same module (control.loom.wasm, the loom v1.1.5 output that feeds synth compile) through wasmtime 42.0.1 and it matches the native reference build of the C source on all four vectors:

    vector (rpm, load, coolant, knock) wasmtime packed spark / fuel native ref match
    (3000, 50, 90, 0) 0x002108FC (2164988) 33 / 2300 33 / 2300 ✅
    (3000, 50, 40, 0) 0x00210A55 (2165333) 33 / 2645 33 / 2645 ✅
    (3000, 50, 0, 0) 0x00210BAE (2165678) 33 / 2990 33 / 2990 ✅
    (6000, 80, 40, 3) 0x002208E5 (2230501) 34 / 2277 34 / 2277 ✅

    So the wasm correctly computes the mid-range coolant enrichment (the (80-coolant)*300/80 term): vector 2 yields fuel 2645, distinct from the no-enrichment 2300. The movw r2,#80; subs r3,r2,r2 in the synth Thumb-2 output is therefore a genuine lowering bug (coolant operand dropped on the subtract), not something upstream — wasmtime and native agree, only the synth ARM path would diverge.

    Repro for the wasm side (one-shot per process; capturing in a shell loop came back empty for me, so invoke each explicitly):

    wasmtime --invoke control_step_decide control.loom.wasm 3000 50 40 0   # -> 2165333 (correct)

    When you have the ARM output executing in your unicorn harness, vector 2 is the discriminator: 2645 = correct, 2300 = coolant dropped. Happy to provide control.loom.wasm / the gist if useful.

  2. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    More data — I compiled a second algorithm through the pipeline: controller_step from our flight_control bench (the bigger composed example). Pure integer, scalar-in/u32-out: 3 axes of -(angle>>6) - (rate>>7), each saturated to [-127, 127], then packed into a u32. synth v0.11.15 compiles it to 348 bytes, 0 relocations, and it's functionally correct (verified against native + wasmtime on 4 vectors — arithmetic-shift / asr.w lowering is right ✅).

    Two things worth your attention, one reinforcing #210 and one new:

    (reinforces) No constant CSE / verbose selects. Same pattern as the engine_control algorithm:

    2c: f240 047e  movw r4, #126
    30: 43e4       mvns r4, r4        ; r4 = -127
    32: f240 057e  movw r5, #126
    36: 43ed       mvns r5, r5        ; r5 = -127  again (no CSE)
    ...
    4c: f240 0c7f  movw ip, #127
    50: f240 007f  movw r0, #127      ; 127 twice
    

    3 spills with r9/r10 free, every clamp bound re-materialized.

    (new) Saturation → use SSAT. Each if (x>127) x=127; if (x<-127) x=-127; clamp expands to ~12 instructions (cmp → boolean-into-reg → cmp #0 → IT → conditional move), ×3 axes = most of the 348 bytes. Cortex-M/Thumb-2 has SSAT Rd, #n, Rm which does signed saturation to a power-of-two range in one instruction (and USAT for unsigned). Recognizing the min(max(x, -lo), hi) idiom and emitting SSAT/USAT (or at least an IT-predicated pair instead of the boolean-materialize-then-test sequence) would be a large win — control/DSP code is saturating everywhere.

    So across two independent functions the theme is consistent: correctness is there; the gap to LLVM-LTO is codegen polish — constant-division SR, constant CSE, register allocation, and now saturation idioms. These are exactly what stands between the current numbers (907 cyc sem; the algorithm/controller code sizes) and LLVM-LTO parity (~471). Happy to keep feeding functions through — flight_control has filter_step next (signed mul + /1000), which will exercise the constant-division path from #210 directly.

    (Testbed note for us: these are now checked by a host-side wasmtime oracle, so I can verify any synth/loom input's functional correctness without hardware, and reserve the G474RE for cycle measurements.)

  3. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    Third function through the pipeline — filter_axis (the complementary-filter hot math from flight_control's filter_step): (( prev+gyro)*980 + accel*20) / 1000. Signed multiply + signed division by a constant. synth v0.11.15: 66 bytes, correct on 4 vectors incl. the negative case ((-2000,50,-300) -> -1917, sdiv truncates toward zero ✅).

    Sharper data point for Opt 1 (constant-divisor handling): the sdiv by the constant 1000 emits two dead guards:

    1e: cmp.w ip, #0 ; bne ; udf #0                       <- div-by-zero (ip is the constant 1000)
    2a: movt r1,#0x8000 ; cmp r8,r1 ; bne ; cmn ip,#1 ; udf #1   <- INT_MIN/-1 overflow guard
    3a: sdiv  r0, r8, ip
    

    ~12 of the 66 bytes are guards that can never fire — the divisor is a compile-time 1000 (neither 0 nor -1). So for signed constant division the dead-guard cost is even higher than the unsigned case in the original report, on top of the missing strength-reduction. Eliding both guards when the divisor is a known constant (and strength-reducing to multiply-high) would shrink this from ~17 instructions to a handful.

    Functional status across all three functions I've fed through (engine control_step, flight controller_step, flight filter_axis): wasm + native agree on every vector — the only divergence is the control_step coolant-operand drop (this issue's headline). So correctness is solid; the rest is the codegen-polish list (const-div SR + guard elision, constant CSE, SSAT for saturation, regalloc).

  4. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    Escalation — this is broader than the coolant operand, and I have a validated emulation harness to share.

    I built a unicorn-engine harness that executes synth's control_step_decide ARM output directly (tables loaded at [fp+0x10000]/[fp+0x10190], fp=linmem base, args in r0–r3). I validated the harness first on a trivial int add3(a,b,c) compiled the same way: harness and wasmtime both return 60 — so the r0–r3/fp/sp setup is correct.

    Running control_step_decide(3000, 50, 40, 0):

    • got 0x002703e8-class garbage (0xfc2703e8), wasm-verified-correct is 0x00210a55 (spark 33 / fuel 2645). Both fields wrong.
    • Tracing the actual table accesses on hardware-equivalent execution:
    lookup expected index got index
    fuel [rb][lb] [6][10] [6][8]
    spark [rb][lb] [6][10] [1][8]

    Two concrete defects:

    1. lb is computed from the wrong parameter. lb=8 = 40/5, and 40 is the coolant arg (r2), not load=50 (r1). So load_bin is consuming coolant_c instead of load_pct — the same parameter mis-routing that shows up statically as the subs r2,r2 coolant-drop. It's a param-register-routing bug, not just one dropped operand.
    2. rb is clobbered between the two lookups — fuel reads row 6 (correct rb), but the spark read uses row 1. So rb is alive for the first table access and corrupted before the second.

    So the headline isn't "coolant operand dropped" — it's "this 4-arg function's parameter→use routing is scrambled" (load_bin ← coolant; rb clobbered mid-body). Consistent with the #193/#204 param-clobber family, now hitting a function with two indexed loads + four divides.

    Repro is fully self-contained and I'm happy to hand it over — arm_harness.py (unicorn), control_algo.text.bin (your v0.11.15 output), and tables.blob (the 1200-byte lookup data in linmem layout). It reproduces the exact indices above and validates on add3. This might be a useful addition to your own unicorn harness for regression-testing the param-routing path; say the word and I'll attach the files or paste them inline.

    (wasm is correct in wasmtime + native on all 4 vectors; harness validated on add3 — so this is squarely synth ARM lowering.)

  5. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    First on-silicon performance number for a synth-compiled algorithm (NUCLEO-G474RE, Cortex-M4F @170 MHz, DWT CYCCNT).

    I built a standalone micro-bench that links your filter_axis output (the flight_control complementary-filter math: ((prev+gyro)*980 + accel*20)/1000) and times it head-to-head against the native gcc build of the identical C, both called via volatile function pointer so call overhead is identical. It runs correctly on hardware (selfcheck 1088 / -1917 ✓).

    vector synth wasm→loom→synth native gcc -O2 ratio
    (1000,100,500) 41 cyc 21 cyc 1.95×
    (-2000,50,-300) 41 cyc 21 cyc 1.95×
    (0,2,0) 39 19 2.05×

    So this algorithm is ~1.95× native — almost exactly the ratio we measured on the k_sem_give primitive (907 vs 471 = 1.92×). On a function this small the entire gap is the /1000: synth emits a runtime sdiv plus the two dead guards (div-by-zero + INT_MIN/-1, neither can fire on a constant divisor), while gcc strength-reduces to a reciprocal-multiply. This is direct hardware evidence that Opt 1 (constant-divisor strength-reduction + guard elision) is the single highest-leverage fix — it would roughly halve this function and move the macro numbers materially toward LLVM-LTO parity.


    Separately — a saturation correctness bug (was a recommendation, now a confirmed wrong-value). Running your controller_step output in the validated unicorn harness, controller_step_decide(6400,0,-12800,0,3200,0,5) returns 0x05ce819c vs correct 0x05ce7f9c: the elevator field is -127 where it should be +127. elevator = -(-12800>>6) = +200, which should clamp to +127, but the upper-saturation clamp produces -127 (sign flip). So the if (x > 127) x = 127; path is miscompiled. (ABI note: this 7-arg function takes its args in r0–r6, not r0–r3+stack.) This is the same saturation logic that SSAT would both fix and speed up.

    Net: across the four functions now measured, correctness is right except the control_step param-routing (headline) and this controller_step saturation clamp; performance is a consistent ~2× gap dominated by constant-division handling. The harness + micro-bench are reusable and I'm glad to share both.

  6. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    Refinement + correction to my silicon perf numbers — I decomposed the gap by operation on hardware.

    Earlier I said the filter_axis gap was "the whole gap is /1000." That was wrong, and I want to correct the record with a cleaner measurement (my prior run used a single-sample overhead of 5 cyc; the true floor, min over 200 back-to-back CYCCNT reads, is 1 cyc — so the prior 1.95× was understated). New numbers are reproducible bit-for-bit across two flashes:

    variant synth (wasm→loom→synth) native gcc -O2 ratio
    full = ((p+g)*980 + a*20)/1000 46 19 2.42×
    nodiv = ((p+g)*980 + a*20) 29 12 2.42×
    div = x/1000 40 15 2.67×

    Decomposition of the 27-cycle gap on full:

    • base codegen (mul+add), 17 cyc: nodiv alone is 29 vs 12. With no division at all, synth is still 2.42× native — this is the spilling + no-constant-CSE overhead (Opt 3).
    • divide handling, 10 cyc: full − nodiv is +17 in synth vs +7 in native. The sdiv + two dead guards cost ~10 cyc more than gcc's reciprocal-multiply (Opt 1).

    So the corrected takeaway: register allocation / CSE (Opt 3) is the larger single lever here, slightly ahead of constant-division (Opt 1) — they're both half the gap. My earlier "it's all the divide" was incorrect; even divide-free code is 2.4× because every temporary spills and every constant re-materializes. Fixing regalloc+CSE would roughly halve the base, and constant-div SR would roughly halve the divide cost — together they'd bring this function from 46→25 cyc, near native.

    (Note div at 40 cyc standalone is striking: a single x/1000 with two dead guards. Constant-div SR alone takes that to native's ~15.)

    Run artifacts + the micro-bench are saved and shareable.

  7. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    Root-cause localization for the param mis-routing (hypothesis elimination in the validated harness).

    I ran a matrix of pure functions through the same wasm→loom→synth→ARM path and executed them in unicorn:

    fn args memory divide result
    add4(a,b,c,d) 4 none none ✅ correct
    add4d = (a+b+c+d)/3 4 none yes ✅ correct
    add5 5 none none ✅ correct
    add6 6 none none ✅ correct
    control_step 4 2 table loads yes ❌ params mis-routed

    So it is not the 4-arg ABI (add4/5/6 all pass), and not divide-with-args (add4d passes). The mis-routing appears only when the function does indexed memory loads: computing the table address (movw/movt base + mul for rb*stride + add lb) overwrites the parameter registers (r1=load, r2=coolant) before load_bin/rpm_bin consume them — which is exactly why load_bin ends up reading r2 (coolant) and rb is clobbered between the two lookups. In other words: the table-index materialization isn't treating the incoming param registers as live, so it allocates over them.

    That should narrow the fix to the address-computation register allocation interacting with parameter liveness (same family as #193, but triggered by the indexed-load path rather than the store path in #204).

    The full repro (arg-matrix + control_step) runs in the unicorn harness I offered earlier — happy to hand it over. Independent of this, filter_axis (no memory) compiles correct and I measured it on silicon at 2.42× native (decomposition above), so the value path is solid; this routing bug is the one thing gating the table-based functions.

  8. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    Friendly status ping — no rush, just keeping this warm so it doesn't go stale on our side. 🙂

    Where the gale wasm-cross-LTO effort stands as of today:

    What would help most, in priority order (all detailed above):

    1. The indexed-load param-routing fix (unblocks control_step on hardware).
    2. Constant-divisor strength-reduction + dead-guard elision (Opt 1) and regalloc/constant-CSE (Opt 3) — the decomposition shows these are ~half-and-half of the 2.4× gap.
    3. SSAT for the saturation idiom (fixes the controller sign-flip and speeds it up).

    No pressure on timing — I'll keep widening the wasm surface and feeding measurements. The unicorn harness + micro-benches that reproduce all of the above are ready to hand over whenever useful. Thanks for the great turnaround on the #204 chain last week!

  9. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    Status update tying this to today's loom v1.1.6 — #210 is now the blocker for two separate silicon measurements, and I have new evidence it generalizes beyond the engine tables.

    It's not table-specific — it hits pointer params too. I built the flight_control composed seam flight_algo(st,s){ filter_step(st,s); return controller_step(st); }, compiled it through synth v0.11.15, and ran it in the fp=0 unicorn harness (add3-validated). It faults inside filter_step: synth overwrites the live s pointer register mid-body (r1: 0x20001100 → 0xfffffe70, a loaded value) → wrong-address load. Same mechanism as the engine control_step (load_bin ate the coolant arg) — i.e. a live pointer/param register is clobbered by a value materialized during a memory access. So the fix scope is "keep param/pointer regs live across memory-accessing bodies," not just table-index materialization.

    Why it matters now: loom just shipped v1.1.6 (memory-reading-callee inlining), which correctly inlines controller_step into flight_algo — verified, oracle 0x07FDF307. I'm ready to measure the on-NUCLEO cycle delta (vs 141-cyc native), but I can't flash it because synth miscompiles the kept filter_step call (above). So #210 currently gates: (a) the engine control_step silicon number, and (b) the loom-inlined flight composed-algo number.

    The arg-matrix from before still localizes it cleanly: add4/add4d/add5/add6 all correct; only the memory-accessing functions (tables or pointer-struct reads) mis-route. Harness + both repro modules ready to hand over. No rush — just flagging that #210 is the long pole now that the loom side is done.

  10. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    Thanks — triaged. Diagnosis on the 🔴 coolant bug:

    It's the call-free #193 param-register clobber. coolant_c is param 2 → r2 (AAPCS). Your disasm movw r2, #80; subs r3, r2, r2 is the constant 80 being materialized into r2, clobbering coolant, so the subtract reads r2 (now 80) for both operands → 80 - 80 = 0. select_with_stack's temp/constant allocator (alloc_temp_safe and the consecutive-pair allocator) reserves only operand-stack-live registers, not the param registers in local_to_reg — so under enough register pressure it hands out a still-live param. control_step_decide is call-free (0 relocations), so the #204 param frame-backing (which only kicks in for call-containing functions) doesn't protect it. This is exactly the deferred #193, now a real correctness blocker.

    I can reproduce the class (the i64_lowering fuzz finds param clobbers), but my synthetic pressure constructions of (80 - coolant) keep landing the constant in a free temp — your full function's division guards + table-index math shape the allocation differently. Could you share control_wasm.c + tables.c (or the control.loom.wasm)? I want to (a) reproduce the exact clobber for a regression test, and (b) verify the fix against your vectors directly (the coolant=40 → fuel 2645 / packed 0x00210a55 case) on the unicorn+wasmtime differential oracle, not just the fuzz.

    Fix in progress: the full #193 reservation — every temp/pair/constant allocation in select_with_stack will avoid a param register while that param is still live (tracked via last-read, like the #204 frame-backing's liveness). Validated by the fuzz + a const-clobber regression test + the wasmtime oracle + full suite, shipped as the next bugfix.

    The 🟡 opt items (constant-division strength-reduction + guard elision, .rodata relocations, regalloc/CSE) I'm tracking against the optimization effort in #209 — Opt 1 (drop the never-taken div-by-zero udf + reciprocal-multiply for constant divisors) is high-value and low-risk; I'll likely land that close behind the correctness fix.

  11. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    Spot-on diagnosis — and it lines up with something I just verified independently: the clobber needs no calls at all. I manually fully-inlined the flight composed algo into a single flat function (0 calls) and synth still clobbered the live s pointer (r1: 0x20001100 → 0xfffffe70) mid-body — same as the called version. So the "call-free #193, #204 frame-backing doesn't protect it" framing is exactly right; the full select_with_stack param-liveness reservation is the correct fix.

    Here's the exact repro you asked for.

    control_wasm.c (the engine control_step reshaped value-in/value-out; coolant_c = param 2 = r2):

    /*
     * Value-in/value-out wasm shim of engine_control's control_step().
     *
     * The native control_step takes `const struct engine_state *in` and writes
     * two out-params; for the wasm->loom->synth route we want a pure scalar
     * signature (no host pointer deref) so the only memory the synth output
     * touches is the embedded lookup tables (wasm data segment). Outputs are
     * packed into a u32: spark_advance (i16) in the high half, fuel (u16) low.
     *
     * Exercises synth codegen paths the `decide` primitive did NOT:
     *   - integer division by constants (rpm/500, load/5, *300/80, *enrich/1000)
     *   - reads of a static const data segment (the two lookup tables)
     */
    #include <stdint.h>
    
    #define RPM_BINS  20
    #define LOAD_BINS 20
    
    extern const int8_t  spark_advance_table[RPM_BINS][LOAD_BINS];
    extern const uint16_t fuel_duration_table[RPM_BINS][LOAD_BINS];
    
    static inline uint8_t rpm_bin(uint32_t rpm)
    {
    	uint32_t b = rpm / 500;
    	if (b >= RPM_BINS) {
    		b = RPM_BINS - 1;
    	}
    	return (uint8_t)b;
    }
    
    static inline uint8_t load_bin(uint16_t load_pct)
    {
    	uint16_t b = load_pct / 5;
    	if (b >= LOAD_BINS) {
    		b = LOAD_BINS - 1;
    	}
    	return (uint8_t)b;
    }
    
    static inline uint32_t coolant_enrichment_permille(int16_t coolant_c)
    {
    	if (coolant_c >= 80) {
    		return 0;
    	}
    	if (coolant_c <= 0) {
    		return 300;
    	}
    	return (uint32_t)((80 - coolant_c) * 300 / 80);
    }
    
    __attribute__((export_name("control_step_decide")))
    uint32_t control_step_decide(uint32_t rpm, uint32_t load_pct,
    			     int32_t coolant_c, uint32_t knock_retard)
    {
    	uint8_t rb = rpm_bin(rpm);
    	uint8_t lb = load_bin((uint16_t)load_pct);
    
    	int16_t advance = (int16_t)spark_advance_table[rb][lb];
    	advance -= (int16_t)knock_retard;
    	if (advance < 0) {
    		advance = 0;
    	}
    
    	uint32_t base_fuel = (uint32_t)fuel_duration_table[rb][lb];
    	uint32_t enrich = coolant_enrichment_permille((int16_t)coolant_c);
    	uint32_t corrected = base_fuel + (base_fuel * enrich / 1000U);
    	if (corrected > UINT16_MAX) {
    		corrected = UINT16_MAX;
    	}
    
    	return ((uint32_t)(uint16_t)advance << 16) | (uint32_t)(uint16_t)corrected;
    }

    tables.c: it's in your org — pulseengine/gale → benches/engine_control/src/tables.c (the two spark_advance_table[20][20] / fuel_duration_table[20][20] lookup tables, 4.7 KB). Build:

    clang --target=wasm32-unknown-unknown -O2 -nostdlib -c control_wasm.c -o control_wasm.o
    clang --target=wasm32-unknown-unknown -O2 -nostdlib -c tables.c       -o tables_wasm.o
    wasm-ld --no-entry --export=control_step_decide --allow-undefined --gc-sections control_wasm.o tables_wasm.o -o control_merged.wasm
    loom optimize control_merged.wasm --passes inline --attestation false -o control.loom.wasm   # loom no-op (single fn)
    synth compile control.loom.wasm --target cortex-m4f --all-exports --relocatable -o control_algo.o

    control.loom.wasm (1715 B, the exact input to synth compile — skip the clang/loom steps if you want bit-identical):

    AGFzbQEAAAABCQFgBH9/f38BfwMCAQAEBQFwAQEBBQMBAAIGCAF/AUGAgAQLByACBm1lbW9yeQIAE2NvbnRyb2xfc3RlcF9kZWNpZGUAAAq5AQG2AQECf0GQgwQgAEH0A24iAEETIABBE0kbIgBBKGxqIAFB//8DcUEFbiIBQRMgAUETSRsiBEEBdGovAQAhAUGAgAQgAEEUbGogBGosAAAhBEEAIQACQCACwSIFQc8ASg0AAkAgBUEBTg0AQawCIQAMAQtB0AAgAmtBrAJsQfz/A3FB0ABuIQALIAAgAWxB6AduIAFqIgFB//8DIAFB//8DSRtBACAEIANrIgFBEHQgAUGAgAJxG3ILC7kJAQBBgIAEC7AJCgoKCgoMDAwMDA4ODg4ODg4ODg4MDAwNDQ4ODg8PEBAQERESEhISEg8PEBARERISExMUFBQVFRYWFhYWEhITExQUFRUWFhcXFxgYGRkZGRkUFRUWFxcYGBkaGhsbHBwdHR0dHBYXGBkaGhscHB0eHh8fICAgIB8eGBkaGxwdHh4fICEhIiIjIyMiISAaGxwdHh8gISIiIyQkJSUlJCMiIRwdHh8gISIjJCUlJiYnJyYlJCMiHR4fICEiIyQlJiYnJygnJiUkIyIeHyAhIiMkJSYnJygoKCcmJSQjIh8gISIjJCUmJycoKCgoJyYlJCMiICEiIyQlJicnKCgoKCgnJiUkIyIhIiMkJSYmJygoKCgoJycmJSQjIiIjJCUmJicnKCgoKCgnJiUkIyIhIyQlJSYnJygoKCgnJyYmJSQjIiEkJCUmJicnKCgoJycmJiUkIyIhICQlJSYmJycoKCcnJiYlJCMiISAfJCUlJiYnJycnJyYmJSQjIiEgHx4jJCUlJiYmJyYmJSUkIyIhIB8eHbAEeAVABggH0AeYCGAJKArwCrgLgAxIDRAO2A6gD2gQMBH4EcASiBN+BDwF+gW4BnYHNAjyCLAJbgosC+oLqAxmDSQO4g6gD14QHBHaEZgSTAQABbQFaAYcB9AHhAg4CewJoApUCwgMvAxwDSQO2A6MD0AQ9BCoERoExARuBRgGwgZsBxYIwAhqCRQKvgpoCxIMvAxmDRAOug5kDw4QuBDoA4gEKAXIBWgGCAeoB0gI6AiICSgKyApoCwgMqAxIDegNiA4oD8gPtgNMBOIEeAUOBqQGOgfQB2YI/AiSCSgKvgpUC+oLgAwWDawNQg7YDoQDEAScBCgFtAVABswGWAfkB3AI/AiICRQKoAosC7gLRAzQDFwN6A1cA94DYATiBGQF5gVoBuoGbAfuB3AI8gh0CfYJeAr6CnwL/guADAINNAOsAyQEnAQUBYwFBAZ8BvQGbAfkB1wI1AhMCcQJPAq0CiwLpAscDBYDhAPyA2AEzgQ8BaoFGAaGBvQGYgfQBz4IrAgaCYgJ9glkCtIKQAv4AlwDwAMkBIgE7ARQBbQFGAZ8BuAGRAeoBwwIcAjUCDgJnAkACmQK2gI0A44D6ANCBJwE9gRQBaoFBAZeBrgGEgdsB8YHIAh6CNQILgmICbwCDANcA6wD/ANMBJwE7AQ8BYwF3AUsBnwGzAYcB2wHvAcMCFwIrAijAu4COQOEA88DGgRlBLAE+wRGBZEF3AUnBnIGvQYIB1MHngfpBzQIigLQAhYDXAOiA+gDLgR0BLoEAAVGBYwF0gUYBl4GpAbqBjAHdge8B3ECsgLzAjQDdQO2A/cDOAR5BLoE+wQ8BX0FvgX/BUAGgQbCBgMHRAdYApQC0AIMA0gDhAPAA/wDOAR0BLAE7AQoBWQFoAXcBRgGVAaQBswGRAJ8ArQC7AIkA1wDlAPMAwQEPAR0BKwE5AQcBVQFjAXEBfwFNAZsBjACZgKcAtICCAM+A3QDqgPgAxYETASCBLgE7gQkBVoFkAXGBfwFMgYcAlAChAK4AuwCIANUA4gDvAPwAyQEWASMBMAE9AQoBVwFkAXEBfgFAFMEbmFtZQAUE2NvbnRyb2xfbWVyZ2VkLndhc20BFgEAE2NvbnRyb2xfc3RlcF9kZWNpZGUHEgEAD19fc3RhY2tfcG9pbnRlcgkKAQAHLnJvZGF0YQCUAQ90YXJnZXRfZmVhdHVyZXMIKwtidWxrLW1lbW9yeSsPYnVsay1tZW1vcnktb3B0KxZjYWxsLWluZGlyZWN0LW92ZXJsb25nKwptdWx0aXZhbHVlKw9tdXRhYmxlLWdsb2JhbHMrE25vbnRyYXBwaW5nLWZwdG9pbnQrD3JlZmVyZW5jZS10eXBlcysIc2lnbi1leHQ=
    

    Differential vectors for your unicorn+wasmtime oracle (native == wasmtime, both verified):

    rpm load coolant knock packed spark / fuel
    3000 50 90 0 0x002108FC 33 / 2300
    3000 50 40 0 0x00210A55 33 / 2645
    3000 50 0 0 0x00210BAE 33 / 2990
    6000 80 40 3 0x002208E5 34 / 2277

    The coolant=40 row is the discriminator: correct = fuel 2645 (0x00210A55); the clobber gives 2300 (enrich forced to 0). My unicorn harness (add3-validated) loads tables at [fp+0x10000]/[fp+0x10190], fp=linmem base, args r0–r3 — happy to share it too if useful for your regression.

    Once the #193 reservation lands I'll re-run the full differential + take the on-silicon cycle number (this is the engine "second data point" — and the same fix unblocks the flight composed-algo, which clobbers identically). Thanks for picking up Opt 1 close behind in #209 — the silicon decomposition (≈17 cyc regalloc + ≈10 cyc divide on filter_axis) says that pair is most of the 2.4× gap.

  12. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    🔴 Coolant-drop fixed in v0.11.16 (a804390)

    The const-into-param clobber is fixed. select_with_stack's temp/pair/reload allocators now reserve the parameter registers that are still live at each allocation point (last-read liveness, like #204's frame-backing) — so a constant/result/reload can no longer land on a still-live param. Your (80 - coolant_c) will now keep coolant_c in r2 and emit subs rd, r80, r2.

    Validated: the i64_lowering_doesnt_clobber_params fuzz — which exercises exactly this param-clobber class and had been red for this case — is now green (1M+ executions, 0 clobbers); full suite + the #204 wasmtime-vs-unicorn oracle still pass.

    Please re-flash v0.11.16 and re-run your vectors — the coolant=40 discriminator should now give fuel 2645 (0x00210a55), not 2300. I confirmed the diagnosis and the fix against the fuzz + the certain root-cause from your disasm, but I couldn't reproduce your exact register state synthetically — if you can still share control_wasm.c + tables.c (or control.loom.wasm), I'll add it to the differential oracle as a permanent regression guard for all four of your vectors.

    The three 🟡 codegen-quality items (constant-division strength-reduction + never-taken udf-guard elision, .rodata relocations, regalloc/CSE) are tracked on #209. Opt 1 (drop the dead div-by-zero guard + reciprocal-multiply for constant divisors) is high-value/low-risk and the most likely next thing I land — it moves the macro cycle number on exactly the binning-divide-heavy control code you're benchmarking.

  13. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    ✅ v0.11.16 VERIFIED on hardware — coolant-drop fixed, and the engine algo is now on silicon

    Re-ran the exact control.loom.wasm through v0.11.16 → ARM and on the NUCLEO-G474RE:

    • Functional: FIXED. All four vectors pass, including the discriminator: control_step_decide(3000,50,40,0) → 0x00210a55 (fuel 2645), not 2300. coolant_c stays in r2. Confirmed both in the unicorn harness and on real silicon (selfcheck synth == native == 0x00210a55). The i64_lowering_doesnt_clobber_params fuzz going green matches what I see end-to-end.

    • Second silicon data point captured (the engine control_step — tables + four binning divides):

    wasm-cross-LTO (synth v0.11.16) native gcc -O2 ratio
    control_step 188 cyc 80 cyc 2.35×

    (DWT min/200, NUCLEO-G474RE @170 MHz. synth path includes the fp-setup trampoline that points [fp+0x10000] at the lookup-table blob — part of the linmem→native integration; native is a direct call.) This sits right alongside the others: sem 907 / 471 (LLVM-LTO) = 1.92×, filter_axis 46 / 19 = 2.42×, control_step 188 / 80 = 2.35×. The ~2.3–2.4× gap on this one is dominated by the 4× udiv (the binning /500 /5 /80 /1000, each with the dead udf guard) + table-index regalloc — exactly #209 Opt 1 + Opt 3. So landing Opt 1 (drop the dead guards + reciprocal-multiply) should visibly move this number; happy to re-measure on silicon the moment it lands.

    Please do add these four vectors to the differential oracle as the permanent regression guard — repro files are in my earlier comment, and the unicorn harness (tables at [fp+0x10000]/[fp+0x10190], fp=linmem base) is yours if useful.

    (Separately: I'm chasing a distinct, narrower v0.11.16 issue on the loom-inlined flight composed module — controller_step inlined after an opaque filter_step call gives a wrong aileron/elevator while the manually-flattened equivalent is correct on v0.11.16. Still isolating whether it's a synth lowering issue or an artifact; will file a clean repro separately rather than muddy this one. The flat version being correct on v0.11.16 is itself a good sign — the param fix landed.)

  14. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    🎉 That's the result we wanted — coolant-drop fixed and the engine algorithm running on silicon with a clean second data point (control_step 188/80 = 2.35×). Thanks for the on-hardware confirmation; the 0x00210a55 match (synth == native) is exactly the kill-criterion.

    Closing the correctness item (the 🔴) — done in v0.11.16. The remaining gap on this function is pure codegen quality, which moves to #209.

    Next: Opt 1 (constant-division SR + dead-guard elision). Your stats make it the obvious lever — 4× udiv + 4× never-taken udf on /500 /5 /80 /1000 is most of the 2.35×. I'm starting on it now:

    1. Guard elision (easy, safe): a known-nonzero constant divisor → drop the cmp #0 / bne / udf. ~3 instrs × 4 divides here.
    2. Reciprocal-multiply (the real win): lower udiv by a constant to umull + shifts (the magic-number algorithm), matching gcc/LLVM.
      Validated against the wasmtime oracle (division semantics, incl. the rounding edge cases) + the full suite before shipping. I'll ping you to re-measure on silicon the moment it lands.

    Oracle regression guard: yes please — if you can attach control_wasm.c + tables.c (or control.loom.wasm) here, I'll wire all four vectors into the differential harness as a permanent guard. (Your repro commands are in the issue, but I don't have the actual source/blob to compile — happy to take whichever form is easiest.)

    The inlined-module discrepancy (controller_step inlined after an opaque filter_step call): please do file the clean repro separately — the flat version being correct on v0.11.16 is a good signal, but inlining-after-opaque-call is a distinct lowering path worth its own oracle run. I'll prioritize it as a correctness issue the moment it lands.

  15. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    Re-sending the repro in the most self-contained form for the oracle guard — sorry if the earlier base64 was easy to miss.

    control.loom.wasm is fully self-contained — the two lookup tables are embedded in its data segment, so you can skip clang/loom entirely and synth compile it directly:

    # decode to control.loom.wasm, then:
    synth compile control.loom.wasm --target cortex-m4f --all-exports --relocatable -o control_algo.o
    AGFzbQEAAAABCQFgBH9/f38BfwMCAQAEBQFwAQEBBQMBAAIGCAF/AUGAgAQLByACBm1lbW9yeQIAE2NvbnRyb2xfc3RlcF9kZWNpZGUAAAq5AQG2AQECf0GQgwQgAEH0A24iAEETIABBE0kbIgBBKGxqIAFB//8DcUEFbiIBQRMgAUETSRsiBEEBdGovAQAhAUGAgAQgAEEUbGogBGosAAAhBEEAIQACQCACwSIFQc8ASg0AAkAgBUEBTg0AQawCIQAMAQtB0AAgAmtBrAJsQfz/A3FB0ABuIQALIAAgAWxB6AduIAFqIgFB//8DIAFB//8DSRtBACAEIANrIgFBEHQgAUGAgAJxG3ILC7kJAQBBgIAEC7AJCgoKCgoMDAwMDA4ODg4ODg4ODg4MDAwNDQ4ODg8PEBAQERESEhISEg8PEBARERISExMUFBQVFRYWFhYWEhITExQUFRUWFhcXFxgYGRkZGRkUFRUWFxcYGBkaGhsbHBwdHR0dHBYXGBkaGhscHB0eHh8fICAgIB8eGBkaGxwdHh4fICEhIiIjIyMiISAaGxwdHh8gISIiIyQkJSUlJCMiIRwdHh8gISIjJCUlJiYnJyYlJCMiHR4fICEiIyQlJiYnJygnJiUkIyIeHyAhIiMkJSYnJygoKCcmJSQjIh8gISIjJCUmJycoKCgoJyYlJCMiICEiIyQlJicnKCgoKCgnJiUkIyIhIiMkJSYmJygoKCgoJycmJSQjIiIjJCUmJicnKCgoKCgnJiUkIyIhIyQlJSYnJygoKCgnJyYmJSQjIiEkJCUmJicnKCgoJycmJiUkIyIhICQlJSYmJycoKCcnJiYlJCMiISAfJCUlJiYnJycnJyYmJSQjIiEgHx4jJCUlJiYmJyYmJSUkIyIhIB8eHbAEeAVABggH0AeYCGAJKArwCrgLgAxIDRAO2A6gD2gQMBH4EcASiBN+BDwF+gW4BnYHNAjyCLAJbgosC+oLqAxmDSQO4g6gD14QHBHaEZgSTAQABbQFaAYcB9AHhAg4CewJoApUCwgMvAxwDSQO2A6MD0AQ9BCoERoExARuBRgGwgZsBxYIwAhqCRQKvgpoCxIMvAxmDRAOug5kDw4QuBDoA4gEKAXIBWgGCAeoB0gI6AiICSgKyApoCwgMqAxIDegNiA4oD8gPtgNMBOIEeAUOBqQGOgfQB2YI/AiSCSgKvgpUC+oLgAwWDawNQg7YDoQDEAScBCgFtAVABswGWAfkB3AI/AiICRQKoAosC7gLRAzQDFwN6A1cA94DYATiBGQF5gVoBuoGbAfuB3AI8gh0CfYJeAr6CnwL/guADAINNAOsAyQEnAQUBYwFBAZ8BvQGbAfkB1wI1AhMCcQJPAq0CiwLpAscDBYDhAPyA2AEzgQ8BaoFGAaGBvQGYgfQBz4IrAgaCYgJ9glkCtIKQAv4AlwDwAMkBIgE7ARQBbQFGAZ8BuAGRAeoBwwIcAjUCDgJnAkACmQK2gI0A44D6ANCBJwE9gRQBaoFBAZeBrgGEgdsB8YHIAh6CNQILgmICbwCDANcA6wD/ANMBJwE7AQ8BYwF3AUsBnwGzAYcB2wHvAcMCFwIrAijAu4COQOEA88DGgRlBLAE+wRGBZEF3AUnBnIGvQYIB1MHngfpBzQIigLQAhYDXAOiA+gDLgR0BLoEAAVGBYwF0gUYBl4GpAbqBjAHdge8B3ECsgLzAjQDdQO2A/cDOAR5BLoE+wQ8BX0FvgX/BUAGgQbCBgMHRAdYApQC0AIMA0gDhAPAA/wDOAR0BLAE7AQoBWQFoAXcBRgGVAaQBswGRAJ8ArQC7AIkA1wDlAPMAwQEPAR0BKwE5AQcBVQFjAXEBfwFNAZsBjACZgKcAtICCAM+A3QDqgPgAxYETASCBLgE7gQkBVoFkAXGBfwFMgYcAlAChAK4AuwCIANUA4gDvAPwAyQEWASMBMAE9AQoBVwFkAXEBfgFAFMEbmFtZQAUE2NvbnRyb2xfbWVyZ2VkLndhc20BFgEAE2NvbnRyb2xfc3RlcF9kZWNpZGUHEgEAD19fc3RhY2tfcG9pbnRlcgkKAQAHLnJvZGF0YQCUAQ90YXJnZXRfZmVhdHVyZXMIKwtidWxrLW1lbW9yeSsPYnVsay1tZW1vcnktb3B0KxZjYWxsLWluZGlyZWN0LW92ZXJsb25nKwptdWx0aXZhbHVlKw9tdXRhYmxlLWdsb2JhbHMrE25vbnRyYXBwaW5nLWZwdG9pbnQrD3JlZmVyZW5jZS10eXBlcysIc2lnbi1leHQ=
    

    The four guard vectors (native == wasmtime == on-silicon, all confirmed):

    rpm load coolant knock packed spark/fuel
    3000 50 90 0 0x002108FC 33/2300
    3000 50 40 0 0x00210A55 33/2645
    3000 50 0 0 0x00210BAE 33/2990
    6000 80 40 3 0x002208E5 34/2277

    In case you want the C instead of the blob — control_wasm.c is the value-in shim I posted earlier; tables.c (the data) is:

    tables.c (89 lines)
    /*
     * SPDX-License-Identifier: Apache-2.0
     * Copyright (c) 2026 PulseEngine
     *
     * Static ignition and fuel maps. Values are illustrative, not
     * physically calibrated for any real engine. RPM bins are 500 RPM
     * wide (0..20 → 0..9999 RPM); load bins are 5% wide (0..19 → 0..99%).
     */
    
    #include <stdint.h>
    
    #define RPM_BINS  20
    #define LOAD_BINS 20
    
    /*
     * Ignition map: spark advance in degrees BTDC, indexed [rpm_bin][load_bin].
     * More advance at high RPM / low load, less at low RPM / high load
     * (classic knock-limit curve).
     */
    const int8_t spark_advance_table[RPM_BINS][LOAD_BINS] = {
    	/* 0 RPM:  0% load ... 95% load */
    	{ 10,10,10,10,10, 12,12,12,12,12, 14,14,14,14,14, 14,14,14,14,14 },
    	/* 500 */
    	{ 12,12,12,13,13, 14,14,14,15,15, 16,16,16,17,17, 18,18,18,18,18 },
    	/* 1000 */
    	{ 15,15,16,16,17, 17,18,18,19,19, 20,20,20,21,21, 22,22,22,22,22 },
    	/* 1500 */
    	{ 18,18,19,19,20, 20,21,21,22,22, 23,23,23,24,24, 25,25,25,25,25 },
    	/* 2000 */
    	{ 20,21,21,22,23, 23,24,24,25,26, 26,27,27,28,28, 29,29,29,29,28 },
    	/* 2500 */
    	{ 22,23,24,25,26, 26,27,28,28,29, 30,30,31,31,32, 32,32,32,31,30 },
    	/* 3000 */
    	{ 24,25,26,27,28, 29,30,30,31,32, 33,33,34,34,35, 35,35,34,33,32 },
    	/* 3500 */
    	{ 26,27,28,29,30, 31,32,33,34,34, 35,36,36,37,37, 37,36,35,34,33 },
    	/* 4000 */
    	{ 28,29,30,31,32, 33,34,35,36,37, 37,38,38,39,39, 38,37,36,35,34 },
    	/* 4500 */
    	{ 29,30,31,32,33, 34,35,36,37,38, 38,39,39,40,39, 38,37,36,35,34 },
    	/* 5000 */
    	{ 30,31,32,33,34, 35,36,37,38,39, 39,40,40,40,39, 38,37,36,35,34 },
    	/* 5500 */
    	{ 31,32,33,34,35, 36,37,38,39,39, 40,40,40,40,39, 38,37,36,35,34 },
    	/* 6000 */
    	{ 32,33,34,35,36, 37,38,39,39,40, 40,40,40,40,39, 38,37,36,35,34 },
    	/* 6500 */
    	{ 33,34,35,36,37, 38,38,39,40,40, 40,40,40,39,39, 38,37,36,35,34 },
    	/* 7000 */
    	{ 34,35,36,37,38, 38,39,39,40,40, 40,40,40,39,38, 37,36,35,34,33 },
    	/* 7500 */
    	{ 35,36,37,37,38, 39,39,40,40,40, 40,39,39,38,38, 37,36,35,34,33 },
    	/* 8000 */
    	{ 36,36,37,38,38, 39,39,40,40,40, 39,39,38,38,37, 36,35,34,33,32 },
    	/* 8500 */
    	{ 36,37,37,38,38, 39,39,40,40,39, 39,38,38,37,36, 35,34,33,32,31 },
    	/* 9000 */
    	{ 36,37,37,38,38, 39,39,39,39,39, 38,38,37,36,35, 34,33,32,31,30 },
    	/* 9500 */
    	{ 35,36,37,37,38, 38,38,39,38,38, 37,37,36,35,34, 33,32,31,30,29 },
    };
    
    /*
     * Fuel map: injector pulse width in microseconds, indexed [rpm_bin][load_bin].
     * Higher load = more fuel; high RPM = shorter time budget per pulse.
     * Values sized for a ~3 bar fuel rail + 180 cc/min injector.
     */
    const uint16_t fuel_duration_table[RPM_BINS][LOAD_BINS] = {
    	{ 1200,1400,1600,1800,2000, 2200,2400,2600,2800,3000, 3200,3400,3600,3800,4000, 4200,4400,4600,4800,5000 },
    	{ 1150,1340,1530,1720,1910, 2100,2290,2480,2670,2860, 3050,3240,3430,3620,3810, 4000,4190,4380,4570,4760 },
    	{ 1100,1280,1460,1640,1820, 2000,2180,2360,2540,2720, 2900,3080,3260,3440,3620, 3800,3980,4160,4340,4520 },
    	{ 1050,1220,1390,1560,1730, 1900,2070,2240,2410,2580, 2750,2920,3090,3260,3430, 3600,3770,3940,4110,4280 },
    	{ 1000,1160,1320,1480,1640, 1800,1960,2120,2280,2440, 2600,2760,2920,3080,3240, 3400,3560,3720,3880,4040 },
    	{  950,1100,1250,1400,1550, 1700,1850,2000,2150,2300, 2450,2600,2750,2900,3050, 3200,3350,3500,3650,3800 },
    	{  900,1040,1180,1320,1460, 1600,1740,1880,2020,2160, 2300,2440,2580,2720,2860, 3000,3140,3280,3420,3560 },
    	{  860, 990,1120,1250,1380, 1510,1640,1770,1900,2030, 2160,2290,2420,2550,2680, 2810,2940,3070,3200,3330 },
    	{  820, 940,1060,1180,1300, 1420,1540,1660,1780,1900, 2020,2140,2260,2380,2500, 2620,2740,2860,2980,3100 },
    	{  790, 900,1010,1120,1230, 1340,1450,1560,1670,1780, 1890,2000,2110,2220,2330, 2440,2550,2660,2770,2880 },
    	{  760, 860, 960,1060,1160, 1260,1360,1460,1560,1660, 1760,1860,1960,2060,2160, 2260,2360,2460,2560,2660 },
    	{  730, 820, 910,1000,1090, 1180,1270,1360,1450,1540, 1630,1720,1810,1900,1990, 2080,2170,2260,2350,2440 },
    	{  700, 780, 860, 940,1020, 1100,1180,1260,1340,1420, 1500,1580,1660,1740,1820, 1900,1980,2060,2140,2220 },
    	{  675, 750, 825, 900, 975, 1050,1125,1200,1275,1350, 1425,1500,1575,1650,1725, 1800,1875,1950,2025,2100 },
    	{  650, 720, 790, 860, 930, 1000,1070,1140,1210,1280, 1350,1420,1490,1560,1630, 1700,1770,1840,1910,1980 },
    	{  625, 690, 755, 820, 885,  950,1015,1080,1145,1210, 1275,1340,1405,1470,1535, 1600,1665,1730,1795,1860 },
    	{  600, 660, 720, 780, 840,  900, 960,1020,1080,1140, 1200,1260,1320,1380,1440, 1500,1560,1620,1680,1740 },
    	{  580, 636, 692, 748, 804,  860, 916, 972,1028,1084, 1140,1196,1252,1308,1364, 1420,1476,1532,1588,1644 },
    	{  560, 614, 668, 722, 776,  830, 884, 938, 992,1046, 1100,1154,1208,1262,1316, 1370,1424,1478,1532,1586 },
    	{  540, 592, 644, 696, 748,  800, 852, 904, 956,1008, 1060,1112,1164,1216,1268, 1320,1372,1424,1476,1528 },
    };

    On Opt 1: great that you're on it — guard-elision + reciprocal-multiply is exactly the lever (4× udiv+udf here, and filter_axis/filter_step are also division-bound). I'll re-measure control_step on silicon the instant it lands; I'd expect a solid chunk of the 188→ gap to come back, since the divides are ~half of it. The unicorn harness (tables at [fp+0x10000], fp=linmem base) + the silicon micro-bench are ready to re-run.

    (Filing the inlined-after-opaque-call discrepancy as its own issue with a minimal repro shortly — agreed it's a distinct lowering path.)

  16. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    Opt 1b (#216, v0.11.19) replaces the constant udiv in your binning (/500 /5 /80 /1000) with umull+shift reciprocal-multiply — together with the Opt 1a guard elision (v0.11.17), each of those four divides goes from cmp/bne/udf + udiv (~14 cyc) to umull+lsrs (~4 cyc). That should move control_step off the 188-cyc / 2.35× number meaningfully. Validated against wasmtime 338/338. Happy to have you re-measure on silicon once v0.11.19 lands.

  17. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    v0.11.19 is tagged (Opt 1b — UMULL reciprocal-multiply) and building. Your binning divides /500 /5 /80 /1000 now compile to umull+lsrs instead of udiv, and with Opt 1a's guard elision already in, each goes from ~14 cyc (cmp/bne/udf + udiv) to ~4 cyc (umull + lsrs). Validated against wasmtime 338/338 incl. every magic-number branch (a-indicator, s=0). Please re-measure control_step on silicon once the release lands — curious where the 188/80 = 2.35× moves to. (Note: v0.11.18+ also carries the #212/#215 R12 reserve, so this is a clean correctness baseline.)

  18. avrabe commented on Jun 2, 2026

    @avrabe
    ContributorAuthor

    v0.11.20 is tagged and building — control_step compiles again AND keeps all four reciprocal-multiplies. The Opt 1b register-exhaustion regression (v0.11.19) is fixed: cost-gated the UMULL path + a 2-temp a=false lowering, so control_step_decide now emits umull:4 udiv:0 and matches wasmtime 13/13 (incl. your (3000,50,40,0) → 0x00210a55). This is the build that should finally move the 188/80 = 2.35× — all four binning divides (/500 /5 /80 /1000) are umull+lsrs (~4 cyc) instead of udiv (~12 cyc) + the already-gone guards. Please re-measure on silicon once v0.11.20 lands; very curious what the gap drops to.

  19. avrabe commented on Jun 4, 2026

    @avrabe
    ContributorAuthor

    Resolved — root-caused as the #193 call-free param clobber (a constant materialized onto a live param register), fixed in v0.11.16, with the v0.11.20 follow-up keeping control_step's reciprocal-multiplies under register pressure. control_step compiles and computes the correct packed result; guarded permanently by the committed control_step differential fixture (0x00210a55, ARM + RV32). Closing as shipped + differential-verified; reopen if a new vector diverges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions