Skip to content

selector: fuse mul+add → mla — flat_flight filter emits separate mul+add where native uses MLA #257

Description

@avrabe

Lever #2 from the flat_flight gap decomposition: fuse mul + add → mla

Per the 262→103 gap decomposition, after const-CSE the next instruction-selection lever for flat_flight is multiply-accumulate fusion. Measured on current main (f6a0c96, cortex-m4f):

synth lowers the filter's gyro*980 + accel*20 as separate mul then add (2 sites — pitch + roll axes):

movw r4, #0x3d4        ; 980
mul  r5, r3, r4        ; r5 = gyro * 980
movw r7, #0x14         ; 20
mul  r8, r6, r7        ; r8 = accel * 20
add.w r2, r5, r8       ; r2 = r5 + r8   ← fuse this add into the mul

native (gcc -O2) uses MLA — the add is free:

mla r2, r7, r6, r2     ; r2 = 980 * accel + r2   (one instruction)
mla r4, r7, r5, r4

The transform

Peephole: when a mul rD, rA, rB result feeds exactly one add rE, rD, rC (rD not otherwise live), rewrite to mla rE, rA, rB, rC and drop the mul. Cortex-M4 has single-cycle MLA. Saves 1 instruction + 1 temp register per site — 2 sites in flat_flight, and it recurs in any a*k1 + b*k2 filter/accumulator (very common in control code).

Bonus adjacent: multiply-by-constant strength reduction

The multipliers here are constants (980, 20). Native strength-reduces *20 to add.w r,r,r,lsl#2; lsls #2 (= *5 *4), avoiding the movw #20; mul entirely. A mul-by-small-constant → shift/add peephole would compound with the MLA fusion. (Lower priority than the MLA fold itself.)

Scope

Pure instruction selection (no regalloc dependency), so it composes cleanly with the const-CSE/spill work on the VCR-RA-001 track. flat_flight-microbench (261) + controller (168) are staged — I'll post the silicon delta when it lands. Filing per my offer on #209; close as dup/wontfix if it's already on the roadmap.

Activity

  1. avrabe commented on Jun 5, 2026

    @avrabe
    ContributorAuthor

    Shipped in v0.11.31: ArmOp::Mla + control-flow-sound fuse_mul_add wired into codegen. Measured flat_flight 1891→1819 B (~18 muls fused); flight_seam stays 0x07FDF307 with the fusion firing.

  2. avrabe commented on Jun 5, 2026

    @avrabe
    ContributorAuthor

    Heads-up: the MLA wiring shipped in v0.11.31, but on the actual gale flat_flight (the named target of this issue) it fires zero times — I rebuilt on 4c3e6f6 and the object is byte-identical to pre-MLA: mul=4, mla=0, 170 instrs, still 255 cyc on the G474RE. The "~18 muls fused / 1891→1819 B" must be a simpler fixture; the deployed filter doesn't match the pattern. Here's the exact window:

    mul     r5, r3, r4       ; gyro*980   (axis 1)
    add.w   ip, r1, #8       ; address calc — NOT the consumer
    ldrsh.w r6, [fp, ip]     ; load
    movw    r7, #0x14
    mul     r8, r6, r7       ; accel*20   (axis 2)
    add.w   r2, r5, r8       ; = gyro*980 + accel*20   ← consumes BOTH muls
    

    Two fusable pairs, neither fires:

    1. mul r8,r6,r7 ; add.w r2, r5, r8 — adjacent, should become mla r2, r6, r7, r5 (the mul result r8 is the add's second operand; r5 is the accumulator). My guess: the pass matches the mul only as the add's rn (first operand), not op2 — i.e. add-commutativity isn't handled. This is the cheap fix and it alone catches the real pattern.
    2. mul r5,r3,r4 … add r2,r5,r8 — consumer is 4 instrs later with an ldrsh [fp,ip] between; the conservative "unmodeled instruction blocks fusion" rule likely trips on the load even though it can't touch r5/r3/r4. Looking through a non-aliasing load would catch this one too, but (1) is sufficient.

    The scheduler interleaves the two filter axes (address-calc + load + the other axis's mul land between a mul and its add), so the immediately-adjacent case with the mul as op2 is the common shape in real code. Worth reopening — happy to re-measure the moment the commutative case fires (flat_flight-microbench staged, native floor is 103, currently 255). The op + encoder are correct; it's just the match condition that misses the deployed pattern.

  3. avrabe commented on Jun 5, 2026

    @avrabe
    ContributorAuthor

    Correction to my earlier comment — I instrumented fuse_mul_add and the real cause is not commutativity (the pass already tries the mul as both operands) nor the load (loads are modeled). Apologies for the wrong guess. The actual blocker, from a FUSE_DEBUG dump of the real flat_flight stream:

    [20] Mul R5,R3,R4        ; mul#1 (gyro*980)
    [23] Mul R8,R6,R7        ; mul#2 (accel*20)
    [24] Add R2,R5,R8        ; consumes R5+R8 — the fusable site
    [36] Mul R5,R3,R4        ; axis-2 REUSES R5
    [37] Add R6,R2,R5        ; reads the *new* R5
    [49] Add R3,R5,R8        ; reads the *new* R5
    

    used_elsewhere scans the whole function for any read of rmul (R5):

    let used_elsewhere = (0..n).any(|k| k != j && !removed[k] && op_may_use(&instrs[k].op, rmul));

    It finds R5 read at [37]/[49] and bails — but those are reads of the redefined R5 (from [36]), not mul#1's value, which is dead right after [24]. So register reuse by the greedy selector blocks the fusion, on every axis. That's why a clean fixture (distinct regs) fuses ~18 muls but the deployed filter fuses zero.

    The good news: this resolves itself under VCR-RA-001. Once the allocator emits non-reused virtual registers (SSA-style), each mul result has a unique vreg, used_elsewhere sees only the real consumer, and the fusion fires — so mla becomes a free rider on the allocator wiring (#272), not a separate fix. If you want it before then, the standalone fix is to make used_elsewhere (and between_ok) live-range-bounded — stop the scan at the next def of rmul (here [36]); a use after rmul is redefined reads a different value and shouldn't block. flat_flight-microbench is staged either way — I'll confirm the silicon delta when mla fires (via the allocator or the live-range bound).

  4. avrabe commented on Jun 5, 2026

    @avrabe
    ContributorAuthor

    Fixed in v0.11.32 — the fusion now fires on the real flat_flight.

    Your commutativity guess was a red herring: the pass already tries both operand orders. I traced r8's live range on the actual --relocatable object and found the real cause — the used_elsewhere soundness check scanned the whole function for any read of the mul-result register, but the single-pass allocator recycles r8 for unrelated later values:

    7e: mul.w r8, r6, r7   ; r8 = accel*20   ← our mul
    82: add.w r2, r5, r8   ; consumes r8     ← the only real use
    a4: movw  r8, #980     ; r8 REDEFINED to a different value
    a8: mul.w r2, r7, r8   ; reads the NEW r8
    

    The reads of the new r8 (at a8, e8, …) falsely blocked every fusion. Replaced the whole-function scan with a precise live-range check (dead-after-the-add-until-redefined).

    Measured on flat_flight.loom.wasm (cortex-m4 --relocatable):

    before after
    mul 4 2
    mla 0 2
    instructions 170 168

    Both filter products (gyro*980, accel*20) now fuse. All three differential fixtures result-identical — flight_seam stays 0x07FDF307 with the fusion firing. Whenever you reflash the G474RE off v0.11.32, the 262 → ? cyc delta would close the loop. (Note this is the instruction-selection lever; the const-CSE clamp lever on #209 is still the bigger pending item.)

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