Full reference for every analysis armlint implements -- mechanics, soundness, and what each rewrite saves. See the README for an at-a-glance table and the project's design and soundness model.
Throughout, datasize is the operand width in bits: 32 for the W-form,
64 for the X-form.
movz w0, #0x6666 ; movk w0, #0x6666, lsl #16instead ofmov w0, #0x66666666(single bitmask-immediate ORR)- More generally, any MOVZ/MOVN + MOVK chain longer than the minimal
move-wide sequence for its final value. The minimal length is one
instruction per non-zero halfword for a MOVZ-based chain, one per
non-0xFFFF halfword for a MOVN-based chain (each with a floor of
one), whichever is smaller. So the four-instruction
movz x0, #0x5678 ; movk x0, #0x1234, lsl #16 ; movk x0, #0xffff, lsl #32 ; movk x0, #0xffff, lsl #48(0xFFFFFFFF12345678) flags with the two-instruction rewritemovn x0, #0xa987 ; movk x0, #0x1234, lsl #16, and a MOVK that rewrites a halfword the base instruction already set (movk #0over MOVZ,movk #0xffffover MOVN) flags as plainly redundant. - The chain is accumulated per register -- a MOVZ or MOVN opens it, same-register same-width MOVKs extend it -- and the final value is judged when the chain closes (any other instruction, or end of region). Judging the net value catches chains whose individual steps look necessary but whose result is cheap.
- Soundness: the rewrite materializes the same constant in the same register; no flags or memory are involved. Like every multi- instruction fold, it assumes control flow does not enter the middle of the chain.
- What it saves: one to three instructions per constant (4 bytes and a decode slot each). Hybrid constructions (a bitmask-immediate ORR or MOV followed by MOVKs), which beat both pure move-wide forms for some values, are not yet modeled -- the reported minimum is an upper bound on the true one.
lsl w0, w1, #3 ; add w0, w2, w0instead ofadd w0, w2, w1, lsl #3. Same for SUB, AND, ORR, EOR (and flag-setting variants), and for the other shift producers: LSR and ASR fold the same way with the consumer carrying their shift type (lsr x0, x1, #4 ; add x0, x2, x0->add x0, x2, x1, lsr #4), and a ROR -- the same-registerEXTR Rd, Rs, Rs, #nalias -- folds into the logical consumers only, because the arithmetic shifted-register encoding reserves shift type 11. An EXTR with distinct sources is a funnel shift and does not fold. The rewrite deletes the shift, so its destination must be dead afterward: a consumer that overwrites it proves that on the spot, and a consumer writing a fresh register (lsl w8, w1, #3 ; add w9, w2, w8) defers through the forward register-liveness scan and reports only oncew8is provably overwritten before any read or control transfer.Rd = 31consumers are excluded -- the non-S forms are dead writes, the S forms theCMP/CMN/TSTaliases.- Why the fuse helps -- shared by the "producer into its consumer"
folds (this one; the bitfield-
UBFX/UBFIZshift/mask pairs;MUL/SMULL+ADD->MADD/SMADDL;NEG+ADD/SUB;MVN+ logical; and the extend fold below): one fewer instruction (4 bytes, a decode/issue slot, I-cache), and the folded-away producer op no longer executes as a separate dependent instruction on the critical path -- it rides in the consumer's ALU operand instead. The scratch register that held the intermediate is freed too. - The shifted-register form carries the shift on
Rmonly. When the shift result is the consumer'sRm, any consumer folds. When it is the consumer'sRn, only a commutative consumer folds, by swapping the two sources so the shifted value moves toRm:lsl w0, w1, #3 ; add w0, w0, w2->add w0, w2, w1, lsl #3. The commutative set isADD/ADDS,AND/ANDS,ORR,EOR, andEON(bitwise XNOR, soa ^ ~b == b ^ ~a);SUB/SUBS,BIC/BICSandORNare not symmetric in their two sources and so do not fold from theRnslot. - The "independent" source (the one that is not the shift result, which
becomes the new
Rn) must not itself be the shift destination. The degeneratelsl wt, ws, #k ; add wt, wt, wt-- both consumer sources equal to the shift destination -- is therefore not flagged: it doubles the shifted value (ws << (k+1)), which the single shifted-register form cannot express, and a naive rewrite would read a stale pre-shift value for the second operand. An XZR independent operand is likewise not flagged: such consumers are shifted register copies (ORRfrom ZR is theMOValias) or constants, not ops the shift rides into.
lsr x2, x1, #56 ; orr x2, x2, x3, lsl #8instead ofextr x2, x3, x1, #56. An immediateLSL/LSRfeeding anORR,EORorADDwhoseRmcarries the complementary shift (opposite direction, amounts summing to the register width) reassembles a funnel shift: theLSR'd source supplies the low half, theLSL'd source the high half, and a singleEXTR Rd, Rhi, Rlo, #lsb(withlsbthe right-shift amount) produces the same bits. When both halves are the same register the funnel is a rotate and folds toROR Rd, Rs, #lsb(the same-registerEXTRalias) instead.- This is the inverse of the
shift-fold check above,
which absorbs a shift whose consumer has no shift of its own. Here the
consumer already carries a second, complementary shift, so the pair is a
two-register funnel that the shifted-register form cannot express but
EXTRcan. - Only
ORR,EORandADDqualify. With complementary amounts the two shifted fields are bit-disjoint -- the high field occupies bits[hi, datasize-1]and the low field[0, hi-1], no overlap -- so OR, XOR and ADD all agree bit-for-bit withEXTR(ADD carries nothing across the gap).SUB(borrows),AND/BIC(disjoint fields AND to zero) and the flag-settingADDS(anEXTRdropsNZCV) are not funnels and are rejected. - The consumer's shift must be logical (
LSL/LSR), neverASR: an arithmetic right shift fills the vacated high bits with the sign, which would collide with the other field instead of leaving the zeroes a funnel needs. The pending producer is likewise onlyLSL/LSR. - Soundness: like the shift-fold check, the rewrite deletes the
producer shift, so its destination must be dead afterward. A consumer
that overwrites it (
Rd == Rt) proves that on the spot; a consumer that writes a fresh register defers through the forward register-liveness scan and reports only once the shift result is provably overwritten before any read or control transfer (Rd = 31, a dead write, is excluded). The inline-shifted source must be a different register than the shift destination, else the funnel would read the shifted value rather than the original register. Shifting or writingXZRis rejected as degenerate. - What it saves: one instruction (4 bytes, a decode/issue slot), and the
producer shift no longer runs as a separate dependent op on the critical
path -- the whole funnel is one
EXTR. Compilers that recognize the idiom emit this already (Go's SSA backend, for one, lowers(x >> (64-c)) | (y << c)straight toEXTR), so on well-optimized output the check is silent; it catches the residual cases from hand-written assembly and weaker code generators.
- The extend counterpart of the shift fold: where that absorbs a shift,
this absorbs an extension. A standalone
UXTB/UXTH(W-form),SXTB/SXTH(W or X), orSXTW(X) feeding anADD/SUBfolds into the consumer's extended-register form, where the extend (and an optional shift) ride on the consumer's Rm:sxtw x0, w1 ; add x0, x3, x0->add x0, x3, w1, sxtwuxtb w0, w1 ; sub w0, w3, w0->sub w0, w3, w1, uxtbThe extended operand is always rendered as aWregister, since these extends source 32 bits or fewer.
- What you actually save: one instruction (4 bytes, a decode slot, I-cache), and -- unlike the pre-/post-index LDR/STR folds, which are backend-neutral -- usually a cycle of latency. The extended-register ADD/SUB performs the extension in the ALU's operand path, so two dependent ops (extend, then add) collapse to one, shortening the dependency chain. (A few cores route extended/shifted ADD to a slightly slower pipe, but the single op is still no slower than the original pair.) It also frees the scratch register that held the extended value.
ADD/ADDScommute, so the extend result may be in the consumer's Rn or Rm slot (it is swapped to Rm);SUB/SUBSonly fold when it is already Rm.- Soundness (mirrors the shift fold): the rewrite deletes the extend,
so its destination must be dead afterward -- a consumer that
overwrites it reports immediately, and one writing a fresh register
defers through the forward register-liveness scan. The other source
operand must not be the extend's destination -- nor register 31,
which the shifted-register consumer read as ZR but the
extended-register rewrite would read as SP.
Rd = 31consumers are excluded for the same reason, and more sharply: the shifted-register consumer'sRd = 31is a discarded ZR write, but the extended-register rewrite'sRd = 31is SP -- the fold would turn dead code into a stack-pointer update. The producer form (W vs X) must match the consumer's, with one relaxation: a W-form zero-extend (UXTB/UXTH) also folds into an X-form consumer (uxtb w0, w1 ; add x0, x2, x0->add x0, x2, w1, uxtb), because the W write zeroed bits 63..32 and that is exactly what the X-form extended-register option computes. The W-form sign-extends do not get the relaxation: they too zero the high half, where the X-formSXToption would replicate the sign. Extend of, or into, ZR is excluded. The standalone 32->64 zero-extendUXTWis not matched as a producer: it is normally aW-registerMOV(aWwrite already zeros the upper half), not a literal instruction.
cmp w0, #0 ; b.eq targetinstead ofcbz w0, target. Same forb.ne->cbnz. Also matches the equivalent zero-test idiomscmp Rn, xzr(SUBS XZR, Rn, XZR),cmn Rn, #0/cmn Rn, xzr(the ADDS-based spellings -- adding zero leaves the same N and Z), andtst Rn, Rn(ANDS XZR, Rn, Rn): all five setZ=1iffRn==0and so fold identically.- The unsigned conditions fold too, for the SUBS-based forms only:
subtracting zero never borrows, so
C == 1andb.hi(C && !Z) reduces tob.ne->cbnz,b.ls(!C || Z) tob.eq->cbz.tst Rn, Rnand thecmnspellings are excluded from this pair -- ANDS clears C, and adding zero never carries, so after either HI is never taken and LS always taken, dead-branch territory rather than a register-test rewrite. (b.hs/b.loafter any zero test are likewise constant-valued and are not rewritten here.) - Why it helps (shared by the
CMP/TST->TBZ/TBNZfolds below): one fewer instruction, and the branch no longer depends on a flag-writingCMP/TST--CBZ/CBNZreads the register directly, removing the NZCV def-use and the scheduling constraint it imposes. - Soundness:
CBZ/CBNZdoes not write NZCV, butCMP Rn, #0writes all four flags. Folding is unsound if subsequent code reads N, C, or V (e.g.ADCS,CSEL,B.LT,CCMP). armlint runs a forward NZCV-liveness scan on the fall-through path: the finding is emitted only after seeing an instruction that overwrites NZCV without reading them (ADDS/SUBS/ANDS/BICS/FCMP, or a well-formed FEAT_MOPS prologue CPYFP*/CPYP*/SETP*/SETGP*, which unconditionally rewrites all four flags -- overlapped-register or reg == 31 forms are CONSTRAINED UNPREDICTABLE or UNDEFINED and do not count) or a terminator that makes prior flags unobservable (RET, BL, BLR). The scan suppresses on any flag-reader (including the FEAT_MOPS main/epilogue stages, which consume the algorithm state their prologue left in NZCV), an unsafe terminator (B unconditional, BR, or a conditional CBZ/CBNZ/TBZ/TBNZ whose taken target may still observe the flags), or after a 16-instruction window with no decision. The branch-target path is not scanned; full soundness would require basic-block analysis.
cmp wn, #0 ; b.lt target(orb.ge/b.mi/b.pl) folds totbnz wn, #(datasize-1), target(ortbz). After any of the zero-test spellings --CMP Rn, #0/CMP Rn, ZR/CMN Rn, #0/CMN Rn, ZR/TST Rn, Rn--V == 0andN = sign(Rn), soB.LT(N != V) reduces to "N == 1" -- exactly a test of the sign bit.B.MIdirectly testsN;B.GE/B.PLare the inverse.- Range:
TBZ/TBNZuse a 14-bit signed offset (~32 KB reach), vs.B.cond's 19-bit (~1 MB). The fold is suggested only when the target fits in the TBZ encoding. - Soundness: same NZCV-liveness scan as the CMP-branch check above. The rewrite drops the CMP/TST, so downstream code that observes N/C/V before they're overwritten would see different values; the scan suppresses on any flag-reader. Shares the existing CMP/TST pending slot, which is sufficient because the sign-only and EQ/NE conditions are mutually exclusive at the same B.cond.
- Same win as the CMP -> CBZ fold: one fewer instruction, and the branch no longer carries an NZCV dependency.
tst w0, #(1<<5) ; b.eq targetinstead oftbz w0, #5, target. Same forb.ne->tbnz. Only the immediate-formTSTis matched (ANDS XZR, Rn, #imm) and only when the immediate is a single power-of-two bit.- Range:
TBZ/TBNZuse a 14-bit signed offset (~32 KB reach), much shorter thanB.cond's 19-bit (~1 MB). The fold is suggested only when the target fits in the TBZ encoding. - Soundness: same NZCV-liveness scan as the CMP-branch check.
- Same win as the CMP -> CBZ fold: one fewer instruction, and the branch no longer carries an NZCV dependency.
- The materialising sibling of the check above: the bit feeds a bool
instead of a branch.
tst w0, #0x10 ; cset w8, necomputes(w0 >> 4) & 1, which isubfx w8, w0, #4, #1-- one flag-free instruction.CSETM(0 or all-ones) is the sign-extending extract,sbfx w8, w0, #4, #1. - Conditions:
NEfolds directly (Z clear exactly when the masked bit is set).MIis accepted as its synonym when the isolated bit is the producer's sign bit -- N is that bit -- and is constant-false for any lower bit.EQ/PLwould need an inverted extract, which has no single-instruction form (ubfx+eor #1is back to two), and every other condition is constant afterTST(which clears C and V). Constant-condition shapes are left alone. - Widths:
CSET's 0/1 result zero-extends identically at either width, so all W/X producer/consumer combinations fold; the extract renders at the consumer's width, bumped to X when the bit lives in the high half.CSETM's all-ones must replicate at the CSETM's own width, so a W-formCSETMof a bit above 31 has no single-instruction form and is skipped. Cross-width register reads are exact -- bit k < 32 ofXnandWnare the same bit. - The rewrite deletes the
TSTand writes no flags, so all four flags it set disappear; emission defers through the same forward NZCV-liveness scan as the TBZ folds until the flags are provably dead (overwritten, or a safe terminator, before any reader). - Win: two instructions to one, the scratch bool no longer rides on
an NZCV dependency, and the flags stay free for the surrounding
schedule. This shape is common in naive codegen materialising
(flags & F) != 0into a register.
- The flag-free spelling of the check above: a producer that isolates
one bit
kofRsinto a scratch register, immediately followed bycbz/cbnzof the scratch. The scratch is zero iffRs[k]is zero, so the pair is a singletbz/tbnz Rs, #kwith the same target --and w8, w9, #0x10 ; cbz w8, L->tbz w9, #4, L. Recognised producers: a non-flag-setting AND with a one-bit mask, and the one-bit UBFM/SBFM extracts (imms == immr) --ubfx/sbfx Rd, Rs, #k, #1and the sign-bit aliaseslsr/asr Rd, Rs, #(datasize-1)(the SBFM forms yield 0 or -1, still zero exactly when the bit is clear). No NZCV is involved on either side, so no flag-liveness scan is needed. - What needs proving instead is register liveness: the rewrite
deletes the producer, so the masked scratch must be dead afterward
-- on BOTH edges of the branch. The forward register-liveness scan
proves the fall-through path (the scratch is overwritten before any
read or control transfer); the taken path is covered by
containment: the finding is emitted only when the branch target
lies within
[fall-through, kill], the span the scan just proved free of reads and control transfers, so the taken edge enters that clean span and runs to the same kill. This is the canonical skip-a-small-block shape --and w8, w9, #0x10 ; cbz w8, 1f ; add x1, x2, x3 ; 1: mov w8, #0folds because both edges reach themovthat killsw8. Backward targets and targets beyond the kill leave the taken edge unproven and are conservatively dropped. Unlike the NZCV checks, which assume flags are dead at branch targets (block-local by convention), no such assumption is made here: a general-purpose register is routinely live into a branch target. - A W-form
cbzafter a producer isolating bit >= 32 is rejected: the zero-extended field sits wholly in the discarded high half, making the branch constant -- dead-branch territory, not this fold.ANDSproducers are excluded (deleting one loses the NZCV write; theRd = ZRspelling belongs to the TST check), as are ZR sources (constant branches) and ZR destinations. The TBZ displacement is range-checked against the signed 14-bit encoding, though the containment gate restricts it far more tightly in practice. - Same win as the TST fold -- one fewer instruction -- plus the scratch register is freed.
cset w8, eq ; cbnz w8, Linstead ofb.eq L. The CSET materialises a condition the flags already hold and the branch immediately re-tests it:cbnzbranches exactly when the condition held,cbzexactly when it did not, so the pair is a singleb.<cond>(b.<inverse cond>forcbz) with the same target -- NZCV is untouched between the adjacent pair, so theb.condreads the same flags thecsetdid. The temp is 0 or 1 zero-extended across the full X register, so W and X producers and branches fold in every combination.cbz/cbnz's imm19 carries over intob.cond's identical imm19 (the displacement grows by the deleted producer's slot, range-checked at the encoding's positive limit).- Two sibling consumers fold the same producer without any branch:
cset w8, eq ; eor w9, w8, #1->cset w9, ne-- EOR #1 inverts the boolean, which is the inverted-condition CSET. Immediates other than 1 andeorwriting SP (Rd = 31in a logical immediate) are excluded.cset w8, eq ; neg w9, w8->csetm w9, eq-- negation maps 1 to all-ones, exactly CSETM, condition unchanged. Shifted and flag-setting (negs) forms are excluded. Both rewrites take the consumer's width (sound at any combination, by the same zero-extension argument).
- The rewrite deletes the CSET, so its temp must be dead afterward.
For the branch consumer that means dead on BOTH edges, and emission
defers through the same two-edge scan as the single-bit fold: the
forward scan proves the fall-through path, containment in
[fall-through, kill]covers the taken edge, and backward targets are dropped at the consumer. For EOR/NEG, a consumer that overwrites the temp itself kills it on the spot and emits immediately; otherwise emission defers through the plain forward register-liveness scan. - Raw CSINC condition fields AL/NV are excluded when opening the
producer:
ConditionHoldstreats both as always-true, so the "cset" is the constant 0, not a conditional. ZR destinations (discarded results) are excluded on both sides. - The win is one instruction and a freed temp register, and the
b.condspelling is what compilers emit for the shape -- thecset+cbnzform appears when a boolean materialised for one purpose is then only branched on.
- An indirect branch through the link register is a spelled-out
return:
br x30andrettransfer to the same address, set no flags, and write no registers. The one bit of daylight is the branch-type hint, and the return-address predictor keys on it -- RET pops the prediction stack that the matching BL pushed, while BR is predicted as an ordinary indirect branch, so abr x30return both mispredicts itself and desynchronizes the stack for the returns around it. The Neoverse software optimization guides and the Apple Silicon guide state the rule directly: returns use RET. ("BR x30 foldable to RET",-> ret.) - A 1-for-1 canonicalization with nothing to prove: no flags, no liveness, and a single-instruction finding is sound from any entry. Under BTI the rewrite only relaxes the target's requirement (an indirect BR needs a landing pad; RET is exempt). On FEAT_GCS hardware (Armv9.4 shadow stacks) the two genuinely diverge -- RET pops and checks the guarded stack, BR x30 bypasses it -- which is an argument for the rewrite, not against: a genuine return must be RET there, and code bypassing the pop deliberately (context switchers, unusual trampolines) is exactly what a reviewer wants surfaced.
- Composition: applying the rewrite turns
autiasp ; br x30into the split epilogue that the-m pauthfold then takes toretaa, and under-a pacthe same word also appears in the unauthenticated-indirect audit. The authenticated BRAA/BRAAZ forms differ in encoding and never match. - Where the shape lives: compilers emit
ret, and none of the six reference binaries -- nor dyld or libobjc, both rich in hand assembly -- contain a single instance. The catch population is hand-written assembly, mechanical ports, and JIT emitters.
- A direct branch whose target is the instruction immediately after
it transfers control exactly where fallthrough would have: taken
or not taken, execution arrives at the same place. B, B.cond and
BC.cond, CBZ/CBNZ, and TBZ/TBNZ write no register and no flags,
so the instruction is a pure no-op regardless of the condition's
value -- deletable with no condition or liveness reasoning at all,
while it costs fetch bandwidth, a predictor slot, and (for the
conditional forms) a possible misprediction. ("branch to the next
instruction is a no-op",
-> delete: control falls through either way.) - BL is the one deliberate exclusion: it writes x30 even over a
zero-length span, and
bl .+4is the classic get-the-PC idiom in old position-independent code -- deleting it would break its actual purpose. The distance test is exactly imm == 1 in instruction units; imm == 0 is branch-to-self, a spin loop with entirely different semantics. Deleting a branch that is itself a branch target is sound: the entering path falls through to the same successor. - Composition: a degenerate
cbz/tbzto the next instruction can simultaneously draw a fold suggestion from the branch-shape checks (a CBZ-to-next of a masked temp is still "foldable to TBZ") -- both findings are individually true, and the deletion is the better rewrite for that shape. - Unlike the BR x30 canonicalization, this shape is genuinely
present in compiler output: 37 instances across five of the six
reference binaries (10 in ssh, 9 in gh, 8 in libcapstone, 5 each
in zsh and sshd; ls has none). Every ssh instance is an
unconditional
b .+4(raw word 0x14000001, each byte-verified) -- empty-basic-block and merged-tail artifacts the compilers never cleaned up.
lsl wd, ws, #a ; lsr wd, wd, #bfolds depending on the relationship betweenaandb:b >= a: extraction.ubfx wd, ws, #(b-a), #(datasize-b). Withasrit folds tosbfx(sign-extending).b < a: insertion.ubfiz wd, ws, #(a-b), #(datasize-a)-- placesws[datasize-a-1 .. 0]atwd[datasize-b-1 .. a-b]with bits belowa-bzeroed. Withasrit folds tosbfiz(sign-extending the high bits fromws[datasize-a-1]).- Same for X-form.
- Currently requires the consumer's
RdandRnto equal the LSL'sRdso the shift result is dead after the rewrite. - Fuse win (see the shift fold): two shifts become one bitfield op -- one fewer instruction, second shift off the critical path.
lsr wd, ws, #n ; and wd, wd, #((1<<w)-1)extracts bitsws[n+w-1 .. n]; equivalent toubfx wd, ws, #n, #w(cappingwatdatasize-nwhen the mask is wider than the LSR-fillable bits). Same for X-form.- Mask must be a contiguous run of low bits with no rotation
(
immr=0and(N, imms)encodingS+1=wones at the appropriate element size); rotated/non-contiguous masks like#0x6are correctly skipped. - Fuse win (see the shift fold): shift + mask become one
UBFX-- one fewer instruction, the mask off the critical path.
- The opposite ("mask then shift-right") order from the check above.
and wd, ws, #mask ; lsr wd, wd, #nwheremaskis a single contiguous run of 1s[lo, hi]; the LSR reads and writes the AND's destination. The surviving bits arews[hi .. n], so the pair is equivalent to a singleubfx wd, ws, #n, #(hi+1-n). Same for X-form. Examples:and w0, w1, #0xff0 ; lsr w0, w0, #4->ubfx w0, w1, #4, #8;and x0, x1, #0xffff00 ; lsr x0, x0, #8->ubfx x0, x1, #8, #16. - Foldable only when
lo <= n <= hi.lo > nwould leave the field above bit 0 (e.g.and w0,w1,#0xff00 ; lsr w0,w0,#4keepsw1[15:8]at bits[11:4], which has no single-UBFX form), andn > hishifts the whole run out (a degenerate zero result). Whenlo < nthe mask's low bits below the shift are simply dropped by the LSR, and the fold still holds (the extracted field isws[hi .. n]). - The mask is decoded to its concrete value (the AArch64
DecodeBitMasksreconstruction) and accepted only as a single contiguous, non-wrapping run: replicated patterns (esize < datasize, e.g.0x0f0f0f0f) and rotated masks that wrap the top of the register leave a gap and are skipped.ANDS(flag-setting) is excluded -- dropping it would lose the NZCV write. - Fuse win (see the shift fold): mask + shift become one
UBFX-- one fewer instruction, the shift off the critical path.
- The left-shift mirror of the two checks above (an
LSL, rather than anLSR/AND, is the consumer):and wd, ws, #((1<<w)-1) ; lsl wd, wd, #nkeeps the lowwbits and shifts them up byn; equivalent toubfiz wd, ws, #n, #w(capping the width atdatasize-nwhenn+wwould overflow, since the high bits shift out). Example:and w0, w1, #0xff ; lsl w0, w0, #4->ubfiz w0, w1, #4, #8.- A zero-extension keeps the same low field as that
AND, so it folds identically (reported as "zero-extend + LSL foldable into UBFIZ"). The recognised producers areuxtb/uxth/uxtw(the UBFM aliases,w= 8/16/32) and the W-formmov wd, ws(orr wd, wzr, ws, which zero-extends the low 32 bits,w= 32).uxtwand the W-formmovestablish a 32-bit field consumed by a 64-bitlsl, so they fold into anX-formubfiz;uxtb/uxthkeep a 32-bit-register field consumed by aW-formlsl. Example:mov w0, w0 ; lsl x0, x0, #2->ubfiz x0, x0, #2, #32(the .NET 7 idiom). The producer's result width must match thelsland the emittedubfiz, so a cross-width pair such asuxtb w0, w1 ; lsl x0, x0, #4is conservatively left unflagged. lsr wd, ws, #a ; lsl wd, wd, #a(equal shifts) is a round-trip that clears the lowabits; equivalent toand wd, ws, #~((1<<a)-1)(the high mask is always a valid bitmask immediate). Example:lsr w0, w1, #4 ; lsl w0, w0, #4->and w0, w1, #0xfffffff0.
LSR+LSLwith unequal shifts is not folded: the surviving field is neither low-aligned nor zero-aligned, so it has no singleUBFM/ANDform (theLSL+LSRorder, by contrast, always folds -- see the two-shift check above). TheLSLmust read and write the producer's destination, andANDS(flag-setting) is excluded by the low-mask decoder.- Fuse win (see the shift fold): two instructions become one, with the shift/mask off the critical path.
- Generalises the previous "redundant UXTW after W-form ALU" rule
to size-aware producer/consumer pairs. The check tracks the
threshold
Pat which the producer guaranteesRt[63:P] == 0; a consumer that clears bits aboveCis redundant whenP <= C. - Baseline thresholds: any W-form data-processing write gives
P = 32(the W write zerosX[63:32]-- the producer set coversADD/SUBimmediate/shifted/extended, logical immediate,MOVZ/MOVN/MOVK, bitfieldSBFM/BFM/UBFM,EXTR, logical shifted register,ADC/SBC, conditional select, DP-3/2/1-source), and the W-form integer loads (any addressing mode) give their access width: 8 forLDRB Wt, 16 forLDRH Wt, 32 forLDR Wt/LDRSB Wt/LDRSH Wt. - Value-derived thresholds pin
Ptighter -- and, since they bound the whole 64-bit result, qualify X-form producers too:UBFM(both forms), from the field geometry: an extraction (imms >= immr-- theUBFX/LSR/UXTB/UXTHshapes) leaves a field ofimms-immr+1low bits, soPis that width (lsr w8, w9, #24givesP = 8); an insertion (imms < immr-- theUBFIZ/LSLshapes) tops out atP = datasize-immr+imms+1, so anLSLgets no sharpening (P = datasize).AND/ANDSimmediate (both forms): the result is a subset of the mask, soP= the mask's top set bit + 1 (and x0, x1, #0xffgivesP = 8).ORR/EORpropagateRn's high bits and keep only the generic W-form threshold.MOVZ(both forms): the value is fully known, soP= its bit count (movz w0, #0x12givesP = 5).CSINC Rd, ZR, ZR, cond(theCSETfamily): the result is 0 or 1 regardless of the condition, soP = 1. An X-form producer whose computedPis 64 guarantees nothing and is skipped.
- Recognised consumers, each requiring
Rd == Rn == producer.Rdso the consumer is purely dead:- an in-place
UBFMwithimmr = 0of any widthC = imms+1-- theUXTB/UXTH/UXTWaliases and the generalUBFX Rd, Rd, #0, #C; the full-width copies (MOV Wd, Wnatimms = 31, and the X-form no-op atimms = 63) clear nothing and are excluded; - an AND-imm whose mask is a contiguous run of
Clow bits (any width, e.g.#0x1fforC = 5), in W or X register variants; MOV Wd, Wd(ORR Wd, WZR, WdwithRm = Rd; the W-form register MOV writes back through the W register and so clears X[63:32], givingC = 32).
- an in-place
- Example flags:
add w0,w1,w2 ; uxtw x0,w0;ldrb w8,[x9] ; and w8,w8,#0xff;lsr w8,w9,#24 ; and w8,w8,#0xff(P = 8);ubfx w8,w9,#3,#4 ; uxtb w8,w8(P = 4);and x0,x1,#0xff ; uxtb w0,w0(X-form producer);cset w8,eq ; and w8,w8,#1(P = 1). Counter-examples (not flagged):ldr w0,[x1] ; uxth w0,w0-- LDR W loads 32 valid bits, so UXTH would actually clear bits 31..16;orr w0,w1,#0xf ; uxtb w0,w0-- ORR can propagate high bits ofw1. - A sharpened threshold can make this check and a bitfield fold
fire on the same pair:
lsr w8, w9, #24 ; and w8, w8, #0xffis also theLSR+AND -> UBFXshape with the width capped. The two findings offer equivalent one-instruction outcomes -- drop the dead AND, or fuse the pair -- and both are reported, like the CMP-drop/CBZ-fold overlap.
- The X-form register MOV alias (
ORR Xd, XZR, Xm, LSL #0) withRm = RdreadsXdand writes the same 64 bits back; the instruction has no architectural effect and can be removed. It shows up occasionally in hand-written assembly and in legacy object code. - The W-form
MOV Wd, Wdis NOT a no-op: writing throughWdclearsX[63:32]. It is handled instead as a consumer of the redundant-zero-extension check above, where it fires only when a preceding producer already zeroed those bits.
- Mirror of the zero-extension framework above. The check tracks two
thresholds
(S, W): the producer guaranteesRd[W-1:S] = sign(Rd[S-1]). A consumerSXTB / SXTH / SXTWwith thresholds(S_c, W_c)is redundant iffS_p <= S_cANDW_p == W_cANDRd == Rn == producer.Rd. - Recognised producers: the sign-extending integer loads
LDRSB / LDRSH / LDRSWin any addressing mode, and anySBFM, withSfrom the field geometry: an extraction (imms >= immr) leaves a field ofimms-immr+1low bits and replicates its sign upward, soSis that width -- this covers theSXTB/SXTH/SXTWaliases (immr = 0,S= 8/16/32),ASR Rd, Rn, #k(imms = datasize-1, soS = datasize-k), and the generalSBFX Rd, Rn, #lsb, #w(S = w); an insertion (imms < immr, theSBFIZshape) places the field with its top at bitdatasize-immr+immsand replicates from there, soSis one above that (sbfiz w0, w1, #8, #8givesS = 16).S == datasize-- the full-width copy, or anSBFIZwhose field reaches the top bit -- leaves no sign-replicated region and is not a producer.W = datasizethroughout. (S, W) maps for the canonical SXT* pairs:LDRSB Wt/SXTB Wd,Wn-> (8, 32);LDRSH Wt/SXTH Wd,Wn-> (16, 32);LDRSB Xt/SXTB Xd,Wn-> (8, 64);LDRSH Xt/SXTH Xd,Wn-> (16, 64);LDRSW Xt/SXTW Xd,Wn-> (32, 64). Example flagged pairs:asr w0, w1, #24 ; sxtb w0, w0(S_p=8 = S_c=8);asr x0, x1, #48 ; sxth x0, w0;sbfx w0, w1, #4, #8 ; sxtb w0, w0(the extracted byte's sign is already replicated). W_p == W_c(not<=) because a W-form consumer writes back throughWdand zerosX[63:32], which differs from an X-form producer's sign-extended upper half. Example flagged:ldrsb w0, [x1] ; sxtb w0, w0;ldrsh x0, [x1] ; sxth x0, w0;ldrsb w0, [x1] ; sxth w0, w0(S_p=8 subsumes S_c=16 within W=32). Counter-example:ldrsb w0, [x1] ; sxtb x0, w0-- producer leftX[63:32] = 0, consumer would set those bits to sign of byte; not redundant.- Same producer state also feeds a "dead sign-extension" path: if
the next instruction is a zero-ext consumer (
UXTB/UXTH/UXTWor general in-placeUBFX #0,ANDwith low-mask, orMOV Wd, Wd) that clears bits>= C_cwithC_c <= S_p, the consumer overwrites every sign-extended bit and the producer can be deleted outright. No width-matching constraint is needed -- when widths mismatch, the W-form auto-zero ofX[63:32]covers the upper half. Example flagged:sxtb w0, w0 ; uxtb w0, w0(drop thesxtb);sbfx w0, w0, #0, #5 ; and w0, w0, #0x1f(drop thesbfx). - The dead path only fires for an in-place sign-extension -- an
SBFMwithimmr = 0(so the data stays in the low bits) andRn == Rd(so the low bits areRd's own). Every other producer writes fresh data into the bits the consumer keeps, so deleting it would change the result:ASR,SBFXwithlsb > 0, andSBFIZrelocate the field -- e.g.asr w0, w1, #24 ; uxtb w0, w0keepsw1[31:24], whereas dropping theASRwould keepw1[7:0].- An extend with
Rn != Rdcopies fromRn:sxtb w0, w1 ; uxtb w0, w0needs the re-sourcing rewriteuxtb w0, w1, not a deletion. - The sign-extending loads bring the value in from memory:
ldrsb w0, [x1] ; uxtb w0, w0would needldrb w0, [x1]-- dropping the load loses the access. The last two shapes have valid one-instruction rewrites that re-source the consumer rather than delete the producer; armlint conservatively reports neither, and stays silent on all of these.
AND Rd, Rs, RsandORR Rd, Rs, Rscollapse toMOV Rd, Rs(identity).EOR Rd, Rs, Rs,SUB Rd, Rs, Rs, andBIC Rd, Rs, Rs(=Rs AND NOT Rs) collapse toMOV Rd, XZR(zero).ORN Rd, Rs, Rs(=Rs OR NOT Rs) andEON Rd, Rs, Rs(=Rs XOR NOT Rs) collapse toMOV Rd, #-1/MOVN Rd, #0(all-ones). Both W- and X-form.- The flag-setting variants
ANDS Rd, Rs, Rs,SUBS Rd, Rs, Rs, andBICS Rd, Rs, Rsare deliberately NOT flagged: writingRdwhile setting flags is the user's intent (combined zero-test + register copy or register zero). Rd = 31(result discarded) andRn = 31(ZRsource, not a real self-op) are excluded.- On uarches with move elimination,
MOV Rd, Rsis zero-cycle whileAND/ORR Rd, Rs, Rsgoes through the ALU.EOR Rd, Rs, Rsis the canonical x86 zero idiom; the canonical AArch64 form isMOV Rd, XZR.
-
The vector twin of the scalar self-op check above, on the ASIMD three-same forms whose two source registers are the same:
eor v26.16b, v26.16b, v26.16b -> movi v26.2d, #0 and v0.16b, v1.16b, v1.16b -> mov v0.16b, v1.16b orr v0.16b, v0.16b, v0.16b -> delete -
EOR,BICandSUBcollapse to zero;ANDandORRgive the operand back.ORNgives all-ones, which needs a different rewrite (aMOVIwith a 0xFF immediate) and does not occur in the corpus, so it is decoded and declined rather than mis-reported.BSL/BIT/BIFshareEOR's U bit, separated by size, but read the destination as a third source -- a different shape. -
One member must never be flagged.
orr Vd, Vn, VnwithRd != Rnis the canonical spelling of the vectorMOV: the assembler emits it for everymov vd.16b, vn.16b. Only its in-place form, writing a register its own value, is a finding. Counting the copies inflated a first pass over this shape from 353 to 2,158, a 6x overcount. -
The
MOVIrewrite uses a2Darrangement whatever the source's was. That is right for aD-form self-op too: every AArch64 SIMD instruction with a 64-bit arrangement zeroes the upper half of its destination, so the two are the same value. -
No liveness argument, and no side-entry question. The rewrite is 1-for-1 into the same destination with the same value; nothing is deleted except in the in-place identity case, and no flags, memory or FP exceptions are involved. A branch landing on the instruction is harmless because the semantics do not change.
-
What it saves is issue, not size.
MOVIwith a zero immediate is on Neoverse V2's "Zero Latency MOVs" list (SWOG section 4.12, whose members "do not utilize the scheduling and execution resources of the machine") and on Apple Firestorm's rename-eliminated set. The self-op is on neither: V2's tables charge the ASIMD logical group latency 2, throughput 4 on the V pipe, the same as a generalMOVI, and the zero form is carved out of that cost whileeor Vn, Vnis not. Every site in the corpus is in place, so the self-op also carries a false dependency on the register's own previous value. Neoverse N1's guide documents no such elimination at all -- its section 4 has no zero-latency list -- so this is "cheaper on newer cores, neutral on older", the same shape as the low-32 fold. -
Corpus: 353 findings across 28.4M instructions (315 libcrypto, 28 go, 10 dyld) -- exactly the swept population, because this candidate has neither an encodability condition nor a deadness gate to lose sites to. 351 are
eor Vd, Vd, Vdand 2 are in-placeorr; nosuborandself-op occurs. 315 of the 353 are in one function, OpenSSL's_asm_aescbc_sha1_hmac, where they zero accumulators four at a time inside the stitched AES-CBC + SHA1-HMAC loop.
-
An ALU instruction whose Rm operand is the zero register collapses, because one input is a known constant:
orr w0, w1, wzr -> mov w0, w1 add / sub / eor / bic likewise and w0, w1, wzr -> mov w0, wzr mul likewise orn w0, w1, wzr -> mov w0, #-1 eon w0, w1, wzr -> mvn w0, w1 -
Rm is the deliberate side, and it is what keeps the alias table out. Every canonical degenerate spelling puts ZR in Rn:
mov Rd, Rmisorr Rd, ZR, Rm,neg Rd, Rmissub Rd, ZR, Rm,mvn Rd, Rmisorn Rd, ZR, Rm. Those are the assembler's own output for three of the most common instructions in any binary, and reporting them would be nonsense. RequiringRm = 31withRn != 31excludes all of them without enumerating a single alias. -
Rd = 31is excluded too: that instruction writes nothing and belongs to the dead-ZR-destination candidate, which the corpus measures at zero. -
The S-variants are excluded (
ANDS/BICS/ADDS/SUBS). Their flag write is a second result the rewrite would drop, which is the dead-flag candidate's question, not this one. The first version of the ADD/SUB mask left the S bit free and reported threeaddsin go, taking the corpus figure to 95 against a swept 90 -- the discrepancy is what found the bug. -
Only the unshifted forms are matched. A shifted ZR is still zero, so
orr w0, w1, wzr, lsl #3would fold identically; admitting it would make the reported figure diverge from the swept population for no new shape. Recorded rather than done. -
No liveness argument and no side entry, like the two self-op checks: 1-for-1 into the same destination with the same value, no flags, no memory.
-
Corpus: 90 findings across 28.4M instructions, every one in librustc_driver -- exactly the swept population. By operation: 67
orr, 14and, 7add, 2sub. Nomul,bic,ornoreonsite occurs, so four of the nine decoded members are carried on the strength of the encoding rather than of the corpus.
- Two
LDR Wt, [Rn, #imm](or X-form) to consecutive offsets fold into a singleLDP Wt1, Wt2, [Rn, #imm7*4]. Analogous for stores ->STP. Both W- and X-form supported, and the SIMD&FP S/D/Q sizes (scales 4/8/16) coalesce the same way into their ownLDP/STPforms -- the FP B and H sizes have no pair encoding and are not flagged. Load+load and store+store only; no mixing of direction, size, or register file. - Why it helps: one paired access replaces two single ones -- halving the load/store instruction count (decode/issue slots, code size) and, on most cores, the number of memory micro-ops. This is the inverse of the LDP-with-writeback caveat noted for the post-index fold: the plain pair forms are a win, whereas pairing with writeback can cost extra micro-ops on Apple cores.
- Both spellings of the addressing mode are decoded: the
unsigned-offset form (
LDR, imm12 scaled by the access size) and the unscaled one (LDUR, a signed 9-bit byte count). Assemblers choose per instruction -- JSC's arm64 MacroAssembler, for one, emitsLDURfor every displacement under 256 -- so a single run of accesses routinely comes out as a mix, and matching only the scaled form misses both the all-unscaled runs and the mixed pairs. Offsets are therefore normalized to signed bytes before comparison, and a pair fires whichever way each half is spelled. - Because the unscaled form can express displacements the pair form
cannot, the imm7 test is explicit rather than implied by the
encoding: the lower of the two byte offsets must divide evenly by
the transfer size and lie within -64..63 of them. The alignment half
of that is what the scaled imm12 used to guarantee for free, and it
is what keeps the rewrite off addresses where
LDP/STPhas implementation-defined behaviour on AArch64 (some cores fault even where a singleLDR/STRworks underSCTLR_EL1.A = 0). - Pre- and post-indexed forms remain deferred: they write back to the base, so they are not interchangeable with a plain pair.
- Constraints checked: same base register
Rn; same access size (both W, both X, or the same S/D/Q); same direction (load/load or store/store); consecutive offsets (imm12_2 = imm12_1 + 1in scaled units);Rt1 != Rt2for LOADS only --LDP/LDPSWwithRt1 == Rt2is CONSTRAINED UNPREDICTABLE, but stores have no such restriction, so a repeated source pairs fine (str x5, [sp] ; str x5, [sp, #8]->stp x5, x5, [sp]); for integer loads, the first instruction'sRt != Rn(else the first load clobbers the base before the second load reads it) -- a SIMD&FPRtcan never alias the integer base, so that guard does not apply to FP pairs. The LOWER of the two byte offsets must also be a multiple of the transfer size and fit LDP's signed 7-bit imm7 in units of it. - Reverse-order pairs (
ldr Rt2, [Rn, #imm+1] ; ldr Rt1, [Rn, #imm]-- higher offset first) are also coalesced, into aldp Rt1, Rt2, [Rn, #imm]with the Rt operands ordered by ascending address. The load-aliasing concern is about source order, not address order, so the constraint is on the FIRST instruction in source order regardless of which offset it targets. - Four consecutive LDR/STRs fold into TWO non-overlapping LDP/STPs (after firing, the state resets so the second LDR isn't also used as the first of a new pair).
- Atomicity caveat: a single LDP is NOT atomic across its two halves (AArch64 doesn't guarantee single-copy atomicity for pairs), but neither are two separate LDRs. So the rewrite doesn't change ordering or atomicity guarantees -- acquire / release variants use different opcodes.
- Adjacent
LDRSW Xt, [Rn, #imm]pairs (LDURSWincluded) fold analogously into a singleLDPSW Xt1, Xt2, [Rn, #imm7*4]. Same constraints (same base, consecutive offsets, distinct Rts, firstRt != Rn, lower offset within a 4-byte-scaled imm7), with the added requirement that the kind matches: a pendingLDRdoes not pair with anLDRSW(different opcode, different sign-extension semantics). LDPSW is always 64-bit destination, load-only, 4-byte transfer.
- Two consecutive W-form stores of the zero register --
STR WZR, [Rn, #imm12*4] ; STR WZR, [Rn, #(imm12+1)*4]-- write the same eight bytes as a singleSTR XZR. This is a refinement of the LDP/STP coalescer above: rather than theSTP WZR, WZRa generic pair fold would emit, both sources being the zero register let one wider store replace the pair outright. - Why it helps: a single 8-byte store replaces two 4-byte stores -- one fewer instruction (decode/issue slot, code size) and one fewer store micro-op.
- When the combined 8-byte offset is a non-negative multiple of 8 the
rewrite is the scaled
STR XZR, [Rn, #off]; an odd 4-byte slot (off % 8 == 4) is not encodable in the scaled form and is reported as the unscaledSTUR XZR, [Rn, #off]. The source offsets are bounded by the coalescer's imm7 gate (lower imm12 <= 63), so the byte offset is in [0, 252] -- in range for whichever form applies. - Only the W-form collapses. Two X-form zero stores span sixteen bytes,
which has no single-GPR-store equivalent (
STP XZR, XZRis already the canonical 16-byte zero store); they are left to the ordinary pair logic. Reverse order (higher offset first) folds the same way. - A mixed pair, where only one source is the zero register, is not a
candidate for the single store and coalesces into an ordinary
STP-- now with the zero operand correctly rendered aswzrrather than the non-assemblablew31.
- A standalone
STP WZR, WZR, [Rn, #imm7*4](W-form, signed offset, no writeback) zeroes eight contiguous bytes -- exactly what a singleSTR XZRdoes -- but as a store-pair operation. Replacing it with the single store drops a micro-op on cores that crack the pair, with no change in architectural effect. - Like the two-store consolidation above, the rewrite is the scaled
STR XZRwhen the byte offset is a non-negative multiple of 8 and the unscaledSTUR XZRotherwise (an odd 4-byte slot or a negative offset). The W-form imm7 yields byte offsets in [-256, 252], all in range for whichever form applies. - Only the 32-bit pair collapses.
STP XZR, XZRzeroes sixteen bytes and has no single-GPR-store form; it is the canonical 16-byte zero store and is left alone. - Soundness: the match is by encoding, requiring
opc = 00(W-form), the signed-offset addressing mode (no writeback), andL = 0(store), with both transfer registers = 31. Pre- and post-indexed writeback forms additionally updateRn, so they are NOT equivalent to a plainSTRand are excluded by the addressing-mode bits;STNP(non-temporal) and the load (LDP) likewise do not match.
- The "clear a field, isolate the same field from a source, OR the two
together" idiom collapses to a single bitfield-insert. With the field
at the low end it is
BFXIL; at an arbitrary positionlsbit isBFI:AND Rd, Rd, #~mask ; AND Rt, Rs, #mask ; ORR Rd, Rd, Rt->BFXIL Rd, Rs, #0, #w(withmask = (1<<w)-1)AND Rd, Rd, #~(mask<<lsb) ; UBFIZ Rt, Rs, #lsb, #w ; ORR Rd, Rd, Rt->BFI Rd, Rs, #lsb, #wThe clear and isolate may appear in either order, and the ORR's second-and-third operands in either order. Both W- and X-form. The check detects the 3-instruction window with strict adjacency.
- The clear is an in-place AND (
Rd == Rn) whose mask, reconstructed to its concrete value, zeros a single contiguous run ofwbits at positionlsb; rotated or split masks have no single-field form and are rejected. The isolate is either a low-maskAND Rt, Rs, #mask(lsb == 0) or aUBFIZ Rt, Rs, #lsb, #w(lsb > 0); when the field reaches the top of the register the UBFIZ encodes identically toLSLand is matched the same way. The ORR is logical-shifted-register with LSL #0. - Clear and isolate are told apart by whether the AND writes in place: a
clear is always
AND Rd, Rd, ..., while a sound isolate writes a separate temp. This matters because an in-place low-mask AND -- e.g. one clearing a field that reaches the top bit -- matches both shapes;Rd == Rnfixes the role. - Aliasing constraints needed for the rewrite to be semantically
equivalent:
Rt != clear.Rd(else the isolate clobbers the cleared register in place),Rt != Rs(else the isolate modifies the source -- the insert leavesRsunchanged), andRs != clear.Rd(the degenerate case whereRsis the just-cleared register yields the wrong result -- the original sequence zeros the field, butBFI Rd, Rd, ...would re-read it). - Useful for hand-written assembly and legacy object code.
- The AArch64 SIMD compares have a register form (
CMEQ/CMGE/CMGT Vd, Vn, Vm, and the FPFCMEQ/FCMGE/FCMGT) and a compare-against-zero form (CMEQ/CMGE/CMGT/CMLE/CMLT Vd, Vn, #0, FP... #0.0). AMOVI Vz, #0that materializes an all-zero vector, immediately consumed by a register compare againstVz, is the zero form spelled in two instructions. - The fold drops the
MOVIand rewrites the compare to the#0form:movi v16.4s, #0 ; cmeq v16.4s, v16.4s, v0.4s->cmeq v16.4s, v0.4s, #0.CMEQ/FCMEQare symmetric, so the zero may sit in either source. The ordered compares are not: a zero left operand flips the sense, because0 >= XisX <= 0and0 > XisX < 0. Socmge Vd, Vz, Xbecomescmle Vd, X, #0andcmgt Vd, Vz, Xbecomescmlt Vd, X, #0(likewisefcmle/fcmlt); a zero right operand keeps the mnemonic (cmge/cmgt/fcmge/fcmgt ..., #0).
- Soundness rests on structural liveness: the fold fires only when the
compare overwrites the zero register (
Vd == Vz), proving the materialized zero is dead. The common compiler output -- a throwaway zero temp that the compare reuses as its destination -- has exactly this shape. A compare that writes a different register leaves the zero potentially live, so removing theMOVIwould need a register-liveness pass; that case is left un-flagged. - The producer's arrangement is irrelevant: any zeroing
MOVIclears all 128 bits (a 64-bit form zeros the upper half too), somovi v3.8b, #0feeds a.16bcompare as well as a matching.4szero does. Only aMOVI(includingMOVI Vd.2D) with an all-zero immediate qualifies;MVNI, a non-zero immediate, and the MSL ones-fillingcmodes yield non-zero vectors and are excluded. - Not matched: the unsigned compares (
CMHI/CMHS) and the bitwiseCMTSThave no direct compare-with-#0equivalent (e.g.CMHI Vd, X, ZisX != 0, which the#0forms cannot express), and the absolute FP compares (FACGE/FACGT) and the half-precision compares are likewise left alone. - Saves an instruction and frees a register: the zero vector no longer needs to be materialized or to occupy a register.
- When the CSEL's
Rn == Rm, both branches produceRn, so the cond is irrelevant and the instruction is equivalent toMOV Rd, Rn. The CSEL also reads NZCV for no reason. Both W- and X-form. - Only
CSEL(op2 = 00) is flagged. The other members of the conditional-select family --CSINC,CSINV,CSNEG-- have different "else" branches (Rn+1, ~Rn, -Rn) and are NOT identities whenRn == Rm. The decoder enforces(op & 0x7FE00C00) == 0x1A800000, which fixes op2 = 00. Rd = 31(result discarded) andRn = 31(ZRsource) are excluded for consistency with the other self-op identity check.
- The FP mirror of the check above:
FCSELis a pure bit-pattern select -- no arithmetic, no NaN processing -- soVn == Vmmakes the condition irrelevant and the instruction a register copy,fmov Vd, Vn. BothFCSELandFMOV (register)zero the vector register above the written lane, so the rewrite is exact for the full 128 bits; the pointless NZCV read disappears too, freeing the select from its flags dependency. - Single and double precision only; half precision (FEAT_FP16) is
not matched, consistent with the other FP checks. FP registers
have no ZR/SP encoding, so no operand exclusions apply -- even the
fully self-referential
fcsel d0, d0, d0, ccfolds (tofmov d0, d0, which is not a no-op: both spellings rewrite the lane and zero above it).
- The non-flag-setting
ADD Rd, Rn, #0orSUB Rd, Rn, #0is a no-op whenRd == Rnand is equivalent toMOV Rd, RnwhenRd != Rn. The explicitADD #0shows up occasionally in real code, notably as a way to set up a function argument from a callee-saved register. - The
ADDS/SUBSflag-setting variants are not flagged: writingRdand settingZ = (Rn == 0)may both be wanted. The SP encoding (Rd = 31orRn = 31) is also excluded because that's the canonicalMOV (to/from SP)alias and the only way to spellMOV X0, SP/MOV SP, X0. - The
Rd == Rncase is further suppressed when immediately preceded byADR/ADRPwith the sameRd: that's a page-relative addressing pair (adrp x8, page ; add x8, x8, #pageoff) where the linker happened to resolvepageoffto 0. Removing theADDrequires re-linking, not an assembler rewrite, so it's not actionable.
-
Two adjacent non-flag-setting
ADD/SUBimmediates adjusting the same register are one instruction's worth of arithmetic:add x11, sp, #0x130 ; add x11, x11, #0x81->add x11, sp, #0x1b1sub x8, x29, #0x100 ; add x8, x8, #0x30->sub x8, x29, #0xd0("ADD/SUB immediate chain foldable to one"). The kinds mix freely: each instruction contributes a signed amount, and the fold renders whichever ofADD/SUBcarries the sum -- orMOVwhen the two cancel exactly.
-
The sum must encode.
ADD/SUBimmediate is a 12-bit unsigned field, optionally shifted left by 12; the two ranges do not overlap above 4095, since the shifted form reaches only multiples of 4096. That single gate is also what keeps the compiler's own split of a wide constant unflagged, with no special case for it:add x8, x8, #0x1, lsl #12 ; add x8, x8, #0x20sums to 0x1020, which is neither an imm12 nor a multiple of 4096, so the already-minimal pair fails the test. -
Widths must agree. A W-form producer zero-extends its 32-bit sum into the full register before an X-form consumer reads it, which 64-bit arithmetic on the original source does not reproduce.
-
Both instructions must be non-flag-setting. An
ADDS/SUBSproducer cannot be deleted without losing its NZCV write. AnADDS/SUBSconsumer is excluded for a subtler reason: the folded instruction computes the same result but not the same flags, because C and V depend on the intermediate the fold erases. Withx9 = -1,add x8, x9, #1 ; adds x0, x8, #1leaves C = 0 whereadds x0, x9, #2leaves C = 1. -
Soundness otherwise: the rewrite deletes the producer, so its destination must be dead afterward. A consumer writing that same register kills it structurally and emits on the spot -- the dominant shape; a fresh destination defers through the forward register-liveness scan. A producer whose destination is SP (
Rd= 31 in this encoding) never opens: the stack pointer is never dead, because an asynchronous signal delivered between the two instructions observes the intermediate value. A zero adjustment on either side is not a chain but a redundant instruction, left toADD/SUB #0 is redundantso no window is reported twice. -
The two adjustments need not be adjacent. The fold is about the two constants, not about the instructions being neighbours, so the scan runs a bounded window rather than a single slot. It gives up on whichever comes first: a read of the intermediate (the producer must stay for that use, so nothing is saved), an overwrite of it (there is no chain left to close), a control transfer, or the window expiring.
-
The window watches the producer's source as well, which no adjacency-based fold has to. The rewritten instruction reads it at the consumer's position rather than at the producer, so anything moving it in between makes the folded constant come off the wrong base. When the source is SP the register-liveness scan cannot help --
arm64_gpr_nummaps SP to -1 -- so the encodings that write it are matched directly, the same recovery the multi-use ADD fold makes. -
insn_countspans producer through consumer rather than a fixed 2, so the central side-entry gate covers the gap exactly: a branch landing between them reaches the second adjustment on a path that never made the first. -
One tracked chain at a time. An unrelated ADD/SUB immediate inside the gap replaces the pending one, so a chain straddling another is a false negative, never a wrong finding.
-
The gap is worth +771 findings on the corpus (26,893 -> 27,664), 770 of them in librustc_driver. The swept candidate population -- two adjustments on the same register with the intermediate untouched across the gap and no control transfer between -- is 6,039, so about one in eight survives the deadness proof and the side-entry gate.
-
Where the shape comes from. Two unrelated LLVM behaviours produce it in roughly equal measure. Classifying librustc_driver's 26,929 findings by what feeds the opening instruction: 13,607 (50.6%) are global-address arithmetic, where the open is the
addof anadrp/addpage pair; 12,875 (47.8%) are stack-address arithmetic, where the open issp- or frame-pointer-relative; 447 (1.6%) are neither. -
The stack half is instruction selection. A bare
ISD::FrameIndexis selected toADDXri <FI>, 0with a hardcoded zero immediate (theISD::FrameIndexcase inAArch64DAGToDAGISel::Select), so an interior pointer into an alloca that escapes becomes two instructions before frame layout is even considered:%0:gpr64sp = ADDXri %stack.0.a, 0, 0 %1:gpr64sp = nuw ADDXri killed %0, 8, 0which resolves to
add x8, sp, #0x320 ; add x8, x8, #0x8. The offset was never expensive:rewriteAArch64FrameIndex()already adds whatever immediate anADDXricarries to the resolved frame offset, so selection could have put the constant there and simply did not. Memory operands escape the shape becauseSelectAddrModeIndexedfoldsFrameIndex + offsetinto the addressing mode -- the same address used by a load is oneldr x0, [sp, #0x328]-- which is why the shape marks escaping interior pointers specifically. Nothing downstream repairs it:LocalStackSlotAllocationleaves the pair untouched, andAArch64MIPeepholeOptcoalescesADDXrrbut notADDXrichains. -
The global half is a declined relocation fold.
performGlobalAddressCombineinAArch64ISelLowering.cppnormally folds a constant offset into the symbol's relocation addend, givingadrp x8, sym@PAGE+16 ; add x8, x8, sym@PAGEOFF+16. It declines in three cases, and each declined fold leaves a third instruction behind: when the offset runs past the object's size (Offset > getTypeAllocSize), the dominant shape here; when the same global is also used at a smaller offset, which trips its "require that the new offset is larger" guard; and when the offset is negative, which it skips with the comment that those "aren't really common enough to matter" -- borne out here, where only 50 of the 13,607 global chains close with asub. -
The two halves are not equally actionable. The stack half is a plain missed optimization -- the constant is available at selection and the frame-index rewrite already accepts it. Folding it there removes 3,893 of 3,893 chains from the 25 largest translation units of an LLVM+clang build, and 0.35% of all instructions emitted, with no translation unit getting larger. That corpus is all stack family: measured on unlinked objects, where
add xN, xN, @PAGEOFFstill carries a zero immediate awaiting relocation, so the open half of a global chain does not yet exist. The global half is a relink-level observation, for the same reason as theadrp+addnote underADD/SUB #0 is redundant: armlint reads the linked image, wherepageoffis resolved and it can check thatpageoff + Kstill encodes, but the compiler emitting the relocation cannot prove that statically. Both halves are sound rewrites of the binary in hand, which is what the check reports. -
The stack half explains the distribution. The check reports 26,929 findings in
librustc_driver(26.0M instructions) against 37 in dyld, 11 in go, 11 in libcrypto, 5 in ssh and 4 in bash -- 1,037 per million instructions against 229, 44 and 34 for the C and C++ binaries. rustc's frames are large and full of interior pointers -- enum payloads, iterator and future state,&mutborrows into locals -- and every one that escapes to a call rather than being loaded through pays the extraadd. The corpus has no large C program, though, so that ratio mixes language with program size. The second immediate is a field offset in both halves: median 16 bytes, 98.6% under 256. -
Verification:
tools/shapescan.pyindependently identifies 46,115 candidate chains inlibrustc_driver; armlint reports a strict subset of them, with no finding outside that set, the remainder suppressed by the side-entry gate or an unproven liveness scan. A random 400 of the reported folds were assembled and executed against their original pairs over 5,000 random register states each: every suggestion computes the identical value.
adds/subs/ands/bics/adcs/sbcs Rd, ... ; cmp Rd, #0 ; b.eq/b.ne L-- the S-variant ALU already setZ = (Rd == 0), so theCMP/TSTis recomputing the sameZ. TheB.EQ/B.NEcan read the S-variant's flags directly; theCMP/TSTis dead.- Currently requires the full three-instruction window: S-variant
immediately followed by
CMP Rd, #0/CMP Rd, ZR/TST Rd, Rd, immediately followed byB.EQ/B.NE. The same forward NZCV-liveness scan as the CMP+B.cond check confirms that downstream code does not observe N/C/V (which the S-variant sets differently from theCMP). - Combines with the CMP+B.cond -> CBZ/CBNZ check above: both fire
on the matching pattern, giving the user a choice between
dropping the
CMP(and keeping theB.cond) or folding theCMP+B.condpair into aCBZ/CBNZ. Both rewrites have identical downstream behaviour.
- The mirror of the check above, one S bit over: when the producer
does NOT set flags but has a flag-setting twin, converting it makes
the zero test droppable --
add w0, w1, w2 ; cmp w0, #0 ; b.eq L->adds w0, w1, w2 ; b.eq L. Producers:ADD/SUB(immediate, shifted-register, extended-register) andAND/BIC(immediate forAND, shifted-register for both) -- every form spells its S-variant as the mnemonic plus "s", including theNEGalias (SUBfrom ZR), whose twin isNEGS.ORR/EORhave no S-forms and never match. - Flag argument:
Zis bit-identical (Rd == 0computed either way), and so isN(the sign ofRdunder every zero-test spelling).CandVdiffer --CMP Rd, #0pinsC = 1,V = 0, theCMN/TSTspellings pinC = 0,V = 0, while the arithmetic S-variants compute the operation's real carry and overflow. TheB.EQ/B.NEitself reads onlyZ; emission defers through the same forward NZCV-liveness scan as the sibling check (in a dedicated pending slot) until any later N/C/V read is ruled out -- conservative forN, which actually agrees. The logical S-forms pinC = V = 0exactly likeTST, soANDS/BICSafter aTSTconsumer is flag-exact; the scan is applied uniformly anyway. - Exclusions:
Rd = 31producers (SP for the immediate and extended forms -- an observable write the S-variant would redirect to ZR -- and a dead ZR write for the shifted ones);ADD/SUBimmediate withimm == 0(the redundant-ADD/MOV-from-SP shapes owned by the ADD/SUB #0 check, whosemovalias spelling must not gain an "s"); andADC/SBC, whose S twins exist but which read the very carry the surrounding code is testing -- left to a future extension. Width (W vs X) of the zero test must match the producer's. - Same three-instruction window as the sibling, and the same
interplay: the CMP+B.cond -> CBZ/CBNZ fold also fires on the
matching pair, so the user chooses between converting the ALU (and
dropping the
CMP) or foldingCMP+B.condinto aCBZ/CBNZ. - What it saves: one instruction -- the zero test -- and its NZCV def-use disappears into the ALU op the code already executes. The shape is common in hand-written assembly and naive codegen, which compute a value and then test it in two steps.
sub wd, wn, wm ; cmp wn, wm->subs wd, wn, wm, and the same with the pair in the other order (cmp wn, wm ; sub wd, wn, wm).CMP Rn, RmisSUBS ZR, Rn, Rm-- the identical subtraction -- and NZCV is a function of the operands only, never Rd, so the foldedSUBS's flags are bit-identical to theCMP's in all four bits. Unlike the zero-CMP fold above, whose C/V diverge, no flag-liveness scan is needed: downstream may read any condition, and the finding emits at the pair.- The operand match is by encoding: the
CMPmust be exactly theSUB's word with the S bit set andRd = 31. One comparison therefore covers the immediate, shifted-register and extended-register forms and enforces equal widths, shift types/amounts and extend options; the reversed compare (cmp wm, wn) never matches, since subtraction is not symmetric. - In the SUB-first order the
CMPruns after theSUBwroteRd, soRdmust not be one of the compared registers -- there theCMPread the difference, and the foldedSUBSwould compare pre-SUBvalues. The CMP-first order writes nothing before theSUBand needs no such restriction (cmp x1, x2 ; sub x1, x1, x2folds).Rd = 31producers are excluded: SP for the immediate and extended forms --SUBS'sRd = 31is ZR, so the fold would drop an observable SP update -- and a dead ZR write for the shifted form. An immediate of 0 is excluded across the family: the pair is degenerate (the ADD/SUB #0 check's shapes), and theADDside's MOV-from-SP alias spelling must not gain an "s". The S-variant spelling is the ALU's mnemonic plus "s" (NEGSfor theNEGalias). A compare that closes an ALU-first pair still opens a compare-first pending, sosub ; cmp ; subchains report both folds. - The
ADD+CMNfamily folds by the identical argument --CMN Rn, RmisADDS ZR, Rn, Rm, the same addition -- and is reported as "ADD + CMN of identical operands foldable to ADDS":add x0, x1, x2 ; cmn x1, x2->adds x0, x1, x2, either order. The word match pairs families automatically (anADD's compare spelling isCMN, aSUB's isCMP;ADD+CMPnever matches).ADDcommutes, so a swapped compare (add x0, x1, x2 ; cmn x2, x1, in either order) also folds for the plain unshifted register form: the CMN sums the same values, so all four NZCV bits match. Only that form swaps -- a nonzero shift amount breaks the symmetry (Rn + (Rm << s) != Rm + (Rn << s)), the immediate form has no second register, the extended form applies its extension to Rm only, and subtraction does not commute at all (cmp x2, x1aftersub x0, x1, x2never folds). - What it saves: one instruction -- the compare -- with zero flag
risk. The shape appears when code computes a difference and
separately compares the same operands: hand-written bounds checks
and naive codegen; optimizing compilers emit the
SUBSdirectly.
- The S-producer sibling of the fold above: when the ALU is ALREADY
flag-setting, the adjacent compare of its own operands recomputes
the NZCV the producer just set --
subs x0, x1, x2 ; cmp x1, x2-> drop thecmp(same forADDS+CMN, including the swapped-operandCMNfor the plain unshifted form, by the same commutativity argument). Nothing else is rewritten -- no mnemonic gains an "s" -- so even theimm = 0spellings need no exclusion here. - A producer with
Rd = 31is itself a compare, so adjacent duplicate compares (cmp x3, x4 ; cmp x3, x4) report under the same check. Chains report per pair: a compare that just closed one pair opens the next. - The compare must not read the producer's destination (
Rdamong the compared registers reads the result, not the original operand). Distinct from the redundantCMPafter S-variant check, which flags the compare of the RESULT against zero; this one flags the compare of the operands, and it needs no NZCV scan: the flags after the drop are bit-identical, unconditionally.
- A compare writes no register:
CMP/CMNareADDS/SUBSdiscarding into the zero register,TSTisANDSdoing the same, andCCMP/CCMNhave no destination field at all. So a compare whose NZCV nothing reads before some later instruction rewrites all four flags has no effect whatever, and the rewrite is a deletion:cmp w9, #8 ; cmp w9, #6-> drop the first. - This is the one member of the dead-flag family worth reporting.
Dropping the
soff anadds/subs/andswhose destination is still live is the same instruction count, the same encoding size and the same latency and port on every AArch64 core -- the flag write itself is a byproduct of the adder and costs a rename slot, not a cycle. Deleting a compare returns a whole instruction slot: a fetch slot, a decode slot, a ROB entry. The energy is in the instruction, not in the flags it wrote. - The deadness proof accepts exactly one stopper, a later full write
of NZCV (
LIV_OVERWRITE). The shared advancer (armlint_advance_pending) would also accept a call or a return, because the PCS leaves the condition flags undefined across both -- LLVM states that rule in as many words, and its machine outliner leans on it, outlining only ranges where NZCV is dead, which is why the tail of an_OUTLINED_FUNCTION_*is a place these turn up. But that is an argument about a callee rather than about code in front of the scanner, and hand-written assembly is free to ignore it: a context restore endingmsr nzcv, x8 ; retreads perfectly dead to a PCS-trusting scan. Deleting an instruction is not where that argument gets spent, soarmlint_advance_pending_dcrefuses both terminators. The corpus figures below are for the overwrite arm alone. - No side-entry gate, despite the window spanning several instructions. Only the head is deleted and nothing else in the window changes, so a path entering in the middle never executed the compare and cannot observe flags it did not write. The finding is one instruction wide and is reported that way. This is why armlint reports more sites than the standalone survey that motivated the check: that survey abandoned a candidate whenever a branch target fell inside the window, a caution the deletion does not need.
FCMP/FCMPEandFCCMP/FCCMPEare excluded even though they are equally destination-free. They also set the FPSR cumulative exception bits, and with the matching FPCR trap enabled may trap on a NaN, so deleting one drops architectural state an NZCV scan cannot see. They still count as killers -- anfcmpdoes overwrite all four flags -- just never as the deleted instruction.ADCS/SBCSinto the zero register are omitted for population rather than soundness: the corpus has none.- Overlaps
SUBS/ADDS+CMP/CMNof identical operands on exactly one shape, an adjacent pair of identical compares, where that check calls the second redundant and this one calls the first dead. Both rewrites are correct and both fire; the corpus contains no such pair. - Corpus: 315 sites across the 28.5M-instruction sweep -- 306 in
librustc_driver, 9 ingo, and zero inlibcrypto,dyld,bash,sshandzsh. The shape is overwhelmingly one compare followed by another within a handful of instructions (cmp+cmpaccounts for nearly all of it), which is why the strict-adjacency discipline the rest of the flag checks use would find only about a third of them: the surveyed distance histogram runs +1: 57, +2: 59, +3: 26, +4: 25, +5..7: 8. - Where the shape comes from: the same late-pipeline restructuring
that leaves the other flag residue behind. Tail merging and the
machine outliner move code across block boundaries after the
compare's consumer has already been rewritten or dropped, and
nothing re-runs dead-code elimination on the flags afterwards. A
representative
librustc_driversite interleaves three of armlint's findings at once --cmp w9, #8 ; cmp w9, #6 ; csel x10, x8, x8, eq ; cmp w9, #4 ; mov x8, x8 ; cmp w9, #5-- two dead compares, a same-operand CSEL and a literal no-opmov. - The other half of the dead-flag family -- an
adds/subs/andswith a live destination whose flags are equally dead -- is measured and deliberately unimplemented; see TODO.md.
add wt, ws, #1 ; csel wd, wn, wt, ccinstead ofcsinc wd, wn, ws, cc: CSINC's else-branch is an increment (Rd = cond ? Rn : Rm + 1), the exact mirror of theNEG->CSNEGandMVN->CSINVconsumers. The else slot carries the condition over; the then slot swaps operands and inverts it (csel wd, wt, wm, cc->csinc wd, wm, ws, !cc).- The rewrite reads the same NZCV the
CSELdid (the non-SADDwrites no flags) and readsws, which still holds its original value at the consumer once theADDis deleted -- even for the in-placeadd wt, wt, #1. AL/NV are excluded (the select is unconditional and the then-slot inversion would still be always-taken);Rd = 31discards the select; both slots readingwtis the CSEL identity's shape; widths must match. Register 31 in ADD-immediate means SP for bothRdandRn, while CSINC's slots are ZR-flavoured, so SP source/destination never open. - A destination overwriting
wtreports immediately; a fresh destination defers through the forward register-liveness scan.
cmp x1, #0 ; cset x0, ltinstead oflsr x0, x1, #63, andcmp x1, #0 ; csetm x0, ltinstead ofasr x0, x1, #63(#31for the W forms;miworks the same way). Materializing "is negative" is just moving the sign bit down: subtracting zero can neither borrow nor overflow, so the compare leavesN = sign(Rn),Z = (Rn == 0),C = 1,V = 0, and both LT (N != V) and MI (N) reduce to the bare sign bit.CSETwrites it as 0/1 -- exactly the logical shift (theUBFMalias) -- andCSETMwrites it replicated as 0/all-ones -- exactly the arithmetic shift (theSBFMalias). This is the value-materializing twin of the compare-zero signed-branch fold.- The rewrite deletes the compare and sets no flags, so emission
defers until NZCV is provably dead (any later flag reader -- even
b.eq-- discards), the same rule as the CSSC MAX/MIN/ABS folds. - The GE/PL complements are matched by neither:
cset x0, geneedslsr+eor #1andcsetm x0, geneedsmvn+asr-- two instructions for two, no win to report. - The widths must agree. The instructive counterexample is the X
CSETMafter a W compare: it writes a 64-bit 0/all-ones mask, which no single W-form shift produces (asr w, w, #31zero-extends its 32-bit mask) -- the sound-but-fiddly mixedCSETcombinations are gated with it for uniformity.Rn = 31compares SP, which the shift cannot name; AL/NV condition fields (a constant, not a conditional) and ZR destinations (dead code) never match. - Only the
CMP Rn, #0producer opens.tst x1, x1andcmn x1, #0pin the sameN/Vand would fold identically, but those zero-test spellings are left for a later pass. - Provenance: this is precisely the rewrite Go's ARM64 compiler backend adopted in CL 801282 ("use right shift to compute >= 0 and < 0", 2026) -- binaries from older compilers and hand-written assembly still carry the two-instruction shape.
mov xc, #(1<<N) ; mul xd, xa, xcinstead oflsl xd, xa, #N(power-of-2 multiplier). Same for W-form.mov xc, #(2^N + 1) ; mul xd, xa, xcinstead ofadd xd, xa, xa, lsl #N.- Why it helps: the multiply runs on a dedicated, multi-cycle,
limited-throughput pipe (~3-4 cycle latency), whereas
LSL/ADDare single-cycle on any ALU pipe -- lower latency, and the multiplier is left free for other work. - Reuses the MOVZ/MOVK chain state, so wide constants assembled
via
MOVZ + MOVK(e.g.2^16 + 1) are caught too. The MOV's width must match the MUL's. MUL is the canonical alias forMADD Rd, Rn, Rm, ZR; explicitMADDwith a non-zero accumulator is the MOV + MADD/MSUB check's shape. - The
2^N - 1case is intentionally not folded. AArch64 has no single shifted-register form that computesx*(2^N - 1):SUB Xd, Xn, Xn, LSL #Ngivesx*(1 - 2^N), the negation, so the rewrite would be two instructions (LSL+SUBorSUB+NEG) at parity withMOV+MULin count. - Dead-constant verification (shared by every MOV-chain fold --
MNEG,UDIV,MOV + ADD/SUB,MOV + AND/ORR/EOR,MOV + CCMP,MOV + FMOV/SCVTF/UCVTF,MOV #0 + use, the register-offset fold, and the MOVI zeroing fold'sMOV #0form): the reported saving assumes the constant register was materialised solely to feed this one consumer, and armlint verifies that before reporting. When the consumer itself overwrites the constant register, the chain is dead on the spot and the finding emits immediately; otherwise it is deferred through a bounded forward register-liveness scan and emitted only once a later instruction overwrites the register before any read or control transfer. A read, branch, call, return, or window expiry discards the finding -- the MOV must stay, so there is nothing worth reporting (the consumer rewrite itself -- thelsl/add #imm/etc. -- would remain valid either way). One shape is suppressed outright across the family: the consumer's surviving operand being the constant register itself (mul xd, xc, xc,udiv xd, xc, xc,add xd, xc, xc, ...). There the rewrite would still readxc, so the MOV could never be deleted even if nothing else uses it -- and an op whose every input is a known constant folds to another constant anyway, which is the rewrite a reader actually wants.
- Direct symmetric counterpart to the MUL strength reduction.
MNEG Rd, Rn, Rmis the canonical alias forMSUB Rd, Rn, Rm, ZR; same MOV-chain plumbing applies, including the dead-constant caveat and the multiplier-avoidance win noted under the MUL check. mov xc, #1 ; mneg xd, xa, xc->neg xd, xamov xc, #(1<<N) ; mneg xd, xa, xc->neg xd, xa, lsl #Nmov xc, #(2^N - 1) ; mneg xd, xa, xc->sub xd, xa, xa, lsl #NThe elegant case:SUB Xd, Xn, Xn, LSL #Ncomputesx*(1 - 2^N) = -x*(2^N - 1), exactly what MNEG needs -- swapping the sign that prevented MUL from folding2^N - 1in one instruction lets MNEG fold it cleanly.2^N + 1is not folded for MNEG: the rewrite is two instructions (ADD-shiftedthenNEG), at parity withMOV+MNEG.
- The non-ZR-accumulator complement of the MUL/MNEG strength
reductions:
Ra = 31is theMUL/MNEGalias and stays with those checks; an explicit accumulator rides the fold instead.mov x8, #8 ; madd xd, xn, x8, xa->add xd, xa, xn, lsl #3mov x8, #8 ; msub xd, xn, x8, xa->sub xd, xa, xn, lsl #3A multiplier of 1 (N = 0) folds to the plainADD/SUB. The multiply commutes, so the chain may sit in either multiply operand; the other survives as the shifted register.
- Same win as the MUL check, plus the accumulate: the whole MAC
leaves the multiplier pipe (2-3 cycle latency, limited throughput)
for a single-cycle shifted
ADD/SUB, and the materialising MOV dies. (On cores where a shifted operand beyondLSL #4costs a second cycle the fold is still never slower than the MAC.) - Reuses the MOVZ/MOVK chain state (and shares the MUL check's
dead-constant caveat). Exclusions:
Rd = 31(result discarded); an accumulator that is the constant register (the rewrite still reads it); a surviving multiply operand that is the constant (a constant-squared shape) or ZR (a zero product -- the pair is a register copy of the accumulator). The chain's width must match the MAC's. Only a power-of-two multiplier folds:2^N +/- 1shapes, which theRa = 31checks handle via the doubled-operand trick, have no single-instruction form once a distinct accumulator occupies the addend slot. - A MAC whose destination IS the constant register overwrites it at the consumer and reports immediately; otherwise emission defers through the forward register-liveness scan until the constant register is provably dead.
mov xc, #(1<<N) ; udiv xd, xn, xc->lsr xd, xn, #N. Same MOV-chain plumbing as the MUL/MNEG checks, including the dead-constant caveat.- Why it helps: integer division is one of the slowest A64 operations
-- data-dependent and poorly pipelined (often ~10-20+ cycles, low
throughput) -- whereas
LSRis a single-cycle ALU op on any pipe. This is the largest per-hit win among the strength reductions. - UDIV is not commutative, so only the divisor (Rm) coming from the MOV chain enables the fold; an Rn-from-MOV match would be a reciprocal-multiply problem, not a shift. Non-pow2 divisors have no single-instruction shift rewrite and are excluded.
- SDIV is intentionally not folded: SDIV by
2^Nis not equivalent toASR by Non negative dividends (SDIV rounds toward zero; ASR rounds toward -inf), so the rewrite would be incorrect. C == 0andC == 1are excluded as degenerate/identity. Rd == ZR (result discarded) and Rn == ZR (dividend always zero) are excluded as different idioms.
- The three-instruction remainder idiom spelled through the divide:
mov x8, #16 ; udiv x9, x1, x8 ; msub x9, x9, x8, x1->and x9, x1, #0xfdividend - (dividend / 2^N) * 2^Nisdividend mod 2^N, andUDIV's truncation is the flooring the identity needs for unsigned values, so a singleANDwith2^N - 1(always a valid bitmask immediate -- a run ofNlow ones) replaces all three instructions and retires one of the slowest A64 operations. The signed (SDIV) idiom does NOT fold: for negative dividends the flooringANDdisagrees withSDIV's truncation toward zero.
- The
MSUB's multiply commutes (quotient and constant in either operand); its accumulator must be the ORIGINAL dividend, which the adjacent pair provably left unmodified. At theUDIV, the quotient must be a fresh register -- overwriting the dividend clobbers what theMSUBre-reads, overwriting the constant clobbers the divisor -- and ZR operands andN = 0(dividing by 1, the identity) are excluded. All widths must match. - Deadness: the rewrite deletes the MOV, the
UDIVand theMSUB, leaving TWO temporaries -- the quotient and the constant. TheMSUB's own destination kills one structurally (compilers reuse the quotient register); the forward register-liveness scan gates the other. A fresh destination would need two proofs at once and is conservatively skipped. - Composes cleanly with the
UDIVstrength reduction: on this shape its dead-constant scan sees theMSUBre-read the constant and discards, so the two checks never double-report -- the pair-onlyLSRfinding appears exactly when the quotient is the real product, and this finding when the remainder is.
mov xc, #C ; add xd, xn, xcinstead ofadd xd, xn, #CwhenCfits the ADD/SUB immediate encoding (12-bit unsigned with optionalLSL #12:Cin[1, 0xFFF]orCa multiple of0x1000withC/0x1000in[1, 0xFFF]). Same forSUB,ADDS,SUBS, and theCMP/CMNaliases (S-variant withRd == ZR).- A NEGATIVE constant whose magnitude encodes folds sign-crossed
into the opposite consumer --
mov x8, #-5 ; add xd, xn, x8->sub xd, xn, #5, and symmetricallysub->add,adds<->subs,cmp<->cmn-- reported as "MOV + ADD/SUB foldable to sign-crossed immediate form". The crossing is exact for every flag, not just the result:SUBS Rn, RmwithRm = -CcomputesRn + NOT(-C) + 1 = Rn + C, the identical 65-bit sum asADDS Rn, #C, so N, Z, C and V agree bit-for-bit (and symmetrically forADDSof a negative). MOVN chains reach these values naturally. - Reuses the MOVZ/MOVK chain state (and shares the MUL check's
dead-constant caveat). ADD is commutative -- either operand may be
the MOV destination. SUB is not: only
Rm == mov_rdfolds, sinceRn == mov_rdwould need a reverse-subtract that AArch64 lacks. Width of the MOV chain must match the consumer's. C == 0is excluded (the no-op / MOV-to-Rn case is covered bycheck_add_sub_zero);ZRas the non-MOV operand is excluded (degenerate MOV/NEG).
-
An in-place ADD/SUB immediate on a register the previous instruction copied can read the copy's source instead, and the copy deletes:
mov x19, x29 -> sub x19, x29, #0x48 sub x19, x19, #0x48 -
The deadness argument is structural, not scanned. The consumer overwrites the destination, so the copied value's only reader is the consumer itself: adjacency rules out an intervening read, the central side-entry gate rules out a branch onto the consumer, and MOV and non-S ADD/SUB leave NZCV alone. Unlike the immediate-form fold above, whose constant register must be proven dead by the forward scan, nothing defers.
-
Two producer spellings, per the recall rule: the canonical GPR copy
ORR Rd, ZR, Rm, and the SP-read aliasADD Rd, SP, #0(mov Rd, sp), whose source drops straight into ADD/SUB's own Rn = 31-is-SP encoding. SP-writing movs do not open: deleting one changes which values SP transiently holds, and an asynchronous observer (signal, profiler) can see the transient. Copies from ZR are constant materializations (the MOV #0 and cheap-constant rows' territory). -
Width follows the copy-chain rule: an X consumer of a W copy would observe the zeroed upper half, which the original source does not hold; a W consumer of an X copy reads the low half of the same value and folds. The consumer keeps its immediate unchanged -- shifted or not -- so encodability is inherited rather than tested.
#0consumers stay in the ADD/SUB #0 row, and the S-variants are excluded with the shared non-flag-setting decoder. -
The corpus is JIT code, not the Mach-O sweep: the shape came out of adjacent-pair mining of SpiderMonkey's JetStream 3 dump (54.7M JIT instructions), and the realized count there is 126,224 -- 120,822 in the Baseline tier, nearly all one emitter, the frame-pointer helper
mov x19, x29 ; sub x19, x19, #0x48that runs before every VM call, plus 5,342 in Ion (the pseudo-SP re-derivationmov x20, sp ; add x20, x20, #immand relatives). The AOT counterpoint is exact:/bin/bash,/usr/bin/sshand/usr/lib/dyldcarry 0 instances between them -- LLVM's copy propagation never leaves the shape behind, so this is a JIT-emitter check.
-
A MOV to SP whose value the very next instruction overwrites without reading it is dead at the data-flow level:
mov sp, x20 -> delete sub sp, x20, #0x10 (reads x20, not sp) -
The finding deletes the first instruction of the pair, so the side-entry gate does not apply. Every other two-instruction fold replaces the window and must suppress when the second slot is a branch target; here a side entry skips the MOV on that path anyway, so the finding is a single-instruction window, immune by construction -- the fixture pins a branch onto the overwriter to hold the distinction, and the realized corpus count comes out above the pair-mined estimate for exactly this reason (the miner skipped branch-target pairs; the check correctly keeps them).
-
SP is why the rewrite is advisory rather than mechanical. For an ordinary register the deletion would be unconditional: a signal handler can observe any GPR through ucontext, but nothing may depend on the register state of asynchronously interrupted code -- the model under which every compiler deletes dead code. SP is load-bearing rather than merely observable: the kernel writes the signal frame below it, so the one-instruction transient the deletion removes participates in delivery itself. Whether the older SP value is still at-or-below live data is the runtime's stack-discipline invariant, which no two-instruction window proves, and the finding text carries that caveat. This is the same asymmetry that keeps SP writes out of the ZR-operand and copy-fold checks.
-
Scope matches the measured population. Producers are the MOV (to SP) alias
ADD/SUB SP, Rn, #0; killers are ADD/SUB immediate writing SP without reading it (Rd = 31,Rn != 31, any immediate). A killer with#0is itself the next producer, so a run of duplicate syncs reports one finding per dead link. Recorded rather than done: ADD/SUB (extended register) SP destinations, non-MOV SP-writing producers (a pre-syncsub sp, x20, #8overwritten by a sync), and a gap-tolerant window across instructions that touch neither SP nor control flow. -
The population is JIT sync traffic: SpiderMonkey's pseudo-SP discipline emits
sp = x20defensively from independent macro-assembler helpers, and adjacent helpers collide. JetStream 3 corpus: 78,156 findings (47,355 Baseline, 30,778 Ion, 23 trampoline) against a pair-mined estimate of 75,392; the Octane-era estimate was ~3.3K, the suite mix explaining the gap. AOT is not quite zero this time:/bin/bashand/usr/bin/sshcarry none, but/usr/lib/dyldhas exactly one --mov sp, x8 ; sub sp, x29, #0x50inRemoteNotificationResponder::notifyMonitorOfImageListChanges, an alloca-restore immediately re-derived from the frame pointer.
-
A register-register ADD/SUB re-executed while its destination and both sources are unwritten recomputes a value the register already holds, and deletes -- nothing is rewritten, so there are no encodability questions:
add x16, x0, x2 ; input + pos ldrb w1, [x16, #1] ; checks that read but write none of cmp w1, #0x78 ; the three registers b.ne fail add x16, x0, x2 -> delete; x16 still holds x0 + x2 -
This is a value-integrity scan, not an adjacent pair. The tracked registers must still hold the earlier result when the recompute appears, so ANY write to Rd, Rn or Rm invalidates -- read-modify-writes included, the ranking
insn_writes_regexists to provide -- as does a call, an unconditional transfer, an exception instruction, and any branch target: a side entry reaches the recompute without the first ADD having executed. That last rule is the mirror image of the SP check above, which deletes the first instruction of its pair and is side-entry-immune; this one deletes the second, so side entries are the binding constraint. Conditional branches do not invalidate -- the fall-through path keeps its registers. -
Matching is by exact instruction word, which settles width, operand order and shift agreement for free. Producers require all three registers real, with the destination not among the inputs (a self-input ADD changes its own operand each execution and is never redundant). The S-variants are excluded: re-executing ADDS recomputes the same flags only if nothing wrote NZCV in between, a condition this check does not track. One producer slot, newest wins. Recorded rather than done: shifted and extended-register forms, a multi-slot cache, the flags-dead ADDS variant, and the other pure ALU ops -- the general direction is local value numbering, and each op class carries its own purity argument.
-
The population is one emitter: irregexp's lookahead character loads re-form
input + posfor every nonzero character offset, while the checks in between read but never write the triple (the zero-offset loads use the register-offset form and skip the scratch entirely). SpiderMonkey's JetStream 3 RegExp tier: 8,836 findings -- exactly the count an independent measurement script produced from the text dumps before the check existed, single-slot cache and full-map bookkeeping agreeing because each block re-forms a single triple. Ion and Baseline: 0 (Ion CSEs addresses at the MIR level and its guard targets kill the windows; Baseline's frame traffic is fp-relative)./bin/bash,/usr/bin/ssh,/usr/lib/dyld: 0. The same emitter ships in V8's arm64 port, so the check transfers to that corpus as-is.
mov xc, #C ; and xd, xn, xcinstead ofand xd, xn, #CwhenCis a valid AArch64 bitmask immediate (a rotated run of consecutive 1s at one of esize=2/4/8/16/32/64). Same forORR,EOR, andANDS-- and theTSTalias (ANDSwithRd == ZR).- The N = 1 family has no immediate form itself, but computes
Rn op NOT(Rm)-- so when the inverted operand is the constant, the NOT folds into it and the direct-form immediate applies:mov xc, #C ; bic xd, xn, xc->and xd, xn, #~Cwhen~C(at the operation width) is a bitmask immediate. Same forORN -> ORR,EON -> EOR,BICS -> ANDS(NZCV matches exactly: A64 logical S-ops set N/Z from the result and C = V = 0 in both register and immediate forms), and theTST #~Calias forBICSwithRd == ZR. Reported under the separate name "MOV + BIC/ORN/EON foldable to bitmask immediate". - Reuses
is_bitmask_immediateas the encodability predicate; 0 and the all-ones-at-width are not bitmask immediates, so those trivial constants naturally skip on either side of the complement. - AND/ORR/EOR/ANDS are commutative; either Rn or Rm may be the
MOV destination. BIC/ORN/EON/BICS invert Rm only, so the
constant must be Rm:
bic xd, xc, xmcomputesC & ~xm, which has no immediate form. Shares the MUL check's dead-constant caveat. - The surviving operand must not be the MOV destination itself
(
mov xc, #C ; and xd, xc, xc): the suggested immediate form would still readxc, so the MOV could never be deleted and the rewrite saves nothing. Those shapes are left to the self-op identity check.
mov x8, #5 ; ccmp x0, x8, #0, neinstead ofccmp x0, #5, #0, ne. The conditional compares have a register and an immediate form; a materialized constant in[0, 31]-- the immediate form's unsignedimm5-- feeding the register form'sRmis the immediate form spelled in two instructions. Same forCCMN; the#nzcvliteral and the condition carry over verbatim.- Reuses the MOVZ/MOVK chain state, but unlike the strength
reductions the consumer rewrite alone saves nothing (the register
and immediate conditional compares cost the same), so the finding
is emitted only after the same forward register-liveness scan as
the
MOV #0fold proves the constant register dead. A conditional compare writes only NZCV -- it can never kill the constant itself -- so the finding always defers. - Not commutative: only
Rm(the subtrahend forCCMP, the addend forCCMN) has an immediate slot, so a chain feedingRnis not folded -- the reversed compare has no encoding. The survivingRnmust not be the constant register (the rewrite would still read it) nor ZR (a degenerate compare-against-zero idiom). Width (W vs X) of the chain must match the compare's. - A non-negative
Coutside[0, 31]has no immediate form and is skipped. A NEGATIVE constant whose magnitude fits imm5 folds sign-crossed into the opposite compare --mov x8, #-7 ; ccmp Rn, x8, #nzcv, cond->ccmn Rn, #7, #nzcv, cond, and symmetricallyccmn->ccmp-- reported as "MOV + CCMP/CCMN foldable to sign-crossed immediate form". The NZCV agree exactly: when the condition holds, the compare of-Cand the opposite compare of#Cperform the identical 65-bit sum, and when it fails both set the carried-over#nzcvliteral.
mov w8, #1 ; csel wd, w8, wn, ccinstead ofcsinc wd, wn, wzr, !cc.CSINC's else-branch isRm + 1, so a materialised 1 in eitherCSELoperand is reproduced by incrementing ZR:mov w8, #1 ; csel wd, w8, wn, cc->csinc wd, wn, wzr, !cc(constant in the then slot: the condition inverts, since the 1 moves to the incrementing else branch).mov w8, #1 ; csel wd, wn, w8, cc->csinc wd, wn, wzr, cc(constant in the else slot: the condition carries over). When the surviving operand is ZR the select is a boolean materialisation and the rewrite is theCSETalias (cset wd, cc/cset wd, !cc-- the condition under which the result is 1). That shape -- a bool built through a constant register instead of from the flags -- is the flagship catch.
- A materialised ALL-ONES folds the same way through
CSINV, whose else-branch is~Rm(mov w8, #-1 ; csel wd, wn, w8, cc->csinv wd, wn, wzr, cc; ZR surviving operand -> theCSETMalias), reported as "MOV #-1 + CSEL foldable to CSINV/CSETM". All-ones is width-dependent (0xFFFFFFFFfor a W chain), unlike the zero fold's width-agnostic value, so the width gate does real work here: a W chain holding0xFFFFFFFFdoes not fold into an X select. - Only
CSELproper (op2 = 00) matches;CSINC/CSINV/CSNEGhave different else-branches. The rewrite reads the same NZCV theCSELdid (the MOV writes no flags), so no flag-liveness scan is needed. Reuses the MOVZ/MOVK chain state; the chain's value must be exactly 1 (or all-ones) and its width (W vs X) must match the select's, consistent with the other integer MOV-chain folds. AL/NVconditions are excluded (ConditionHoldstreats both as always-true, so the select is a plainMOVand the then-slot inversion,AL<->NV, would still be always-taken), as areRd = 31(a discarded select) and aCSELreading the constant in both slots (the same-operand identity, which the CSEL identity check owns).- The rewrite deletes the MOV. A select whose destination IS the constant register overwrites it at the consumer itself and reports immediately; otherwise the finding defers through the forward register-liveness scan until the constant register is provably dead (shares the MUL check's dead-constant caveat).
- What it saves: the materialising MOV (one instruction and the
register that held the 1); the select itself neither gains nor
loses --
CSELandCSINCcost the same on current cores.
mov w8, #5 ; lsl wd, wn, w8instead oflsl wd, wn, #5. The variable shiftsLSLV/LSRV/ASRV/RORV-- which assemblers spelllsl/lsr/asr/rorwith a register amount -- each have an immediate-form twin (theUBFM/SBFMaliases;EXTRforROR), so a materialised shift amount folds into it and the MOV dies.- The register form shifts by
UInt(Rm) MOD datasize, so the folded immediate is the chain's value reduced modulo 32/64: a chain of#67feeding a 64-bit shift folds to#3, and a MOVN chain's all-ones value folds to#31/#63. A residue of 0 shifts by nothing -- a register copy, not a shift -- and is left alone as degenerate. - Reuses the MOVZ/MOVK chain state (and shares the MUL check's
dead-constant caveat). The chain must feed the amount operand
Rm; the shifted operandRnmust not be the constant register (the rewrite would still read it, so the MOV could never be deleted) nor ZR (shifting zero is a constant, a different idiom).Rd = 31(a discarded shift) is excluded, and the chain's width must match the shift's. - The rewrite deletes the MOV; the immediate and register shift forms themselves cost the same on current cores. A shift whose destination IS the constant register overwrites it at the consumer and reports immediately; otherwise emission defers through the forward register-liveness scan until the constant register is provably dead.
mov w8, #0x3f800000 ; fmov s0, w8instead offmov s0, #1.0. Materialising a floating-point constant through a general register costs the extra MOV (two or more instructions for a wide double pattern) plus a cross-register-file transfer, which runs several cycles of latency on most cores;FMOV (scalar, immediate)produces the value directly on the FP side. Conversions of pinned small integers are the same pattern one step removed:mov w8, #5 ; scvtf d0, w8->fmov d0, #5.0, andUCVTFlikewise.FMOV's imm8 expands (VFPExpandImm) to+/-(16..31)/16 x 2^nfornin[-3, 4]-- 256 values from a sign, a 4-bit fraction and a 3-bit exponent. Every integer of magnitude 1..31 is included. Zero is NOT expressible, so zero materialisations never match (idiomatic FP zeroing isMOVI, a separate concern). NaNs, infinities and denormals fail the exponent shape; extra fraction bits fail outright. The test is an exact bit-pattern comparison -- no floating-point arithmetic is involved in the FMOV direction.- Consumers:
FMOV (general)in the GPR->FPR direction only (fmov Sd, Wn/fmov Dd, Xn) and the scalar integer conversionsSCVTF/UCVTFfrom either GPR width. A 64-bit source also accepts a W-form chain -- the W write zeroedX[63:32], pinning the full 64-bit read -- while a W source requires a W chain. Half-precision destinations (FEAT_FP16) are not matched. - Folding a conversion is sound only when the conversion is exact:
SCVTF/UCVTFround per the dynamic FPCR mode, and only exactness makes the result mode-independent (an exact conversion also raises no FP exceptions, preserving FPSR). Encodability already implies exactness -- an imm8 value's magnitude is at most 31.5, bounding the integer's by 31 -- but the check verifies the round-trip explicitly rather than lean on that argument. The write semantics agree too: the transfer, the conversion andFMOV #immall zero the vector register above the written scalar lane. - The consumer writes only an FP register and can never kill the constant GPR itself, so -- like the CCMP fold -- the finding always defers through the forward register-liveness scan until the constant register is provably dead.
fmov s0, wzrinstead ofmovi d0, #0. FMOV's immediate cannot encode zero, so zeroing an FP or vector register is MOVI's job -- and routing the zero through a general register instead costs a cross-register-file transfer (several cycles of latency on most cores) that MOVI performs on the FP side, where zero idioms are often free at rename. Three consumer families:FMOV (general), GPR->FPR:fmov s0, wzr/fmov d0, xzr.SCVTF/UCVTFof zero: integer zero converts to+0.0-- the all-zeros pattern -- in every FPCR rounding mode (FixedToFPreturnsFPZerofor a zero input outright, raising no exceptions), soscvtf d0, wzris exactlymovi d0, #0.DUP (general)broadcasting ZR:dup v0.4s, wzr->movi v0.4s, #0, keeping the arrangement. All three zero the vector register above what they write, exactly as MOVI does, so the final 128-bit state is bit-identical. Scalar consumers render the canonical 64-bit zeroingmovi dN, #0(the whole register is zero regardless of the S/D destination width).
- The ZR-source forms are one-for-one rewrites with no deleted write
and report immediately. The same consumers fed by a MOV-chain
register pinned to zero (
mov w8, #0 ; fmov s0, w8) additionally delete the MOV, and defer through the forward register-liveness scan until the constant register provably dies -- width admission as in the FMOV-immediate fold (a 64-bit source also accepts a W-form chain). These consumers are deliberately not in theMOV #0fold's set: substituting WZR would keep the cross-file transfer that MOVI eliminates. - Nonzero DUP broadcasts that MOVI's expanded immediate could encode
(
mov w8, #5 ; dup v0.4s, w8->movi v0.4s, #5) are a natural extension, deferred for now. Half-precision (FEAT_FP16) transfers are not matched, consistent with the FMOV-immediate fold.
sxtw x8, w0 ; scvtf d0, x8instead ofscvtf d0, w0. The conversions have both W- and X-source forms, and the W-source form performs the widening itself -- extending first through a scratch register spends an instruction and a register on work the conversion already does. Recognised extends:SXTW Xd, Wn(the SBFM alias) and the zero-extendingMOV Wd, Wm(any W write zeroes the upper half; this ORR alias is the canonical uint32 -> 64 widening).- Signedness maps by value, not by spelling:
sxtw+scvtf Xn->scvtf Wn(the sign-extended value is the signed 32-bit value).mov w, w+ucvtf Xn->ucvtf Wn.mov w, w+scvtf Xn->ucvtf Wn-- the zero-extended 64-bit value IS the unsigned 32-bit value, so even the signed wide conversion becomes the unsigned narrow one.sxtw+ucvtf Xndoes NOT fold: the unsigned reading ofsext(negative)is a huge value, not the 32-bit one. Both sides convert the same mathematical value, so the identity is exact in every FPCR rounding mode and raises identical exceptions -- no exactness argument is needed. ZR operands are excluded as degenerate (a constant-zero extend).
- The rewrite reads the extend's own source, which the adjacent pair
leaves unchanged -- even in-place:
sxtw x0, w0andmov w0, w0keep the low 32 bits of their destination equal to the source. Deleting the extend requires its destination dead; the conversion writes only an FP register and never kills it, so the finding always defers through the forward register-liveness scan until the extended register provably dies.
ldr w8, [x1] ; scvtf s0, w8instead ofldr s0, [x1] ; scvtf s0, s0. An int-to-FP conversion routed through a general register pays a cross-register-file transfer the FP-side spelling avoids: the GPR-source conversions crack into a several-cycle GPR -> FP move plus the convert (the move rides the load pipes on Apple's cores and the M0 pipe on Neoverse), while loading straight into the FP register and converting in-SIMD is two independent cheap ops. Apple's CPU optimization guide recommends exactly this rewrite (measuring 11 -> 7 cycles on M-series). The instruction count is unchanged; the win is the transfer, plus the freed GPR.- Exactness: the rewrite performs the identical memory access (same address, same size), converts the same 32/64-bit integer value under the same FPCR rounding with the same FPSR exceptions, and both spellings zero the vector register above the written lane.
- Widths must match on both sides -- only int32 -> single and
int64 -> double have in-SIMD twins; there is no cross-width scalar
conversion -- so mixed pairs (
scvtf d0, w8), the byte/halfword loads and the sign-extending loads never fold. Half precision (FEAT_FP16) is not matched, consistent with the FMOV folds.Rt = 31(a discarded load) does not open. - The rewrite stops writing the GPR entirely, so the loaded register must be dead afterward; the conversion writes only an FP register and can never kill it, so the finding always defers through the forward register-liveness scan. v1 matches the unsigned-offset addressing form only, like the other load-rewriting folds.
and x0, x1, #0xffffffff->mov w0, w1, and the bitfield spellingubfx x0, x1, #0, #32->mov w0, w1. Both computeZeroExtend(Xn[31:0], 64), which is exactly what a W-form register move already does: every W write zeroes the upper half of its X register. One instruction either way, nothing deleted, no flags or memory touched -- so like the lane-0 UMOV fold this needs no liveness argument.- What it saves is not size.
MOV Wd, Wnis anORR Wd, WZR, Wm, which Neoverse cores resolve at register rename with no execution slot; the mask and the bitfield extract each occupy an ALU pipe. Neutral on cores that do not rename it away, so this is another "cheaper, not shorter" finding. - Only the X form matters. The 32-bit
and w0, w1, #0xffffffffis not even encodable -- the logical-immediate encoding excludes all-ones -- and a W-form op has nothing above bit 31 left to clear. - The width must be exactly 32 and the lsb exactly 0. A narrower mask
is a real extraction, and the full-width spellings clear nothing:
ubfx xd, xn, #0, #64isUBFM Xd, Xn, #0, #63, which the assembler renders aslsr xd, xn, #0and which belongs with the degenerate register-copy spellings, not here. Rd == Rnis deliberately not reported, though the rewrite is equally sound. It would read-> mov w0, w0, a shape whose obvious follow-on is to delete it -- and deleting it is a miscompile, since a W-form move of a register to itself still zeroes bits 63:32. For a tool whose worst failure is a false positive, advice one step away from a wrong edit is close enough to one. The in-place cases that genuinely are deletable, where an earlier instruction already cleared those bits, belong toredundant zero-extension, which says "delete" rather than "respell". That exclusion costs 13 of the 56 corpus candidates and removes the whole overlap.- Three operand traps, none of which the corpus happens to contain but
all of which the encodings allow:
- AND-immediate's
Rd = 31is SP, not ZR, while the rewrite'sORR Wd, WZR, WmreadsRd = 31as WZR. The two encodings disagree about register 31, so an SP destination can never fold. The assembler enforces this from the other side:and xzr, x1, #immis rejected outright as an invalid operand. UBFM'sRd = 31really is ZR, so that result is discarded and the instruction is dead outright -- a deletion, not a respelling.- A ZR source turns either op into a zero materialization rather than a truncation; that belongs with the ZR-operand canonicalizations.
- AND-immediate's
- Corpus: 40 findings of 56 candidates across 28.4M instructions (30 librustc_driver, 6 libcrypto, 4 go). The 16 unreported are the 13 in-place cases above and 3 ZR-source ones; dyld's 4 candidates are all in-place, which is why it contributes nothing.
- Overlap with the two-instruction folds that consume the same
instruction -- the redundant zero-extension check, and the
zero-extend + LSL fold that turns
uxtw x0, w1 ; lsl x0, x0, #2into aUBFIZ-- is real in principle: the pair fold deletes the instruction outright, which beats respelling it. In practice not one of the 40 findings shares an offset with another check's, so no precedence machinery is warranted. The fixtures pin both directions.
umov w0, v1.s[0]->fmov w0, s1, andumov x0, v1.d[0]->fmov x0, d1.SnandDnare not separate registers: they are the low 32 and 64 bits ofVn. For lane 0 the two instructions therefore move identical bits into an identical destination, zero-extending the same way, and the rewrite needs no conditions beyond the operand shape -- nothing is deleted, no flags or memory are involved, and the source is read exactly once either way. This is the one check in the file with no liveness argument at all.- What it saves is not size. Both encodings are one instruction.
FMOVuses a cheaper port thanUMOVon Apple cores (Apple Silicon CPU Optimization Guide 4.5.2), so this is an execution-resource finding, in the same "cheaper, not shorter" class as the pair-offset residue. Worth knowing before acting on a large count of them. - Only lane 0.
FMOV(general) can address just the low element, which is the entire restriction. The lane index lives inimm5above the size bit --.s[i]encodes asi:100,.d[i]asi:1000-- so "lane 0" is exactly "no bits set above the size bit", a single mask test.umov w0, v1.s[2]has noFMOVspelling whatsoever. - The B forms never fold at any lane: there is no
FMOV Wd, Bn. - The halfword arm is feature-gated (
-m fp16), reported under its own nameUMOV of lane 0 foldable to FMOV (FP16):umov w0, v1.h[0]->fmov w0, h1. Both zero-extendVn[15:0]intoWd, butFMOV Wd, Hnis FEAT_FP16, so the fold is silent unless the target is known to have it. It is where the volume is: 1,780 sites across the corpus against 484 for the always-live arms, 1,775 of them in librustc_driver. Rd = 31is excluded. That is ZR, so the transfer is discarded and the instruction is dead outright; respelling a dead instruction as a different dead instruction is not useful advice, and the deletion belongs to a different check.- Match on the encoding, not the mnemonic.
MOV Wd, Vn.S[index]andMOV Xd, Vn.D[index]are the preferred aliases for exactly the two always-live forms, so both the assembler and every disassembler print them asmov; only the.band.hforms show up asumov. Grepping disassembly for "umov" finds none of the unconditionally foldable sites. - Corpus: 484 always-live sites (464 libcrypto, 20 go) plus 1,780
FP16-gated ones. The operand condition is what makes this a check
rather than a blanket rewrite -- there are 7,150
UMOVs in the corpus and only 484 are lane-0 S or D, 6.8%. librustc_driver's 6,465 are almost entirely halfword extraction and.d[1], the high lane; libcrypto's are the reverse.
- Armv8.9/9.4 Common Short Sequence Compression gives single
instructions for idioms the base ISA spells in two. These checks
suggest instructions the target must support, so they stay silent
unless
-m csscis passed:cmp x1, x2 ; csel x0, x1, x2, gt->smax x0, x1, x2("CMP + CSEL foldable to MAX/MIN (CSSC)"). GE/GT pick the larger -- the equal case selects equal values, so both conditions work -- LT/LE the smaller, HS/HI and LO/LS the unsigned twins, and swapped CSEL operands flip the direction. Only the plain shifted-register LSL #0 compare with distinct operands opens.cmp x1, #0 ; cneg x0, x1, mi->abs x0, x1("CMP #0 + CNEG foldable to ABS (CSSC)"). The raw match is a CSNEG with both sources the compared register and a condition in {PL, GE, GT}: the condition holds exactly forr >= 0(GE because V = 0 after a zero compare), orr > 0where the r = 0 else-branch still yields -0 = 0. Source-levelcnegconditions MI/LT/LE, inverted by the alias.rbit x0, x1 ; clz x0, x0->ctz x0, x1("RBIT + CLZ foldable to CTZ (CSSC)"): counting leading zeros of a bit reversal counts trailing zeros of the original.
- Soundness: the MAX/MIN and ABS rewrites DELETE the compare and set
no flags at all, so they defer through an NZCV-death scan on a
dedicated shared slot -- any later flag reader (even
b.eq) discards, and only a full NZCV overwrite or safe terminator commits. CTZ involves no flags; the reversed value must be dead, with the usual structural kill (the CLZ overwrites it) or forward register-liveness deferral, and the RBIT's source still holds its original value at the consumer once the RBIT is deleted (even in-place). - The NEON popcount round trip folds to the GPR
CNT:fmov d0, x1 ; cnt v0.8b, v0.8b ; addv b0, v0.8b ; fmov w0, s0->cnt x0, x1("NEON popcount foldable to CNT (CSSC)"). All vector stages must run in place on one register with strict adjacency; the 8B and 16BCNT/ADDVforms both match (the openingFMOVzeroed the upper half), and the closing transfer may read the S or D view into any GPR -- the count fits every view and both spellings zero above it. The rewrite never writes the vector register and nothing in the chain can kill it structurally, so emission always defers through the FP/vector-register liveness scan. This retires four instructions, two of them cross-register-file transfers. One honest limit: a chain that runs straight intoret-- the canonical standalone__builtin_popcountllemission -- stays unreported, because v0 is the FP return-value register and the scan stops conservatively at every control transfer; the realistic catch is the inlined chain whose vector register is reused shortly after.
-
Armv9.6's FEAT_CMPBR -- optional from Armv9.5, mandatory from 9.6 -- adds
CB<cc>, which does a comparison and a conditional branch in one instruction and writes no flags. The compare in front of a conditional branch then disappears:cmp x1, x2 ; b.gt L->cbgt x1, x2, Lcmp w0, #10 ; b.ls L->cbls w0, #0xa, L("CMP + B.cond foldable to compare-and-branch (CMPBR)"). The check is silent without-m cmpbr: a target that does not implement FEAT_CMPBR finds the encoding UNDEFINED.
-
The condition maps across unchanged.
CB<cc>spells its condition into the mnemonic, and the ten it can express --EQ/NE, the signedGT/GE/LT/LE, and the unsignedHI/HS/LO/LS-- are exactly the ten aCMP's flags define as a comparison of the two operands (HSisC,GEisN == V, and so on), so eachb.<cc>becomescb<cc>of the same operands in the same order.MI/PLread a sign andVS/VCan overflow that no comparison of values reproduces;AL/NVare not conditions. None opens. -
Only the two compare spellings
CBmirrors open: shifted-register withLSL #0(cmp Rn, Rm) and immediate withsh = 0(cmp Rn, #imm12). A shifted or extended-register compare has noCBoperand for the shift;CMNcompares against a negated operand andTSTagainst a mask, neither of whichCBexpresses.cmp Rn, XZRis the register spelling ofcmp Rn, #0and reports as the immediate form, so no suggestion has to name a zero register.Rn = 31never opens: it is SP in the immediate form, whichCBcannot encode (itsRt = 31is the zero register), and a degenerateXZRcompare in the register one. -
Two encoding windows gate the rewrite:
- The comparand.
CB<cc>(immediate) carries an unsigned 6-bit field.EQ/NE/GT/LT/HI/LOencode it directly and reach 0..63. The other four are assembler pseudo-instructions that shift the stored value by one:CBGE/CBHSassemble asCBGT/CBHIofimm-1and so reach 1..64,CBLE/CBLSasCBLT/CBLOofimm+1and so stop at 62. - The reach.
CB'simm9spans -1024..1020 bytes whereB.cond'simm19spanned +-1MB. TheCBsits at the compare's address, 4 bytes ahead of the branch, so the displacement it must encode isimm19 + 1words -- the same accounting theTBZfold makes, and conservative by one word for a forward target, which deleting the compare pulls 4 bytes closer.
- The comparand.
-
A zero comparand is left to the baseline folds rather than reported twice.
cmp Rn, #0+b.eq/b.neis alreadyCBZ/CBNZ(andb.hi/b.lsreduce to those once the compare pinsC = 1), andb.lt/b.geis alreadyTBZ/TBNZof the sign bit -- none of which needs an extension.HSandLOare not folds at all after a zero compare (C = 1makesHSalways taken andLOnever), andCBHS #0is outsideCBHS's window anyway.GTandLEare what remains, and they are genuinely new: no baseline instruction tests> 0or<= 0in one word. -
Soundness: both edges of the branch are proven, not one. The rewrite deletes the compare and
CBwrites no flags at all, so the old NZCV must go unread whichever way the branch goes. The fall-through half is the usual deferred scan. The taken half is the part that makes this check different from every other branch fold here: those assume NZCV is dead at the target -- fair for a zero-test producer, whose flags a compiler rarely reuses -- but this one's producer is a general two-register compare, which is exactly what a compiler does reuse across a branch. clang's three-way comparator is the shape, and/bin/lsis full of it:cmp x10, x11 b.le L ; L below re-reads N/Z/V from THIS compare mov w0, #1 ret L: b.ge ...
Folding there would leave
Lreading undefined flags. So emission additionally requires a forward scan starting at the branch target to reach a flag overwrite -- or a call or return, past which the PCS makes the flags caller-clobbered -- before any reader, under the same bounded window and the same conservative classification as the fall-through scan. Anything short of that proof refuses: no scanned buffer, a target outside it, a reader, a control transfer whose own destination would have to be chased in turn, or a window that expires. On macOS 26's/bin/lsit refuses 16 of 52 candidate pairs, leaving 36 findings in 3817 instructions: six are the comparator shape above, where the target genuinely re-reads the deleted flags, and the other ten are conservative -- aCBZ/CBNZor an unconditionalBat the target ends the scan before it can reach a kill. What survives is not a trickle: arm64e/usr/lib/dyldreports 2550 pairs in 161738 instructions, spread across all ten conditions (cbne1366,cbeq658, then the unsigned four, with the signedcbgt/cble/cblt/cbgethe tail at 64), and every one of them re-assembles as a realCB<cc>at its own displacement. -
Not implemented:
CBB<cc>andCBH<cc>, the byte and halfword compares. They pay off only by deleting an explicitUXTB/UXTH/SXTB/SXTHahead of the compare -- a 3-for-1 or 4-for-1 fold with its own liveness argument -- since on already narrow valuesCBitself is the same one instruction. See TODO.md.
-
FEAT_SHA3 -- optional from Armv8.2, never mandatory -- carries four instructions that are general bit-mixing rather than Keccak-specific. Two of them collapse an adjacent pair:
eor v0.16b, v1.16b, v2.16b ; eor v0.16b, v0.16b, v3.16b->eor3 v0.16b, v1.16b, v2.16b, v3.16b("EOR + EOR foldable to EOR3 (SHA3)")bic v0.16b, v2.16b, v3.16b ; eor v0.16b, v1.16b, v0.16b->bcax v0.16b, v1.16b, v2.16b, v3.16b("BIC + EOR foldable to BCAX (SHA3)")
-
Soundness is as simple as it gets in this tool.
EOR3isVn EOR Vm EOR VaandBCAXisVn EOR (Vm AND NOT Va), which is exactly what the pairs compute -- pure bitwise identities over the same 128 bits, with no lane width, rounding, exception, or flag behavior to preserve. Both were checked by execution as well as by the pseudocode: 200,000 random vector triples through each spelling, bit-identical. -
Only the 16B forms open and close. Neither fused instruction has an 8B form, and an 8B pair zeroes the destination's upper half where the fused one would write real data. The three-same logic ops share one encoding class and are separated only by U (bit 29) and size (bits 23..22), so
AND/ORR/ORN/BSL/BIT/BIFare excluded by pinning both. -
The consumer must read the temp in exactly one source slot. With both sources equal to it the
EORcancels to zero, which no three-operand form reproduces. The producer's own sources may be the temp: deleting the producer leaves them holding the value it read itself, so the in-placeeor Vt, Vt, Vb ; eor Vt, Vt, Vcspelling -- the one compilers actually emit -- folds like any other. -
The rewrite deletes the producer, so its destination must be dead afterward. A consumer writing that same register kills it structurally and emits on the spot; a fresh destination defers through the vector-register liveness scan (
armlint_advance_pending_fp). In practice the structural path is the whole population: of OpenSSL 3.6.3 libcrypto's 106 adjacent dependent pairs of this shape, 71 have the in-place destination and 35 do not -- and none of the 35 commit, because that scan treats a written vector operand as also read unless the writer is on the pure-overwrite whitelist (loads and scalar FP), so a following vector op merely fails to prove the temp dead. False negatives only. -
The side-entry gate is load-bearing here, not a formality. 19 of those 71 pairs are suppressed because the consumer is a direct branch target -- in libcrypto's AES loops, two
binstructions land on the secondeor:36f8: b 0x3704 36fc: aesd.16b v1, v17 3700: eor.16b v1, v1, v18 3704: eor.16b v1, v1, v31 ; <- branched to from 36c8 and 36f8
A path entering at
3704never ran the producer, so the fusedeor3would mix inv18where the original mixes in nothing. That leaves 52 reported findings, every one of which re-assembles as a realEOR3and, executed against its original pair over 20,000 random register states, computes the identical result. -
Yield is narrow and concentrated: 52 in libcrypto (560349 instructions), 10 in
go, 0 indyld, 0 in librustc_driver. This is a crypto-and-hashing fold, not a general one. -
Actionability caveat, the same one
-m pauthcarries: FEAT_SHA3 is never mandatory, so-m sha3is a real assertion about the target rather than an architecture-version floor. A library that dispatches on it at runtime keeps the two-instruction path on purpose -- and libcrypto is exactly that library, already shipping 65xarinstructions in a FEAT_SHA3 Keccak path alongside the portable code these findings come from. -
Not implemented:
XAR((Vn EOR Vm)rotated right per 64-bit lane) andRAX1(Vn EOR ROL(Vm, 1)). Both fold three or more instructions rather than two, since a NEON lane rotate is itself a shift pair; see TODO.md.
- A pac-ret epilogue restores the signed return address, authenticates
it, and returns. The authenticate-and-return steps take two
instructions in the portable spelling; Armv8.3's combined forms do
both in one -- same key (IA/IB), same modifier (SP), same register
(x30):
autiasp ; ret->retaaautibsp ; ret->retab("AUTIASP/AUTIBSP + RET foldable to RETAA/RETAB (PAuth)"). Both sides are fixed words, matched raw under strict adjacency.
- The split spelling exists for portability, which is why the check is
opt-in on a general target: AUTIASP/AUTIBSP live in the hint space
and execute as NOPs on pre-Armv8.3 cores, so one binary hardens
where the keys exist and still runs everywhere -- while RETAA/RETAB
are UNDEFINED there.
-m pauthasserts the target guarantees v8.3. An arm64e slice does guarantee it -- FEAT_PAuth is the ABI's whole premise -- so the driver arms-m pauthautomatically there (the same cpusubtype gate that auto-arms the PAC audit; see the PAC hygiene audit section and scan_macho). A plain arm64 slice keeps the flag opt-in, since it may target a pre-v8.3 core. Compilers already emit the combined forms at a guaranteed-v8.3 baseline (arm64e,-march=armv8.3-aand up); the check surfaces the residue, which is real: macOS 26's arm64e/usr/bin/sshcarries 42 adjacentautibsp ; retpairs alongside its 762retabs -- and now reports them with no flag. - Soundness fine print: the combined forms do not write the authenticated address back to x30, so after the return the register holds the still-signed value where the split form left the raw one. AAPCS64 makes x30 a plain temporary once the call returns -- no conforming caller reads it -- the register twin of the BL-clobbers-NZCV argument the flag-liveness scan makes. On a forged return address both spellings deny the hijack; only the diagnosis point differs: FEAT_FPAC faults the standalone AUT precisely, the combined form faults given FEAT_FPACCOMBINE, and cores with neither branch to a poisoned address in both spellings.
- The side-entry gate is load-bearing here, not a formality. A shared
epilogue whose RET is a direct-branch target is reached by paths
that never signed x30 (shrink-wrapped fast paths), and the folded
RETAA/RETAB would authenticate a raw pointer there -- a fault, not
a slowdown. In that same
/usr/bin/ssh, 18 of the 42 pairs are exactly this shape (each one confirmed branch-targeted at the RET) and are suppressed; the 24 clean pairs are reported. - Exclusions:
ret x17(the combined forms are x30-only); the zero-modifier AUTIAZ/AUTIBZ (no combined zero-modifier return exists); the general-encodingautia x30, spspelling (identical semantics, unseen in compiler output); tail calls (autibsp ; b targethas no combined form, andautiasp+br x30is left to a futurebr x30->retcanonicalization).
- The
-a <audit>class is different in kind from-m:-masserts what the target supports so rewrites may use it, while-aopts into informational findings that flag missing hardening rather than a missed fold.-a pacaudits a binary against the full pointer-authentication contract that arm64e code follows. Audit findings are review items, a benign residue is expected, and they ride the regular reporting machinery (so they are counted in the same opportunities summary; the "(PAC audit)" suffix marks their kind). - The audit arms automatically on arm64e Mach-O slices. An arm64e
slice is one whose cpusubtype is CPU_SUBTYPE_ARM64E -- the ABI in
which every function signs its return address and routes indirect
calls through the authenticated branches -- so the audit's central
assumption (the binary opted into pac-ret) is exactly true there,
and the driver enables it with no flag. The gate is deliberately
narrow: a plain arm64 slice never opted in, so arming it would flag
every function's spill (the gh figures below). The detection is one
cpusubtype test in scan_macho; an explicit
-a pacstill forces the audit on any slice, e.g. a plain arm64 binary hand-built with-mbranch-protection=pac-ret. - "LR spill without PACIASP/PACIBSP (PAC audit)": pac-ret exists because a return address spilled to the stack is the classic ROP target -- sign it before it leaves the register file and a stack overwrite faults at authentication instead of steering the return. The check flags any SP-based spill of x30 (STP pre-index or signed offset with either data register x30; STR unsigned offset or pre-index) with no PACIASP/PACIBSP in the same straight-line run: a 16-instruction window reset by any control transfer, because real prologues sign first and never branch between the signing and the save (interposed callee-saved pairs sit comfortably inside the window). Leaf functions never spill x30, so they need no signing and produce no findings. Out of scope: non-SP bases (a jmp_buf in setjmp is a real PAC surface but a different shape) and STP post-index (not a prologue store).
- "unauthenticated BR/BLR (PAC audit)": in fully signed code,
function-pointer transfers go through BRAA(Z)/BLRAA(Z), which
authenticate the target register before branching; each raw BR/BLR
is a JOP hazard. The dominant benign shape is compiler switch
dispatch, and a jump-table classifier keeps it off the worklist:
the clang idiom
adrp xB ; add xB,xB,#off ; ldrsw xE,[xB,xI,lsl #2] ; adr xA,#. ; add xT,xA,xE ; br xTcomputes its target as a PC-relative base plus a signed offset read from a statically addressed (read-only) table -- not a corruptible pointer -- so a BR to exactly thatxTis dismissed. The match is strict-adjacency and deliberately narrow: for an audit the dangerous error is hiding a real hazard, so only this exact five-producer shape is recognized. What still surfaces: every BLR (a call has no jump-table form), linker long-branch veneers (adr+br, no table load), the compactldrb-scaled table variant (a different idiom, left for a future pass), and any genuinely unclassified branch. The authenticated variants and RET differ in encoding and never match. - "zero-discriminator authenticated BR/BLR (PAC audit)": the next
rung of the same ladder. BRAAZ/BLRAAZ (and the B-key BRABZ/BLRABZ)
authenticate their target, but against the constant-zero modifier,
so a passing check proves only "some pointer signed with this key
and discriminator zero" -- and that class is enormous, because the
arm64e C ABI signs every plain function pointer IA with
discriminator zero. An attacker who can overwrite the slot swaps in
any other IA+0-signed pointer in the process; PAC then
authenticates the substitute happily. This is the weakest live PAC
form: above raw BR/BLR (which prove nothing) and below the
diversified BRAA/BLRAA
Xn, Xmforms, whose modifier -- typically the pointer's storage address,__ptrauth-style address diversity, possibly blended with a constant discriminator -- narrows the substitution class to pointers signed for that one slot. The encodings differ in the Z bit (24) and the modifier field, so the diversified forms never match, including theXm = SPspelling; RETAA/RETAB are SP-diversified by construction and are a different encoding entirely. Findings are worklist items, not errors: IA+0 is the ABI floor wherever C function pointers must stay interchangeable across translation units, so each site is a candidate for a__ptrauth-qualified upgrade rather than a bug -- which is exactly the audit-class framing. - Calibration on macOS 26 (Apple's arm64e system binaries): zero
unsigned LR spills across ls, zsh, ssh, and sshd -- Apple's signing
is complete, and the window produces no false positives over
thousands of signed prologues. Every raw BR in these four is the
clang jump-table idiom (ls 1, ssh 3, sshd 3, zsh 22, bash 18 --
each of the 47 byte-verified against the disassembly, zero
mismatches), so the classifier empties the raw-BR worklist
entirely; what would remain on other binaries is veneers, the
ldrbvariant, or real hazards. Over a binary that never opted into pac-ret the LR-spill flag reports every function by design -- the assertion is simply false there (Homebrew's plain-arm64 gh: 31109 spills and 20129 raw BLRs, Go emitting neither signing nor authenticated calls; its jump-table BRs use a different idiom and stay flagged too). That gap is exactly why the auto-arm gates on cpusubtype rather than firing everywhere: the four system binaries above are arm64e and now surface their worklists with no flag, while gh and the other Homebrew arm64 binaries stay silent unless-a pacis asked for explicitly. - Zero-discriminator census over the same corpus (August 2026,
independently byte-verified with a mask scan): ssh 6 braaz + 103
blraaz, sshd 5 + 34, zsh 17 + 426, ls 1 + 1, bash 10 + 75, dyld
23 + 146; no B-key Z form anywhere. The sampled shapes are exactly
the ABI floor: a loaded function pointer, a CBZ NULL check, then
blraaz x8(a C callback invocation), and in zsh a fully signed epilogue --autibspplus the auth-failurebrktrap -- ending inbraaz x2, a tail call through a C function pointer. dyld is the calibration for the rung above: alongside its 169 zero-discriminator sites it makes 571blraa+ 79braadiversified transfers, so the upgrade the finding suggests is standard practice in the one binary whose job is authenticated dispatch. libcapstone and gh (plain arm64) contain none of these encodings at all.
- The Armv8.0 atomic read-modify-write is a retry loop around the
exclusive monitor; Armv8.1 FEAT_LSE does the whole thing in one
wait-free instruction. Three shapes match, under strict adjacency
plus exact branch targets -- the first check in the tree that
validates a cycle:
ldxr x8, [x0] ; add x9, x8, x1 ; stxr w10, x9, [x0] ; cbnz w10, back->ldadd x1, x8, [x0]ldxr x8, [x0] ; stxr w10, x1, [x0] ; cbnz w10, back->swp x1, x8, [x0]ldxr x8, [x0] ; cmp x8, x1 ; b.ne end ; stxr w10, x2, [x0] ; cbnz w10, back ; end:->mov x8, x1 ; cas x8, x2, [x0] ; cmp x8, x1("LDXR/STXR loop foldable to LSE atomic (LSE)").
- The middle op picks the atomic: ADD -> LDADD, ORR -> LDSET, EOR -> LDEOR, BIC -> LDCLR directly; AND -> MVN + LDCLR, SUB -> NEG + LDADD, and ADD/SUB #imm12 -> MOV #imm + LDADD, where the pre-op's scratch reuses the loop's computed-value register -- dead by the same proof that justifies the fold (and excluded in-place, where the scratch would collide with the atomic's own destination, a CONSTRAINED UNPREDICTABLE encoding). Commutative ops accept either operand order; SUB and BIC need the loaded value on the left. All four sizes match, and the exclusive pair's ordering carries over exactly: LDAXR contributes the A suffix, STLXR the L.
- Equivalence rests on the architecture's own terms: both forms are
the two official compiler mappings of the same C11 atomic RMW (GCC
and LLVM emit the loop at
-march=armv8-aand the single atomic at armv8.1+), and the LSE form is wait-free where the loop can livelock under contention. What the rewrite does NOT produce are the loop's scratches -- the computed new value and the store-exclusive status -- so emission defers until BOTH are overwritten before any read or control transfer: the tree's first dual-register death scan (armlint_advance_pending_lse; the swap and CAS shapes watch only the status, since SWP and CAS themselves preserve the old value). - Real spellings, from Go: the status test is often the X-form CBNZ (the store-exclusive's W write zero-extends, so the wide view reads the same 0-or-1), and an X-form ALU between W-size exclusives is routine (the store keeps only the low bits, where add/sub and the logicals agree between widths; the wider destination is in the death set regardless). Both are accepted; the converse W-op-feeding-X-exclusives truncates and never matches.
- The CAS shape has its own contract. CAS compares its first operand against memory, stores the second on a match, and returns the old value in the first either way -- so the three-instruction rewrite re-materializes the comparand into the loaded register with a MOV, lets CAS deposit the old value exactly where the loop left it, and recomputes the flags with a trailing CMP whose operand order mirrors the original (Z alone is order-blind, N/C/V are not). Unlike the fetch-op shape, the only loop output the rewrite does not produce is the store-exclusive status, so the swap-style single-register watch covers it -- including gc's spelling where the status register IS the loaded register (REGTMP serves as both), in which case that one register's death covers the divergence on the success path (status 0 vs old value).
- Two CAS-only gates. First, the early exit must be
b.neto exactly the instruction after the closing CBNZ -- the tree's first forward branch-target validation -- so the compare-fail and store-success paths converge and one death scan covers both; the diverging shape (LLVM parks a CLREX block out of line) never matches. The compare-fail path also leaves the exclusive monitor armed where CAS does not, which well-formed code cannot observe (a STXR without its paired LDXR is CONSTRAINED UNPREDICTABLE). Second, the CMP must be the shifted-register form at exactly the exclusives' width, and only word/doubleword sizes match: an X compare over W exclusives would let the comparand's high bits veto a store CAS would perform, and byte/half loops compare through a zero-extended 32-bit CMP that byte-wide CASB/CASH cannot express. ZR passes where it never does elsewhere in the check: gc spells zero-expected CAS ascmp w27, wzrand CAS-to-zero stores WZR (698 of gh's 857 loops carry one or the other). - Register sanity throughout: the address and operand must be loop-invariant, ZR participates nowhere, SP is no base, and the status register must be fresh (an alias of the address, value, or operand would clobber the next iteration -- and Rs == Rn or Rs == Rt is CONSTRAINED UNPREDICTABLE for STXR anyway). A branch into the loop interior is suppressed by the central side-entry gate; entry at the LDXR itself is the loop's own back edge and is fine.
- On the reference corpus the check is a Go-binary instrument: the
macOS system binaries are all-LSE already (Apple's baseline is
v8.1), while Homebrew's gh -- Go still targeting Armv8.0 --
carries 2,255 exclusive loads. 1,398 of them sit in matching
fetch-op skeletons, and 16 survive the death scan: inlined
fetch-adds whose scratches provably die, each byte-verified
(
c85ffc03 8b020063 c81bfc03 b5ffffbbat__text+0x139acis an in-placeldaxr x3 ; add x3, x3, x2 ; stlxr w27, x3 ; cbnz x27, with x3 overwritten two instructions after the loop and w27 shortly after). The conservative discard of the other ~1,380 is the design working: Go's standalone atomic functions return the loop's computed value, which the single LD-atomic does not produce. - The CAS side of the same census: 857 converging CAS skeletons,
every one gc's intrinsic shape (acquire+release exclusives, REGTMP
as both loaded and status register, the X-form CBNZ; zero
diverging exits, zero immediate-comparand or CBNZ-as-compare
variants). One survives the death scan, byte-verified at
__text+0x9b3d34:885ffcbb 6b1f037f 54000061 881bfca6 b5ffff9bisldaxr w27, [x5] ; cmp w27, wzr ; b.ne +3 ; stlxr w27, w6, [x5] ; cbnz x27, -4->mov w27, wzr ; casal w27, w6, [x5] ; cmp w27, wzr, committed because gc happens to recycle REGTMP for anadrp x27page-address materialization one instruction after the loop's CSET. The ~856 discards are again the conservatism working: the CSET itself is fine (it reads the flags the trailing CMP reproduces), but gc then branches on the bool (inline sites) or returns (the out-of-lineatomic.Casbodies endcset ; mov ; ret) before REGTMP is rewritten -- and Go's register ABI returns results in R0+, so assuming caller-saved death at RET would be unsound there. - Deliberately out of scope, recorded in TODO.md: diverging-exit
CAS loops (the CLREX tail needs a second suggested branch and a
two-path death argument), immediate-form comparands and the
CBNZ-as-compare zero-expected shape (zero of each in gh),
byte/half CAS via the extended-register compare (
cmp w8, w1, uxtb), the compare-and-select MIN/MAX loops (ldsmaxfamily), ST-form suggestions for unused results, and bitmask-immediate logic operands (the complemented constant is not always one MOV).
movz x16, #0x11
orr x0, x28, x16folds to
add x0, x28, #0x11V8 runs with pointer compression: heap pointers are stored as 32-bit
offsets and rebuilt by merging them with a "cage base" kept in x28.
The base is 4GB-aligned, so its low 32 bits are zero and orr and
add compute the same result for any 32-bit offset -- which is why
V8 uses orr for the merge in the first place. When the offset is a
compile-time constant that fits an ADD immediate (12 bits, optionally
LSL #12), the materialize-then-merge pair collapses to one add.
The shape dominates V8 JIT output because every load of a read-only
root (undefined = cage + 0x11, null, true, false, the empty
string) is exactly this sequence: a JetStream 3 JIT dump carried
276,922 adjacent pairs, all with imm12-encodable offsets, and the
check reports the 95,864 of them whose scratch provably dies on the
fall-through path.
The match requires a direct 64-bit ORR Rd, Rn, Rm with LSL #0,
one operand produced by the active MOV chain, and the other operand
x28 exactly (either order); the chain's value must be at most 32 bits
and ADD-immediate-encodable. Bitmask-immediate values are excluded --
the sound MOV + ORR fold already owns them. The deleted MOV goes
through the same deferred register-liveness proof as the other MOV
folds.
Unlike the -m ISA gates, v8cage asserts a runtime invariant of
the scanned code rather than a hardware capability: nothing in the
instruction stream proves x28's alignment, so for arbitrary code the
rewrite is unsound (a set low bit in x28 makes orr and add
disagree). The check therefore stays silent unless the caller asserts
the invariant. It exists because the pattern pointed at a real V8
bug: MacroAssembler::DecompressTagged(Register, Tagged_t) guarded
on IsImmAddSub(immediate) -- the ADD encodability test -- and then
emitted Orr, which needs a (rarely matching) logical immediate and
so quietly materialized through a scratch register instead.
ldr w0, <literal>where the pooled word is0x2ainstead ofmov w0, #0x2a;ldr d0, <literal>holding 1.5 instead offmov d0, #1.5. GPR values fold when they are MOVZ / MOVN / bitmask-immediate encodable (exactly the assembler'smov Rd, #immforms); FP values when FMOV-imm8 encodable (VFPExpandImm in reverse). AnLDRSWliteral materialises the SIGN-EXTENDED value, folding when that 64-bit value is mov-encodable (ldrsw x3, <literal 0xfffffff6>->mov x3, #-10). A Q literal folds when the 128-bit pattern has an integerMOVI/MVNIspelling (AdvSimdExpandImm in reverse): both 64-bit halves equal -- every MOVI form replicates -- and the half byte-replicated (.16b), halfword-replicated (.8h,LSL #0/8, MOVI or MVNI), word-replicated (.4s,LSL #0/8/16/24or the MSL "shifting ones", MOVI or MVNI), or a per-byte 00/FF mask (.2d). The smallest element wins the rendering; the FP-vector immediates (FMOV Vd.4s/2d, #imm8) are not attempted.PRFMis not a load and never folds.- The first binary-aware check: the literal is PC-relative, so the
check reads the pooled bytes out of the scanned buffer itself. A
target outside the buffer (an out-of-section pool) is silently
skipped. Inline pools are hand-written-assembly and JIT territory
-- compilers on AArch64 place constants in data sections reached
via
ADRP-- which is precisely where a reviewer wants the hint. - A one-for-one rewrite: same destination register, no other register or flag touched, and the loaded value is reproduced exactly, so the finding emits immediately with no liveness proof. What it saves: the memory access -- load-use latency and a cache line -- plus the pool slot when nothing else references it.
- V8 JIT dumps:
tools/v8dump2elf.pykeeps each code object's inline constant pool in the section, so this check can read the pooled values; scan that output with-m v8pool, which recognizes V8's self-describing pool marker (LDR XZR, (literal)whose imm19 counts the data words that follow) and steps over the pools rather than decoding embedded constants as instructions. Likev8cagethe bit asserts knowledge about the scanned stream, not an ISA capability: in arbitrary code a literal load to XZR is a legal discarded load followed by real instructions, so the skip stays off by default.
adr x8, L ; ldr x8, [x8]instead ofldr x8, L: the consumer has a direct PC-relative form of its own, so the address never needs to exist in a register. The load form covers every literal-capable width --LDRW/X,LDRSW, and SIMD&FP S/D/Q (byte/halfword loads have no literal form) -- at zero offset, and performs the identical access.adr x16, L ; br x16folds tob L, dropping an indirect branch (BTB/indirect-predictor pressure and mispredict risk) for a fully static one.- Encodability, load form: the literal's word-scaled imm19 anchors
at the LOAD's PC, one instruction after the ADR's, so the target
must be 4-byte aligned (ADR can name any byte) and the re-anchored
displacement must still fit +/-1MB -- it can fall off the low edge
when the ADR named exactly -1MB. Branch form:
Breaches +/-128MB, strictly covering ADR's +/-1MB, so no range check at all. The target may lie outside the scanned buffer; the fold never reads the pointed-to data, so unlike the literal-constant check no buffer is required. - Deadness: the rewrite deletes the ADR. A load destination that IS
the address register kills it structurally; other load
destinations (all FP ones included) defer through the forward
register-liveness scan.
BRnever writes the address register and the linear scan cannot follow the branch, so v1 folds only x16/x17 (IP0/IP1): the ABI reserves them as veneer scratch, and code at the target is not entitled to receive values in them across exactly this shape -- the general-register case would need liveness at the TARGET, future work.BLRis excluded outright (a callee legitimately receives registers, x8 -- the indirect-result pointer -- in particular). ADRP does not open (page arithmetic); ADR to XZR is a dead write. - Composes with the literal-constant fold: once the load is
rewritten to
ldr x8, L, that check may further fold it to amov/moviwhen the pooled value is immediate-encodable.
fmul d0, d1, d2 ; fneg d0, d0instead offnmul d0, d1, d2.FNMUL's pseudocode isFPMulfollowed byFPNegof the ALREADY-ROUNDED product -- negation is a pure sign flip, applied after rounding and raising nothing -- which is exactly what the two-instruction spelling computes. The fold is therefore bit-exact in every FPCR rounding mode with identical FPSR exceptions, NaNs included: both spellings apply the sameFPNegto the sameFPMulresult. All three scalar writes zero the vector register above the written lane, so the final 128-bit state is identical too.- The unsound sibling is deliberately not matched: negating an
operand before the multiply (
fneg d1, d1 ; fmul d0, d1, d2) computesround(-(a*b)), which differs fromFNMUL's-(round(a*b))under the directed rounding modes (FPCR.RMode= RP or RM) -- the two agree only under round-to-nearest, and armlint cannot know the dynamic mode. - Soundness: the
FNEGmust read theFMUL's destination (Rn= the product register). An in-placefneg dd, ddoverwrites the product on the spot -- the same structural argument as the integer producer folds -- and reports immediately; a fresh destination defers through the FP/vector-register liveness scan until the product register provably dies. The scan watches all six views (B/H/S/D/Q/V) of the register and treats written vector operands as read-modify-writes unless their class provably overwrites in full (scalar FP ops, FP loads) -- lane inserts, accumulators and friends can never wrongly commit a finding. No aliasing exclusions are needed: the rewrite reads the multiply's own sources at its position, and even in-place multiplies read before writing in both spellings. - Single and double precision fold; half precision (FEAT_FP16) is not matched, consistent with the FMOV folds.
- What it saves: one instruction, and the dependent
FNEGleaves the critical path --FNMULcosts the same asFMULon current cores, so the negation is free.
-
mov xd, #0 ; <use xd>instead of<use xzr>. Five consumer families:- An integer store (B/H/W/X) with
Rt == mov_rd, in either spelling ->st(u)r <wzr/xzr>, [...]. Saves the MOV when Rt-only. ADD/SUB/ADDS/SUBS(shifted-register, LSL #0) with Rn or Rm == mov_rd -> the same op with that operand as ZR.CMP/CMNaliases are rendered when Rd == ZR + S-variant.AND/ORR/EOR/ANDS(shifted-register, LSL #0, N = 0) with Rn or Rm == mov_rd -> the same op with the operand as ZR.TSTalias when Rd == ZR + ANDS.CSEL/CSINC/CSINV/CSNEGwith Rn or Rm == mov_rd -> the same select with that slot as ZR (legal in either slot for all four). Both slots zero is left to the CSEL identity, a strictly better rewrite.- Register-form
CCMP/CCMNwith Rn == mov_rd ->ccmp ZR, Rm, #nzcv, cond. Only the left operand: an Rm-slot zero already folds to the#0immediate form via the CCMP fold, which deletes the register read outright.
- An integer store (B/H/W/X) with
-
The consumer's instruction count does not change, but the MOV becomes dead (assuming no other read of
xd). Further simplification of forms likeADD Rd, Rn, XZR -> MOV Rd, RnorSUB Rd, XZR, Rm -> NEG Rd, Rmis left to the reader. -
The Rn (base) slot of STR is intentionally excluded: register 31 in addressing means SP, not ZR, so replacing the base would silently change semantics.
-
Both spellings of the store are decoded. The unsigned-offset form scales its imm12 by the transfer size and cannot go negative; the unscaled
STURform carries a signed byte count. Nothing about this rewrite turns on which one the assembler picked -- only the data register changes, and the address is copied through untouched -- so reading one and not the other was a blind spot, the same class of error as the LDUR blindness in the pair coalescer. The unscaled decoder covers loads as well, and only stores are taken: a load intomov_rdoverwrites the zero rather than reading it, so there is no ZR to substitute. -
Corpus: 525 findings across 28.4M instructions. The store arm accounts for 195 of them -- 184 in the unsigned-offset spelling and 11 in the unscaled one, all 11 in librustc_driver. Both realize at about the same rate off their candidate pools (184 of 3,235 and 11 of 225, 5.7% against 4.9%), which is the point: what was missing was the spelling, not a different deadness story, and what still gates both is the forward liveness scan proving the zero register dead. (These figures are post-
insn_writes_no_gpr. This check was the largest victim of the compare-is-not-a-kill bug, losing 310 of 835 -- 285 of them in the SUB arm, wheremov x0, #0 ; sub x3, x0, x2is routinely followed by a compare ofx0.) The dominant unscaled shape is LLVM clearing trailing bytes off a frame pointer, where the negative displacement leaves the assembler no choice:movz w14, #0 sturb w14, [x12, #-3] -> sturb wzr, [x12, #-3]In librustc_driver the same unrolled clear emits
sturbat -3, -2 and -1 and thenstrbat 0 -- so the check had been reporting the last of four and passing over the other three. -
Two store addressing forms remain unread, the writeback and register-offset ones, where the ZR substitution would also be sound (only Rt changes, so the base update and the index are irrelevant to it). Measured rather than assumed: 5 and 10 candidate sites corpus-wide, which at the realization rate above is about one finding. Recorded in TODO.md and not implemented.
-
Side entries dominate this check's false positives on optimized code -- by selection: were the pair straight-line, the compiler would have used ZR directly, so the findings that survive skew toward the shared-return shape,
mov x8, #0on one arm joining a commonmov x0, x8 ; rettail whose other predecessors arrive with a live, non-zero register (every one of the 67 findings on /bin/bash and 38 on /bin/zsh was this). The central emission gate (armlint_finding_has_side_entry) drops a finding whose use slot is a direct-branch target.
mov x8, #256 ; ldr x0, [x1, x8]instead ofldr x0, [x1, #256]. A MOV chain materialises a constant whose only use is the index register of a register-offset load or store; the access already has an immediate-offset form, so the constant folds into it and the MOV dies. The index's scale carries into the byte offset:mov x8, #4 ; ldr x0, [x1, x8, lsl #3]->ldr x0, [x1, #32].- The rewrite is the scaled unsigned-offset form when the byte offset
is non-negative, a multiple of the access size, and at most
4095 x size; otherwise the unscaled
LDUR/STURform when it lies in[-256, 255]:mov x8, #3 ; ldr x0, [x1, x8]->ldur x0, [x1, #3], and an X-form MOVN chain reaches the negative side,mov x8, #-8 ; ldr x0, [x1, x8]->ldur x0, [x1, #-8]. Constants outside every form are not flagged. - Consumers: the integer register-offset family -- the zero- and
sign-extending loads (
LDRB/LDRH/LDR,LDRSB/LDRSH/LDRSW;PRFMis excluded, its Rt being a prefetch operation) and theSTRB/STRH/STRstores. SIMD&FP accesses are not matched. Only theLSL/UXTXindex option (a full 64-bit index) qualifies: the chain pins that index's value exactly -- a W-form chain also qualifies, since its W write zeroedX[63:32]-- while theUXTW/SXTW/SXTXextend options re-interpret the index register and are left alone. - Soundness and the dead-constant question: the fold's saving is the
deleted MOV, so unlike the strength-reduction folds (whose consumer
rewrite pays for itself), the finding is deferred through the same
forward register-liveness scan as the
MOV #0fold and emitted only once the constant register is provably dead -- overwritten before any read or control transfer. A load whose destination IS the constant register kills it at the consumer itself and reports immediately. The base register must not be the constant (the rewrite would still read it), nor may a store's data register be.Rn = 31means SP in both the register-offset and immediate-offset forms, so SP-based accesses fold soundly. - What it saves: one instruction -- the materialising MOV -- and the register that held the index. The access itself neither gains nor loses: the register-offset and immediate-offset forms cost the same on current cores.
mul xt, xa, xb ; add xd, xt, xc->madd xd, xa, xb, xc. Standard array-indexing pattern (base + i*stride). Same for the commuted ADD (add xd, xc, xt) and for SUB with Rm=xt (sub xd, xc, xt -> msub xd, xa, xb, xc).sub xd, xt, xcis NOT folded: MSUB computesRa - Rn*Rm, notRn*Rm - Ra. There is no AArch64 instruction matching the latter form in one op.mul xt, xa, xb ; neg xt, xt(thesub xt, xzr, xtform, so the accumulator is XZR) folds tomneg xt, xa, xb-- theMSUB-with-ZR alias -- and is reported separately as "MUL + NEG foldable to MNEG".- Soundness: the rewrite deletes the MUL, so the product register
must be dead afterward -- an ADD/SUB that overwrites it (
Rd == Rt) reports immediately, and one writing a fresh register defers through the forward register-liveness scan (Rd = 31, a dead write, is excluded). The accumulator operand must not equal Rt (otherwise the ADD reads the MUL's result twice while the MADD rewrite reads pre-MUL values, diverging), and an ADD whose accumulator is XZR is a multiply + register copy, not an accumulate -- a ZR-accumulator MADD would just respell the MUL. - S-variants (ADDS/SUBS) skipped: MADD/MSUB have no flag-setting form. Widths must match (both W or both X).
- Fuse win (a "producer into consumer" fold, see the shift fold):
MADD/MSUBhas the same latency as the bare multiply, so the fold removes the dependentADD/SUBessentially for free, plus one instruction.
- The widening (32x32 -> 64) analogue of the MUL+ADD check.
smull xt, wa, wb ; add xt, xt, xc->smaddl xt, wa, wb, xc. Same for the commuted ADD (add xt, xc, xt) and for SUB with Rm=xt (sub xt, xc, xt -> smsubl xt, wa, wb, xc). TheUMULLforms fold toUMADDL/UMSUBL.SMULL/UMULLare theRa == XZRaliases ofSMADDL/UMADDL. - Width asymmetry vs. the MUL+ADD check: the 32x32 product is
64-bit, so the consumer ADD/SUB must be X-form. A W-form
consumer would operate on only the low 32 bits and is rejected.
In the rewrite the multiply operands stay W-form (
wa,wb) while the destination and accumulator are X-form. sub xt, xt, xcis NOT folded:SMSUBLcomputesXa - Wn*Wm, notWn*Wm - Xa-- the same asymmetry that blockssub xd, xt, xcin the MUL+ADD check.smull xt, wa, wb ; neg xt, xtfolds tosmnegl xt, wa, wb(and theUMULLform toumnegl) -- the longMSUB-with-ZR alias -- reported as "SMULL/UMULL + NEG foldable to SMNEGL/UMNEGL".- Soundness (identical to MUL+ADD): the rewrite deletes the multiply,
so the 64-bit product must be dead afterward -- an ADD/SUB that
overwrites Xt reports immediately, one writing a fresh register
defers through the forward register-liveness scan (
Rd = 31excluded), the accumulator operand must not equal Xt, and an XZR-accumulator ADD (a multiply + register copy) is rejected. Signedness must match the producer (SMULLpairs only withSMADDL/SMSUBL,UMULLonly withUMADDL/UMSUBL). S-variants (ADDS/SUBS) are skipped (no flag-setting long MAC);SMULL/UMULLwriting to ZR is excluded. - Fuse win: same as
MUL + ADD -> MADDabove --SMADDL/UMADDLhas the latency of the widening multiply, so the dependent add is removed essentially for free, plus one instruction.
neg xt, xs ; add xd, xc, xt->sub xd, xc, xs. The ADD is commutative, soneg xt, xs ; add xd, xt, xcfolds the same way. The SUB consumer mirrors:neg xt, xs ; sub xd, xc, xt->add xd, xc, xs.sub xd, xt, xcis NOT foldable: computes-xs - xc, which has no single-instruction AArch64 form.- A
CSELconsumer folds too, becauseCSNEG's else-branch is a negation (Rd = cond ? Rn : -Rm):neg xt, xs ; csel xd, xn, xt, cc->csneg xd, xn, xs, cc(negation in the else slot; the condition carries over).neg xt, xs ; csel xd, xt, xm, cc->csneg xd, xm, xs, !cc(negation in the then slot; the rewrite swaps the operands and inverts the condition). OnlyCSELproper (op2 = 00) matches --CSINC/CSINV/CSNEGhave different else-branches. The rewrite reads the same NZCV theCSELdid (aNEGwrites no flags), so no flag-liveness scan is needed, and it readsxs, which the adjacent pair leaves unchanged even for the in-placeneg xt, xt.AL/NVconditions are excluded:ConditionHoldstreats both as always-true, so such a select is a plainMOVand the then-slot inversion (AL<->NV) would still be always-taken. ACSELreadingxtin both slots is the same-operand identity, which the CSEL identity check owns. Unlike theADD/SUBconsumers, the surviving operand may beXZR(csneg xd, xzr, xs, cc--cond ? 0 : -xs-- has no shorter form). Reported as "NEG + CSEL foldable to CSNEG"; the shape appears when codegen materialises a negation and then selects between the original and negated value (abs/nabs-style branchless idioms).
- Soundness: the rewrite deletes the NEG, so its destination must be
dead afterward -- a consumer that overwrites it (
Rd == Rt) reports immediately, and one writing a fresh register defers through the forward register-liveness scan (Rd = 31-- a dead write, or a discarded select -- is excluded). The ADD/SUB accumulator operand must not equal Rt -- otherwise both ADD/SUB sources are-xs, computing-2*xsor0instead of the additive identity the fold assumes -- nor XZR, whose shapes are double negations (a copy of the negation, or the negation of it), not accumulates. - S-variants (ADDS/SUBS, NEGS) are skipped: flag definitions differ between the original and the rewrite. Widths must match (both W or both X). NEG of XZR (computes 0) is excluded.
- Fuse win (see the shift fold): the negate is absorbed into the
consumer's sign -- one fewer instruction, the
NEGoff the critical path.
- The logical-op counterpart of the NEG fold.
mvn wt, ws(bitwise NOT) feeding a logical op collapses into that op's built-in negated-operand form:mvn wt, ws ; and wd, wn, wt->bic wd, wn, wsmvn wt, ws ; orr wd, wn, wt->orn wd, wn, wsmvn wt, ws ; eor wd, wn, wt->eon wd, wn, wsmvn wt, ws ; ands wd, wn, wt->bics wd, wn, ws
- All four consumers are commutative, so the
mvnresult may sit in the consumer's Rn or Rm slot; the fold puts the other operand in Rn andwsin the negated Rm slot. (ANDS->BICSis sound because both set N/Z from the same result with C = V = 0.) - Soundness (mirrors the NEG fold): the rewrite deletes the
mvn, so its destination must be dead afterward -- a consumer that overwrites it (Rd == wt) reports immediately, and one writing a fresh register defers through the forward register-liveness scan (Rd = 31-- a dead write, or theTSTalias forANDS-- is excluded). The independent operand must not also bewt-- themvn wt, ws ; and wt, wt, wtdegenerate is a self-op, reported by the self-op check instead -- nor XZR (orr wd, wzr, wtis theMOValias, whose fold isMVNitself; theAND/EORforms are constants). The shiftedMVNform is not handled (the consumer would shift the complemented value, notws).MVNto ZR, andMVNof ZR (the all-onesmov wd, #-1idiom), are excluded. - A
CSELconsumer folds toCSINV, whose else-branch is a complement (Rd = cond ? Rn : ~Rm) -- the exact mirror of theNEG+CSEL->CSNEGfold: the else slot carries the condition over (mvn wt, ws ; csel wd, wn, wt, cc->csinv wd, wn, ws, cc), the then slot swaps operands and inverts it. AL/NV,Rd = 31, both-slots (check_csel_self's shape) and width mismatches are excluded; the destination overwritingwtreports immediately, a fresh destination defers through the register-liveness scan. - Fuse win (see the shift fold): the
MVNis absorbed into the consumer's negated-operand form -- one fewer instruction, theMVNoff the critical path.
add xt, xn, xm{, lsl #s} ; ldr xt, [xt]->ldr xt, [xn, xm{, lsl #s}]. Saves the ADD by letting the LDR do the address arithmetic via its register-offset addressing mode.- Shift constraint: AArch64's LDR (register) accepts only LSL #0 or LSL #log2(access_size) -- 0 or 1 for LDRH, 0 or 2 for LDR W, 0 or 3 for LDR X. The check filters to those amounts.
- Soundness: the rewrite deletes the ADD, so its destination must be
dead afterward. A load whose
Rtequals the ADD's Rd proves that structurally -- the write to Wt/Xt destroys the pre-LDR address value -- and reports immediately. - The sign-extending loads (
LDRSB/LDRSH, Wt or Xt;LDRSW) fold identically: they too overwrite the full X register named byRt(a W-form write zeros the upper half) and have register-offset forms with the same shift rule.PRFM, which shares the encoding family, is excluded -- itsRtfield is a prefetch operation, not a destination, so the address register stays live. - Stores and fresh-destination loads fold too, through the deferred
tier:
add xt, xn, xm ; str x0, [xt]->str x0, [xn, xm](same forSTRB/STRH, reported as "ADD + STR foldable to register-offset STR"), andadd xt, xn, xm ; ldr xq, [xt]withxq != xt. Neither consumer overwritesxt, so emission defers through the forward register-liveness scan and reports only once a later instruction overwritesxtbefore any read or control transfer. A store whose data register isxtnever folds -- the rewritten store would read the deleted sum. - Rn = XZR in the ADD is excluded because Rn = 31 in the LDR's register-offset form means SP, a semantic mismatch. Rm = XZR (degenerate ADD) is skipped for cleanliness.
- The extend analogue of the check above: where that absorbs an
ADDinto the load's register offset, this absorbs a sign-extend into the offset's extend modifier. The canonical 32-bit-signed-index idiom:sxtw x0, w1 ; ldr x0, [x3, x0]->ldr x0, [x3, w1, sxtw]sxtw x0, w1 ; ldr x0, [x3, x0, lsl #3]->ldr x0, [x3, w1, sxtw #3]All four zero-extending sizes (LDRB/LDRH/LDR W/LDR X), the sign-extending loads (LDRSB/LDRSH, Wt or Xt;LDRSW), and theSTRB/STRH/STRstores are handled, and the scale bit carries over.
- Why it helps: one fewer instruction, and the sign-extend leaves the critical path -- the load's address-generation unit does it for free rather than a separate dependent op feeding the load. (Same profile as the LSL/extend folds; this is the load-addressing form of it.)
- Soundness (mirrors the
ADD + LDRregister-offset check): the consumer must use the LSL/UXTX index option (a full 64-bit register offset, identical to theSXTWresult) withRm == Xt. The rewrite deletes theSXTW, soXtmust be dead afterward: a load withRt == Xtproves that structurally and reports immediately; a store (reported as "SXTW + register-offset STR foldable into the store"), or a load into a different register, defers through the forward register-liveness scan untilXtis provably overwritten before any read or control transfer. A store whose data register isXtnever folds (the rewritten store would read the deleted extend's result), andPRFMis excluded (itsRtis a prefetch operation rather than a destination). The baseRnmust NOT beXt: with theSXTWfolded away the base would read its pre-SXTWvalue, changing the address.SXTWinto ZR is excluded. - Only
SXTWis matched: the load-index extend is word-width, and a standalone 32->64 zero-extend is normally aW-registerMOV, not a literalUXTWinstruction.
- A zero-extending load immediately re-extended with the sign is the
sign-extending load:
ldrb w3, [x1] ; sxtb w3, w3->ldrsb w3, [x1]ldrb w3, [x1] ; sxtb x3, w3->ldrsb x3, [x1](the X-form consumer widens to 64 bits, so the fold is theXtform)ldrh w4, [x1, #2] ; sxth w4, w4->ldrsh w4, [x1, #2]ldr w2, [sp, #4] ; sxtw x2, w2->ldrsw x2, [sp, #4]
- Fuse win (see the shift fold): one fewer instruction, and the
extension moves off the critical path into the load's own writeback
-- the dependent
SXTno longer executes as a separate ALU op. - Soundness (structural): the
SXTreads and overwrites the load'sRt, so the zero-extended intermediate is provably dead, and the rewrite performs the identical memory access -- same address, same size -- with only the extension behaviour changed to match what the pair computed. - The W-form sign-extending loads (
LDRSB/LDRSH Wt) are a second producer family: re-widened to 64 bits by an X-form consumer, the pair is exactly the X-form load.ldrsb w8, [x9] ; sxtb x8, w8->ldrsb x8, [x9]ldrsb w8, [x9] ; sxtw x8, w8->ldrsb x8, [x9]ldrsh w8, [x9, #2] ; sxth x8, w8->ldrsh x8, [x9, #2]Here the threshold need only be AT OR ABOVE the access width: every bit from the width up is a copy of the loaded sign, soSXTB,SXTHandSXTWall reproduce what the X-form load computes. The W-form consumer is excluded -- after a W-form sign-extending load it changes nothing, which is the redundant-sext check's finding, not a fold.
- For the zero-extending producers, the consumer's sign threshold
must equal the load's access width.
Below it (
ldr w2, [x1] ; sxtb w2, w2) theLDRSrewrite would shrink the memory access, which is not architecturally identical (alignment, permissions and watchpoints are checked per byte accessed) -- the same exclusion applies to below-width thresholds after a sign-extending load (ldrsh w8, [x9] ; sxtb x8, w8), where bit 7 of the halfword is data, not its sign. Above it (ldrb w3, [x1] ; sxth w3, w3) the consumer sign-extends from a bit the load provably zeroed -- a no-op worth removing, but not this rewrite. An X-form load never folds: LDR Xt is already full-width, and the X-form sign-extending loads are already extended through bit 63 (their re-extensions are the redundant-sext check's no-ops). - v1 matches the unsigned-offset addressing form only, like the other
load-rewriting folds. The unscaled, pre-/post-indexed and
register-offset forms have
LDRSequivalents and could fold the same way.
add xt, xn, #a ; ldr xt, [xt, #b]->ldr xt, [xn, #(a+b)], withb == 0the most common case. The immediate-form complement of the register-offset fold: same deadness soundness argument, but the ADD's constant offset (plus the access's, if any) moves into the unsigned immediate slot. The sign-extending loads (LDRSB/LDRSH, Wt or Xt;LDRSW) fold the same way -- they too overwrite the full X register named byRtand have unsigned-offset forms;PRFMis excluded (itsRtis a prefetch operation, so the address register stays live).- Stores and fresh-destination loads fold through the deferred tier
(mirroring the register-offset check):
add xt, xn, #a ; str x0, [xt, #b]->str x0, [xn, #(a+b)](same forSTRB/STRH, reported as "ADD + STR foldable to immediate-offset STR"), andadd xt, xn, #a ; ldr xq, [xt]withxq != xt. Neither consumer overwritesxt, so emission defers through the forward register-liveness scan and reports only oncextis provably overwritten before any read or control transfer. A store whose data register isxtnever folds (the rewritten store would read the deleted sum). The canonical stack-spill-through-a-temp --add x8, sp, #32 ; str x0, [x8]->str x0, [sp, #0x20]-- is the flagship store shape. - Both register files are matched. An access's data register has no
bearing on its address arithmetic, so the SIMD&FP forms fold on the
same terms as the integer ones, with the log2 transfer size coming
from the encoding -- 16 bytes for a
Q, 8 for aD-- and setting the grid the combined offset must land on. Two things do turn on the register file, and both cut the same way: a SIMD&FP data register can never alias the integer base, so it is neither the read-the-deleted-sum case that blocks a store fold nor the load-into-its-own-base that proves the sum dead on the spot. Every SIMD&FP site defers to the forward liveness scan; there is no structural tier there at all. - Both spellings of the access are decoded. AArch64 gives every
base-plus-offset access two encodings -- the unsigned-offset form,
whose
imm12is scaled by the transfer size and cannot go negative, and the unscaledLDUR/STURform, whoseimm9is a signed byte count -- and an assembler picks per instruction. Which one it picked says nothing about whether the sum folds, so both decode to a signed byte displacement and mix freely. - Encoding constraint: the combined byte offset must encode in one
spelling or the other -- non-negative, on the access-size grid and
under
4095 * sizefor the scaled form, or within-256..255for the unscaled one. Neither property can be inferred from the ADD's immediate the way it could when every input was scaled, because an unscaled input carries no alignment guarantee; the sum itself is tested. That also admits sums the old test refused: a misaligned ADD immediate under a scaled access lands outsideimm12but insideimm9, soadd x3, x1, #4 ; ldr x3, [x3]folds toldur x3, [x1, #4]. The output spelling is chosen from the sum alone. The ADD'ssh=1form (imm12 << 12) is supported. The-256floor is unreachable from this producer -- ADD-immediate is non-negative andimm9bottoms out at-256, so the sum never goes below it -- but the guard states the encoding's range rather than this caller's reach. - Rn = SP (Rn = 31 in ADD-imm) is intentionally flagged: ADD-imm
and LDR-uimm both encode 31 as SP, so the canonical stack-
relative load pattern (
add xt, sp, #imm ; ldr xt, [xt]) folds correctly. That includesimm == 0-- the MOV-from-SP alias:mov xt, sp ; ldr xt, [xt]->ldr xt, [sp](rendered with themovspelling).imm == 0with a GPR source stays excluded; that is the redundant ADDcheck_add_sub_zeroowns. Rd = SP in the ADD is excluded -- folding would discard the observable SP update. - SUB-immediate is not folded. The reason used to be that the LDR
unsigned-offset form has no negative-immediate encoding; with the
unscaled spelling understood that is no longer true, and the only
remaining reason is that the pending slot opens on ADD-immediate
alone. A SUB producer whose sum lands in
imm9would fold; it is simply not looked for. - Side entries: a memory op that is itself the target of a direct
branch (B/BL, B.cond/BC.cond, CBZ/CBNZ, TBZ/TBNZ) never closes a
fold. The entering path skips the ADD -- the list-walk idiom
p = p->nextre-enters at the load with the base holding a node pointer, not the ADD's sum -- so the merged instruction would apply the immediate on a path that never added it. The gate reads the branch-target map that armlint_state_set_buffer builds once per section from the raw words; without a buffer it is off (bufferless callers keep the old behavior). The map deliberately under-approximates: indirect branches (BR, jump tables) and cross-section entries are invisible, so a residual false positive is possible where such an entry lands exactly on a flagged memory op. A branch onto the ADD itself does not suppress the fold -- that entry executes the whole pair. Data words that decode as branches can only add spurious targets, i.e. suppress a finding, never unsuppress one. The same rule is enforced centrally for every multi-instruction finding at emission (armlint_finding_has_side_entry: no instruction of the rewritten window after the first may be a branch target); this check gates at close anyway so a doomed pairing never occupies the shared deferral slot. - Corpus: 7,394 findings across 28.4M instructions (6,304
librustc_driver, 903 bash, 77 dyld, 58 libcrypto, 51 ssh, 1 go).
Two coverage fixes account for 1,956 of those: teaching the check
the unscaled spelling added 126, and teaching it the SIMD&FP
register class added 1,830. The second cost 18 pair findings it did
not intend to: the newly-matched SIMD&FP accesses open deferrals of
their own, and
defer_dead_mov's single slot silently drops an earlier one when a second arrives, so a pending ADD + LDP finding waiting on its kill can now be evicted by an FP access two instructions later. Net +1,812, and the eviction is the tracked multi-slot item in TODO.md rather than anything specific to this check. The unscaled figure is far below what the candidate population suggests -- 23,503 adjacent ADD + LDUR/STUR pairs exist in the corpus, 9,124 of them with a sum that encodes -- and the gap is structural, not a further blind spot. The immediate tier is a load into its own base (add x3, x1, #16 ; ldr x3, [x3]), which proves the sum dead on the spot; everything else defers to the forward liveness scan, which usually refuses. That tier is 21.3% of the encodable scaled population and 1.4% of the unscaled one, because the two spellings sit in different idioms: a scaled offset is the compute-an-address-and-dereference-it shape, while the unscaled one appears on field accesses off a long-lived base that the ADD does not consume (the next instruction is another access off the same base at 10.2% of unscaled sites against 4.4% of scaled ones). The remaining ~8,000 are visible to the check and refused on soundness, which is a different backlog entry from being unable to see them. One specific reason accounts for much of it -- the base having a second consumer, which forces the deferred scan to refuse -- and that is what the multi-use fold below now claims, 3,188 sites drawn from this backlog and from the scaled, SIMD&FP and pair populations alongside it. - Strict adjacency is the other boundary, and it is a boundary between the two checks rather than a condition on the rewrite. The pending slot clears on any instruction that is not the consumer, so a base whose sole use sits even one instruction further on is invisible here; the multi-use fold's window reaches it and reports it under that name, 616 more sites.
add xt, xn, #a ; ldp xq, xr, [xt, #b]->ldp xq, xr, [xn, #(a+b)], and the store twin (ADD + STP foldable to immediate-offset STP). The pair arm of the single-access fold above, sharing its pending-ADD state, its side-entry gate and its liveness scan; what differs is the slot the combined offset has to fit.- Why it is not just the single-access rule with a wider register
list: the pair forms have no unsigned-offset encoding. Their
imm7is SIGNED and pre-scaled by the per-register transfer size, so the combined offset may land on either side of the new base. The shape that dominates real code is an ADD forward and a negativeimm7back, cancelling to a bare base --add x19, x26, #0xb8 ; ldp x21, x20, [x19, #-0xb8]->ldp x21, x20, [x26]. The single-access fold reaches a negative sum only through the unscaledLDUR/STURspelling, whoseimm9stops at-256; a pair'simm7is pre-scaled, so it reaches-512for X and-1024for Q. Beyond that the pair form is simply a different slot, not a wider one. - Encoding constraint:
imm7is already a multiple of the transfer size, so the combined offset's alignment is decided solely by the ADD's byte immediate, and the SCALED total must fit signed 7 bits (-64 .. 63, i.e. -512..504 bytes for an X pair, -1024..1008 for a Q pair). Thesh=1ADD form is accepted; a total that overflows the slot keeps its own ADD, as before. The negative end cannot be undershot: sourceimm7bottoms out at -64 and the ADD's immediate is non-negative, so the only way to reach exactly -64 scaled is the zero-immediate MOV-from-SP alias. - Both integer and SIMD&FP pairs fold, plus
LDPSW(whose transfer is 4 bytes per register even though it writes X destinations, so it scales by 4). The writeback spellings are not matched here -- those belong to the pre-/post-index checks. - Deadness tiers, as for the single-access fold. An integer pair LOAD
whose destination list covers the ADD's
Rdoverwrites the address register on the spot, proving the sum dead with no scan; every other pair -- stores, fresh-destination loads, and all SIMD&FP pairs -- defers through the forward register-liveness scan. In the mining corpus the structural tier is tiny (27 sites of 8,775 candidates): essentially the whole population is deferred, which is why this check could not have been written before that scan existed. - A pair STORE whose data registers include the ADD's
Rdnever folds -- the rewritten store would read the deleted sum. The test is for integer pairs only: SIMD&FP data registers live in the other register file and can never alias the integer base, sostp q8, q9, [x8]offadd x8, ...folds despite the shared register number.Rt = 31in a pair is ZR, never SP, and the ADD'sRdis never 31, so no zero-register case slips through the alias test. - Naming the new base among a pair LOAD's destinations is safe: the
no-writeback form reads the base once before writing either
destination (the
t == nrestriction applies to the pre- and post-indexed forms, not this one), and compilers emit that shape freely -- 32,078 sites across the mining corpus. - Actionability limit: in an UNLINKED object an ADD immediate may be a
relocation field (
R_AARCH64_ADD_ABS_LO12_NC, Mach-OPAGEOFF12), and no relocation type targets a pair'simm7, so the fold would not be expressible even though it is sound. armlint does not read relocations, so this is a residual false positive on unlinked input; on linked binaries -- what the corpus figures below measure -- the immediate is final and the concern does not arise. The same caveat applies to the single-access fold, where the:lo12:load form happens to make it expressible. - Corpus: 8,775 candidate sites fold 2-for-1 across 28.4M instructions
(5,467 librustc_driver, 3,270 go), of which armlint reports 2,864
after the liveness scan -- 2,811 in librustc_driver, a 51% realized
rate matching the ADD/SUB chain check's. Go realizes far less (50 of
3,270): gc's fixed
x27scratch stays live across the pair, so the scan correctly refuses. A further 17,565 sites have a combined offset too large forimm7; those split into two singles rather than one pair and are tracked in TODO.md as a latency-only, size-neutral rewrite.
-
add xt, xn, #a ; <access> [xt, #b] ; <access> [xt, #c] ; ...-> the accesses rebased onxnata+b,a+c, ... and the ADD deleted. The other half of the single-access fold's population: that check folds an ADD into the one access next to it and gives up as soon as the base is read again -- correctly, since the ADD would have to survive for the second consumer and the rewrite would save nothing. But when every consumer in the base's live range can be rebased, all of them are, and the ADD goes away. Three instructions become two, the same saving, off a shape the adjacency rule cannot see:add x8, x0, #0x120 ldur w1, [x8, #-4] -> ldur w1, [x0, #0x11c] ldr w2, [x8, #4] -> ldr w2, [x0, #0x124] -
A sole use reports here too, provided it is not the instruction directly after the ADD. One use pays exactly as well as many -- the ADD goes either way, so the saving is one instruction -- and the only question is whose finding it is. That one position is the whole of
check_add_ldr_imm_offset's reach: it clears its pending slot on anything that is not the consumer. So the two split by position and stay disjoint by construction, not by arrangement -- at one use this check refuses the adjacent site, and at two or more the other one's deferred liveness scan sees the base read again and discards. -
The split is by position, not by outcome, which leaves a narrow false negative. An adjacent sole use that
check_add_ldr_imm_offsetopens a deferral for and then loses -- to an evicted slot or an expired window -- is refused here too rather than picked up as a second chance. Reporting a site twice is the worse failure. -
The gapped sole use is also what makes the side-entry span below earn its keep. With two or more uses the instructions between them are almost always uses themselves; with one use across a gap the finding covers instructions it does not mention, and a branch into that gap rejects it.
-
Rebasable means an access whose displacement is a plain immediate: the integer and SIMD&FP single accesses in both the unsigned-offset and the unscaled spelling, and the signed-offset pairs. Covering all of them matters more here than for a single-access fold, because one unrecognized use fails the whole site -- the SIMD&FP and pair forms are not an extension of this check but a precondition for it, since the dominant real shape is a block of
q-register spills. The writeback (pre- and post-index) and register-offset forms are absent by design: a writeback also updates the base, so deleting the ADD would drop an observable update, and a register-offset address is not a constant the ADD's immediate can join. -
The range test is per-use and per-form. A single access takes whichever of its two spellings fits, since the assembler picks between them; the pair forms have no unsigned-offset spelling at all, so there the sum must be on the transfer-size grid and fit signed 7 bits once scaled.
-
Proving every use is what this needs a forward scan for, where the other folds need only adjacency. The scan runs to whichever comes first: the base overwritten (the fold is safe, every use is behind us), any other read of it (a use that does not fold, so the ADD must stay), a control transfer, or the window expiring. The window counts only instructions that are neither uses nor the kill, so a long run of consecutive accesses never exhausts it -- the corpus has a 20-use site.
-
The ADD's source is watched too, which no adjacency-based fold has to do. Every rewritten access reads
xnat its own offset instead of at the ADD, so anything that writesxnin between invalidates the rebase. When the source is SP the register-liveness scan cannot help:arm64_gpr_nummaps SP (like the zero register) to -1, soinsn_reg_accessnever reports it, and the encodings that can name SP as a destination are matched directly instead -- ADD/SUB immediate and extended-register withRd = 31andS = 0, and the writeback addressing forms withRn = 31. A dynamic stack allocation between the base copy and its uses would otherwise be silently dropped. -
Side entries:
insn_countspans the ADD through the last use, not the rendered lines, and the central gate (armlint_finding_has_side_entry) then covers exactly that range. That is not conservatism -- a branch landing anywhere between the ADD and the last use reaches a rewritten access on a path that never added the immediate, so the address would differ. A branch past the last use is harmless, and the span ends there. -
One tracked ADD at a time. A second arriving while one is live replaces it, so interleaved bases report only the inner one. Like
defer_dead_mov's single slot this is false-negative-only, and it costs little in practice: the measured yield landed within 0.1% of the estimate made without the restriction. -
Corpus: 3,804 findings across 28.4M instructions (2,935 librustc_driver, 708 go, 70 dyld, 68 bash, 16 libcrypto, 7 ssh). Uses per site: 1 at 616 sites, 2 at 2,174, 3 at 664, 4 at 170, 5 at 65, 6 at 64, and a tail to 20. By what produces the base, 2,644 of the multi-use sites are stack frames (
add xt, sp, #a), 468 are global addresses off an ADRP, and 76 are plain pointer arithmetic. -
The sole-use population inverts that. Of its 616 sites 318 are ADRP globals, 213 stack frames and 85 pointer arithmetic -- the one shape where a base is computed for exactly one access is a global's page offset, and the tail of the LLVM frame shape is the part where the virtual base register ended up feeding a single slot. They also sit close: 553 of the 616 are gapped by exactly one instruction, and in 412 of those the gap is itself a load or store. That is a scheduler covering the address's latency with independent work, and it is the entire reason
check_add_ldr_imm_offsetcannot see them:add x8, sp, #0x1d0 movi v0.2d, #0 <- the value the store needs stp q0, q0, [x8, #0x10] -> stp q0, q0, [sp, #0x1e0]By what the use is: 270 integer single accesses, 224 SIMD&FP pairs, 118 SIMD&FP singles, 4 integer pairs.
-
The stack-frame majority is a single LLVM shape.
LocalStackSlotAllocationinserts a virtual base register when a frame index's estimated offset looks out of addressing range, and the estimate is made before the frame layout is final; when the real offset turns out to fit, the base register stays. The tell is that the ADD's immediate is usually off the transfer-size grid, which is what forced the accesses into the unscaled spelling in the first place -- rebasing puts them all back on it:add x8, sp, #0x2a8 stur q0, [x8, #0x68] -> str q0, [sp, #0x310] stur q0, [x8, #0x78] -> str q0, [sp, #0x320] ... eight more, then x8 is overwrittenNot a toolchain-forced shape: nothing about the ISA or the object format requires the scratch base, and the rewrite is a pure deletion.
ldr xt, [xn] ; add xn, xn, #imm->ldr xt, [xn], #imm, and the negative-directionldr xt, [xn] ; sub xn, xn, #imm->ldr xt, [xn], #-imm. Same for STR, all four integer access sizes (B/H/W/X), and every SIMD&FP size (B/H/S/D/Q) -- an FP Rt never aliases the integer base, so the Rt == Rn writeback restriction is integer-only. The post-indexed encoding already expresses "load/store from[xn]and then bumpxnby ±imm", so the rewrite is a literal source-to-encoding fold with no semantic change.- Pairs fold the same way:
ldp xt, xu, [xn] ; add xn, xn, #imm->ldp xt, xu, [xn], #imm, covering the integer W/X pairs,LDPSW, and the SIMD&FP S/D/Q pairs. The flagship shape is the canonical frame epilogue,ldp x29, x30, [sp] ; add sp, sp, #imm->ldp x29, x30, [sp], #imm-- exactly what compilers emit, so its unfused spelling is a reliable tell of naive codegen (baseline JIT tiers, hand-written assembly). - What you actually save: 4 bytes per fold and one fetch/decode slot. The backend cost is typically unchanged -- most modern OoO cores (Apple M-series, Cortex-A76+, Neoverse N1+) crack the post-indexed load into two micro-ops (load and base-register writeback), the same backend work as the original two instructions. Critical-path latency is unchanged. The wins are in code size, I-cache footprint, and front-end bandwidth (helpful on decode-bound inner loops); don't expect a measurable cycle drop on backend-bound code. The same accounting holds for the pair forms: a writeback LDP cracks into about the same total micro-op count as the separate LDP + ADD on current big cores, so the pair fold is likewise a size and front-end win rather than a cycle win -- mainstream compilers emit the folded form for every frame prologue/epilogue.
- Encoding constraint: single post-index uses a 9-bit signed byte
immediate (-256..255). An
ADD-imm self-update with imm in 1..255 folds to a positive writeback; aSUB-imm self-update with imm in 1..256 folds to a negative one (-256 is the signed-9-bit minimum, so the negative side reaches one further than the positive). Pair post-index instead uses a scaled signed 7-bit immediate: the ADD/SUB amount must be a multiple of the per-register transfer size (4 for W/S/LDPSW, 8 for X/D, 16 for Q) with quotient 1..63 forADDor 1..64 forSUB(so X pairs reach +504/-512 bytes and Q pairs +1008/-1024). Thesh=1form (imm >= 4096) is out of every slot's range. The access's offset must be 0 -- a non-zero offset combined with a base bump matches the pre-indexed pattern, not post-index. - Soundness: the ADD/SUB must be a self-update (
Rd == Rn ==the access'sRn), since post-index can only update its own base register. Rt == Rn writeback is UNPREDICTABLE for loads and CONSTRAINED UNPREDICTABLE for stores -- for pairs that applies to either data register -- so those cases are rejected, except when Rn == 31, where Rn means SP and Rt means XZR (distinct registers, no conflict). A load pair with Rt == Rt2 is CONSTRAINED UNPREDICTABLE even without writeback and is never folded; a store pair with a repeated source is well-defined, sostp xzr, xzr, [sp] ; add sp, sp, #imm(the common 16-byte zero store plus bump) is flagged.str xzr, [sp] ; add sp, sp, #immandldr xt, [sp] ; add sp, sp, #immare both flagged as the canonical stack-frame teardown patterns. ADDS/SUBS(flag-setting) are excluded because post-index has no flag-setting form. Distinct fromcheck_add_ldr_imm_offset, which catches the reversed sequence (ADD then LDR) and folds into the unsigned-offset form rather than post-index.
add xn, xn, #imm ; ldr xt, [xn]->ldr xt, [xn, #imm]!, and the negative-directionsub xn, xn, #imm ; ldr xt, [xn]->ldr xt, [xn, #-imm]!. Same for STR, all four integer access sizes (B/H/W/X), and every SIMD&FP size (B/H/S/D/Q) -- an FP Rt never aliases the integer base, so the Rt == Rn writeback restriction is integer-only. The pre-indexed encoding already expresses "bumpxnby ±imm and then load/store from the newxn", which is exactly what the source sequence does.- Pairs fold the same way:
sub sp, sp, #imm ; stp x29, x30, [sp]->stp x29, x30, [sp, #-imm]!is THE canonical frame prologue, and the fold covers the integer W/X pairs,LDPSW, and the SIMD&FP S/D/Q pairs. - Same code-size and decode-slot win as the post-index check. The backend cost is also unchanged: most modern OoO cores crack pre-indexed loads into two micro-ops (address update and load), the same dependency chain as ADD followed by LDR; the pair forms carry the same accounting (see the post-index notes).
- Encoding constraint: same slots as post-index. Singles use the
9-bit signed byte immediate -- an
ADDself-update with imm in 1..255 folds to a positive writeback, aSUBself-update with imm in 1..256 to a negative one. Pairs use the scaled signed 7-bit immediate: a multiple of the 4/8/16-byte transfer size with quotient 1..63 (ADD) or 1..64 (SUB). The pending ADD/SUB is admitted up to the largest pair writeback (1008/1024 bytes) and the consumer's actual slot is re-checked when the access arrives, soadd xn, xn, #304 ; ldp ...folds while the same bump with a singleldrcorrectly does not. The access's offset must be 0 -- a non-zero offset combined with a base bump has no single pre-index expression that preserves both the access address and the final base register value. - Soundness: the ADD/SUB must be a self-update (
Rd == Rn ==the access'sRn). Rt == Rn writeback is rejected (UNPREDICTABLE / CONSTRAINED UNPREDICTABLE) -- for pairs, either data register -- except when Rn == 31 (Rn means SP, Rt means XZR; distinct registers). A load pair with Rt == Rt2 is CONSTRAINED UNPREDICTABLE on its own and is never folded; a store pair with a repeated source (stp xzr, xzr) is well-defined and folds. - Cross-check interaction with
check_add_ldr_imm_offset: when Rt == Rn == ADD's Rd, that earlier check fires instead and folds to the unsigned-offset form (no writeback) -- but only forADD, since the unsigned-offset form has no negative immediate, so aSUBwith Rt == Rn yields no finding at all. When Rt != Rn but rn == ADD's Rd, both can fire: this check reports the pre-indexed form immediately, and the immediate-offset fold additionally reports once its forward scan proves the updated base dead -- the writeback is pointless for a dead base, so its no-writeback rewrite is strictly better there. The two findings offer alternative outcomes, like the CMP-drop/CBZ-fold overlap. - Side entries: a memory op that is itself the target of a direct
branch never closes a fold. The rotated-loop idiom --
while (isspace(*p)) p++compiles to an entry branch that lands on the load, past the increment -- is exactly this shape, and the pre-indexed rewrite would bump the base on the entering path too. Unlike the immediate-offset fold, no liveness argument catches this (the rewrite keeps the base live on purpose), so the gate is the only defense. It shares the branch-target map described in the immediate-offset section (built by armlint_state_set_buffer; off without a buffer; blind to indirect branches), and the central emission gate covers it as well. A branch onto the ADD/SUB itself does not suppress the fold. - The
ADDhalf of a linker-materialized address pair never opens the pattern:ADD Rd, Rd, #immimmediately preceded byADR/ADRPwith the sameRdis the page-relative addressing pair (adrp xn, page ; add xn, xn, #pageoff), and#pageoffis a relocation field (ELFR_AARCH64_ADD_ABS_LO12_NC, Mach-OARM64_RELOC_PAGEOFF12, Go'sR_ADDRARM64). Relocation types exist only for the single load/store scaled imm12 slot (theR_AARCH64_LDST{8,16,32,64}_ABS_LO12_NCfamily) -- none targets the pre-indexed imm9 or the pair imm7 field -- so no compiler or assembler can emit the suggested rewrite; changing the pair requires re-linking, not a code rewrite, the same reasoning as the ADD/SUB #0 check's ADRP+ADD suppression. This is why toolchains that fold#pageoffinto single loads (Go emitsadrp ; ldr xt, [xn, #lo12]for every aligned global load) still emitadrp ; add ; ldpfor pair loads of 16-byte globals -- the three-instruction form is already optimal under the available relocations, and flagging it (209 LDP + 12 STP + 1 STR sites in go1.26.4'sgobinary, every one an ADRP pair) would demand the impossible.SUBself-updates are unaffected (no relocation usesSUB), an intervening instruction between the ADR/ADRP and the ADD re-enables the fold (strict adjacency, matching the relocation span), and the tracking is private to this check:check_add_sub_zeroowns the sharedadr_recentflag and has already cleared it (the ADD is not an ADR) by the time this check runs.check_add_ldr_imm_offsetdeliberately keeps flagging ADRP-paired singles: its unsigned-offset rewrite is expressible with the LDST relocations, so those findings mark real toolchain gaps (typically missing alignment metadata on the symbol).
Patterns that look like they should fold and deliberately do not. Each entry is a near-miss of an implemented check; the check's own section describes the sound sibling, this appendix consolidates the counter-arguments so a reviewer wondering "why doesn't armlint flag this?" has one place to look.
fmul+fadd/fsub->fmadd/fmsub/fnmadd/fnmsub(contraction). The fused ops round ONCE:fmaddcomputesround(a*b + c)with the infinitely precise product feeding the add. The two-instruction sequence rounds twice --round(round(a*b) + c)-- and the results differ in the last ulp for well-chosen inputs. Compilers only contract under-ffp-contract=fast(or#pragma STDC FP_CONTRACT ON), an explicit license to change results that a binary-level linter cannot assume. This is the canonical member of the family and the reason theFMUL+FNEGfold spells out why IT is exact:FNMULnegates the already-rounded product, adding no rounding step.fnegof an operand +fmul->fnmul. Negating an operand first computesround(-(a*b));FNMULcomputes-(round(a*b)). Under round-to-nearest these agree (rounding is symmetric), but under the directed modes (FPCR.RMode= toward +inf or -inf) they differ, and armlint cannot know the dynamic rounding mode. Only the result-negating order folds -- seeFMUL+FNEG, whose fixture pins this sibling as a negative.
fcmp+fcsel->fmax/fmin.FMAX/FMINhave their own NaN and signed-zero semantics: a quiet-NaN operand propagates (the result is NaN), andfmax(+0.0, -0.0)is+0.0. The compare-and-select computes something else on exactly those inputs:fcmpwith a NaN sets the unordered flags, sofcsel ..., gttakes the ELSE operand (yielding the NaN's partner, not the NaN), and +/-0.0 compare EQUAL, so the select picks whichever slot the condition maps to, not canonical +0.0. The integer twin has no such trap, which is whyCMP+CSEL->SMAX/SMIN(CSSC) folds and the FP shape never will. (FMAXNM/FMINNMchange the NaN rule but not the +/-0.0 one; no variant matches the select.)
SDIV-based power-of-two remainder ->AND. For unsigned values,dividend - (dividend / 2^N) * 2^Nis a low-bits mask becauseUDIVtruncates and truncation IS flooring there.SDIVtruncates toward zero: for a negative dividend the flooringANDand the truncating remainder disagree (C's%yields -1 for -1 % 4; the mask yields 3). Only the unsigned idiom folds -- see remainder by power of two.sub+ swappedcmp->subs. Subtraction does not commute:cmp x2, x1computesx2 - x1, whose NZCV bear no fixed relation tosubs x0, x1, x2. Only the identical-operand compare folds for the SUB family; the swap is sound solely forADD+CMN, where both spellings compute the identical 65-bit sum.ADD+ swappedCMNwith a shifted operand. The swap argument needs plain registers:Rn + (Rm << s)is notRm + (Rn << s), so only the LSL #0 shifted-register form swaps. The immediate form has no second register and the extended form extends Rm only -- neither has a swapped spelling at all.csinc/csinv/csnegwith equal operands as an "identity". OnlyCSEL Rd, Rn, Rnselects the same value on both branches. The rest of the family computesRn+1,~Rnor-Rnon the else path, so the equal- operand forms are conditional increment/invert/negate (the CINC / CINV / CNEG aliases), not copies.
- Blessing "harmless" flag readers after a flag-CHANGING
rewrite. Whether a later
b.eqcan survive a fold depends on which flags the rewrite preserves, not on the reader looking benign. The zero-CMP -> S-variant fold keeps N and Z bit-identical but changes C and V (cmp #0pins C = 1, V = 0), so its scan admits EQ/NE readers and rejects everything else; the CSSC folds delete the compare outright and set NO flags, so their scan rejects every reader,b.eqincluded. Same machinery, different flag-agreement proofs -- neither is transferable to the other.
LDPwithRt1 == Rt2. CONSTRAINED UNPREDICTABLE, so adjacent same-register LOADS never coalesce; adjacent same-register stores do (STP Rt, Rtis well-defined -- it simply stores the value twice).- The
STRbase slot as ZR. In addressing, register 31 means SP, not the zero register: rewriting a base to "ZR" would silently re-address the store. The MOV #0 fold substitutes only data slots (Rt, ALU operands, select operands), never a base. adr+blr->bl. A callee legitimately receives values in registers -- x8 in particular is the indirect-result (sret) pointer -- so the address register cannot be assumed dead at a call target the way x16/x17 can across a veneer-shapedbr.- Cross-width load + convert.
ldr w8 ; scvtf d0, w8(int32 -> double) has no FP-side twin: there is no in-SIMD cross-width scalar conversion, so only the width-matched pairs fold in the load + convert check.