Repository navigation
Algorithm codegen (new wasm test case): suspected coolant-operand drop + constant-div SR, .rodata relocations, regalloc spilling #210
Description
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.binSource 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 fromclang→wasm-ld - target NUCLEO-G474RE (Cortex-M4F @170 MHz); Zephyr SDK 1.0.1
Activity
avrabe commentedon Jun 2, 2026
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 feedssynth compile) through wasmtime 42.0.1 and it matches the native reference build of the C source on all four vectors:0x002108FC(2164988)0x00210A55(2165333)0x00210BAE(2165678)0x002208E5(2230501)So the wasm correctly computes the mid-range coolant enrichment (the
(80-coolant)*300/80term): vector 2 yields fuel 2645, distinct from the no-enrichment 2300. Themovw r2,#80; subs r3,r2,r2in 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.avrabe commentedon Jun 2, 2026
More data — I compiled a second algorithm through the pipeline:
controller_stepfrom ourflight_controlbench (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.wlowering 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:
3 spills with r9/r10 free, every clamp bound re-materialized.
(new) Saturation → use
SSAT. Eachif (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 hasSSAT Rd, #n, Rmwhich does signed saturation to a power-of-two range in one instruction (andUSATfor unsigned). Recognizing themin(max(x, -lo), hi)idiom and emittingSSAT/USAT(or at least anIT-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_controlhasfilter_stepnext (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.)
avrabe commentedon Jun 2, 2026
Third function through the pipeline —
filter_axis(the complementary-filter hot math fromflight_control'sfilter_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
sdivby the constant 1000 emits two dead guards:~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, flightcontroller_step, flightfilter_axis): wasm + native agree on every vector — the only divergence is thecontrol_stepcoolant-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).avrabe commentedon Jun 2, 2026
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_decideARM output directly (tables loaded at[fp+0x10000]/[fp+0x10190],fp=linmem base, args in r0–r3). I validated the harness first on a trivialint 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):0x002703e8-class garbage (0xfc2703e8), wasm-verified-correct is0x00210a55(spark 33 / fuel 2645). Both fields wrong.[rb][lb][6][10][6][8][rb][lb][6][10][1][8]Two concrete defects:
lbis computed from the wrong parameter.lb=8 = 40/5, and 40 is the coolant arg (r2), not load=50 (r1). Soload_binis consumingcoolant_cinstead ofload_pct— the same parameter mis-routing that shows up statically as thesubs r2,r2coolant-drop. It's a param-register-routing bug, not just one dropped operand.rbis clobbered between the two lookups — fuel reads row 6 (correctrb), but the spark read uses row 1. Sorbis 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), andtables.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.)
avrabe commentedon Jun 2, 2026
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_axisoutput (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 ✓).So this algorithm is ~1.95× native — almost exactly the ratio we measured on the
k_sem_giveprimitive (907 vs 471 = 1.92×). On a function this small the entire gap is the/1000: synth emits a runtimesdivplus 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_stepoutput in the validated unicorn harness,controller_step_decide(6400,0,-12800,0,3200,0,5)returns0x05ce819cvs correct0x05ce7f9c: the elevator field is-127where it should be+127. elevator =-(-12800>>6) = +200, which should clamp to +127, but the upper-saturation clamp produces-127(sign flip). So theif (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 thatSSATwould both fix and speed up.Net: across the four functions now measured, correctness is right except the
control_stepparam-routing (headline) and thiscontroller_stepsaturation 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.avrabe commentedon Jun 2, 2026
Refinement + correction to my silicon perf numbers — I decomposed the gap by operation on hardware.
Earlier I said the
filter_axisgap 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:full=((p+g)*980 + a*20)/1000nodiv=((p+g)*980 + a*20)div=x/1000Decomposition of the 27-cycle gap on
full:nodivalone 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).full − nodivis +17 in synth vs +7 in native. Thesdiv+ 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
divat 40 cyc standalone is striking: a singlex/1000with two dead guards. Constant-div SR alone takes that to native's ~15.)Run artifacts + the micro-bench are saved and shareable.
avrabe commentedon Jun 2, 2026
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:
add4(a,b,c,d)add4d = (a+b+c+d)/3add5add6control_stepSo 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/movtbase +mulforrb*stride+ addlb) overwrites the parameter registers (r1=load, r2=coolant) beforeload_bin/rpm_binconsume them — which is exactly whyload_binends up reading r2 (coolant) andrbis 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.avrabe commentedon Jun 2, 2026
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:
z_impl_k_sem_give(seam dissolved) runs on the NUCLEO-G474RE — 907-cyc handoff (1.92× LLVM-LTO's 471).filter_axis(flight_control math) measured at 2.42× native, decomposed: ~17 cyc base codegen + ~10 cyc divide.control_stepmis-routes params (root-caused above to the indexed-load path clobbering param registers), andcontroller_stephas the saturation sign-flip. Those two are the only things gating the rest of the per-function silicon table.What would help most, in priority order (all detailed above):
control_stepon hardware).SSATfor 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!
avrabe commentedon Jun 2, 2026
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 insidefilter_step: synth overwrites the livespointer register mid-body (r1:0x20001100→0xfffffe70, a loaded value) → wrong-address load. Same mechanism as the enginecontrol_step(load_binate 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_stepintoflight_algo— verified, oracle0x07FDF307. I'm ready to measure the on-NUCLEO cycle delta (vs 141-cyc native), but I can't flash it because synth miscompiles the keptfilter_stepcall (above). So #210 currently gates: (a) the enginecontrol_stepsilicon number, and (b) the loom-inlined flight composed-algo number.The arg-matrix from before still localizes it cleanly:
add4/add4d/add5/add6all 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.avrabe commentedon Jun 2, 2026
Thanks — triaged. Diagnosis on the 🔴 coolant bug:
It's the call-free #193 param-register clobber.
coolant_cis param 2 → r2 (AAPCS). Your disasmmovw r2, #80; subs r3, r2, r2is the constant80being 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_safeand the consecutive-pair allocator) reserves only operand-stack-live registers, not the param registers inlocal_to_reg— so under enough register pressure it hands out a still-live param.control_step_decideis 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_loweringfuzz 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 sharecontrol_wasm.c+tables.c(or thecontrol.loom.wasm)? I want to (a) reproduce the exact clobber for a regression test, and (b) verify the fix against your vectors directly (thecoolant=40→ fuel2645/ packed0x00210a55case) 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_stackwill 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,
.rodatarelocations, regalloc/CSE) I'm tracking against the optimization effort in #209 — Opt 1 (drop the never-taken div-by-zeroudf+ reciprocal-multiply for constant divisors) is high-value and low-risk; I'll likely land that close behind the correctness fix.avrabe commentedon Jun 2, 2026
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
spointer (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 fullselect_with_stackparam-liveness reservation is the correct fix.Here's the exact repro you asked for.
control_wasm.c(the enginecontrol_stepreshaped value-in/value-out;coolant_c= param 2 = r2):tables.c: it's in your org —pulseengine/gale→benches/engine_control/src/tables.c(the twospark_advance_table[20][20]/fuel_duration_table[20][20]lookup tables, 4.7 KB). Build:control.loom.wasm(1715 B, the exact input tosynth compile— skip the clang/loom steps if you want bit-identical):Differential vectors for your unicorn+wasmtime oracle (native == wasmtime, both verified):
0x002108FC0x00210A550x00210BAE0x002208E5The
coolant=40row 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.fix(selector): reserve live param registers from temp/pair/reload all…
avrabe commentedon Jun 2, 2026
🔴 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 keepcoolant_cin r2 and emitsubs rd, r80, r2.Validated: the
i64_lowering_doesnt_clobber_paramsfuzz — 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=40discriminator 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 sharecontrol_wasm.c+tables.c(orcontrol.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,.rodatarelocations, 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.avrabe commentedon Jun 2, 2026
✅ v0.11.16 VERIFIED on hardware — coolant-drop fixed, and the engine algo is now on silicon
Re-ran the exact
control.loom.wasmthrough 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_cstays in r2. Confirmed both in the unicorn harness and on real silicon (selfcheck synth == native ==0x00210a55). Thei64_lowering_doesnt_clobber_paramsfuzz going green matches what I see end-to-end.Second silicon data point captured (the engine
control_step— tables + four binning divides):control_step(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_axis46 / 19 = 2.42×,control_step188 / 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 deadudfguard) + 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_stepinlined after an opaquefilter_stepcall 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.)avrabe commentedon Jun 2, 2026
🎉 That's the result we wanted — coolant-drop fixed and the engine algorithm running on silicon with a clean second data point (
control_step188/80 = 2.35×). Thanks for the on-hardware confirmation; the0x00210a55match (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-takenudfon/500 /5 /80 /1000is most of the 2.35×. I'm starting on it now:cmp #0 / bne / udf. ~3 instrs × 4 divides here.udivby a constant toumull+ 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(orcontrol.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_stepinlined after an opaquefilter_stepcall): 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.avrabe commentedon Jun 2, 2026
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.wasmis fully self-contained — the two lookup tables are embedded in its data segment, so you can skip clang/loom entirely andsynth compileit directly:# decode to control.loom.wasm, then: synth compile control.loom.wasm --target cortex-m4f --all-exports --relocatable -o control_algo.oThe four guard vectors (native == wasmtime == on-silicon, all confirmed):
0x002108FC0x00210A550x00210BAE0x002208E5In case you want the C instead of the blob —
control_wasm.cis the value-in shim I posted earlier;tables.c(the data) is:tables.c (89 lines)
On Opt 1: great that you're on it — guard-elision + reciprocal-multiply is exactly the lever (4×
udiv+udfhere, andfilter_axis/filter_stepare also division-bound). I'll re-measurecontrol_stepon 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.)
avrabe commentedon Jun 2, 2026
Opt 1b (#216, v0.11.19) replaces the constant
udivin your binning (/500 /5 /80 /1000) withumull+shift reciprocal-multiply — together with the Opt 1a guard elision (v0.11.17), each of those four divides goes fromcmp/bne/udf+udiv(~14 cyc) toumull+lsrs(~4 cyc). That should movecontrol_stepoff 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.perf(selector): UMULL reciprocal-multiply for constant unsigned divis…
avrabe commentedon Jun 2, 2026
v0.11.19 is tagged (Opt 1b — UMULL reciprocal-multiply) and building. Your binning divides
/500 /5 /80 /1000now compile toumull+lsrsinstead ofudiv, 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-measurecontrol_stepon 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.)avrabe commentedon Jun 2, 2026
v0.11.20 is tagged and building —
control_stepcompiles 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-tempa=falselowering, socontrol_step_decidenow emitsumull:4 udiv:0and 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) areumull+lsrs(~4 cyc) instead ofudiv(~12 cyc) + the already-gone guards. Please re-measure on silicon once v0.11.20 lands; very curious what the gap drops to.avrabe commentedon Jun 4, 2026
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_stepdifferential fixture (0x00210a55, ARM + RV32). Closing as shipped + differential-verified; reopen if a new vector diverges.