armlint examines AArch64 machine code to find suboptimal instruction
sequences. For example, building the constant 0x66666666 as
movz w0, #0x6666
movk w0, #0x6666, lsl #16is two instructions where one would do, because 0x66666666 is encodable
as an AArch64 logical (bitmask) immediate:
mov w0, #0x66666666 ; orr w0, wzr, #0x66666666armlint helps compiler writers and assembly authors generate tighter code, and documents corners of the A64 instruction set.
armlint is a peephole analyzer. It decodes each 32-bit A64 instruction
directly from the binary and matches it by mask and value, resolving
aliases (for example MUL is MADD with a zero accumulator) so that
both spellings of a pattern are caught. It then looks for a short window
of adjacent instructions that a shorter or cheaper encoding can replace.
The overriding rule is soundness: armlint emits a finding only when the
rewrite provably preserves the architectural result. For a tool that
suggests code changes, a false positive is the worst failure, so it errs
toward false negatives -- a missed opportunity is cheaper than a wrong
one. Each check documents the exact conditions under which its rewrite is
equivalent; the constraints below are the ones they share, and
analyses.md's appendix
collects the near-miss folds that are deliberately never matched (FP
contraction, fcsel -> fmax, the SDIV remainder, and friends) with
the argument against each.
- Strict adjacency for matching; bounded lookahead for proof. The instructions of a matched pattern must be consecutive -- an unrelated instruction between a producer and its consumer suppresses the finding -- and armlint does not reorder code or match through intervening instructions. Emission, though, is not confined to the pattern window: as the following bullets describe, many findings are held back while a bounded forward scan (16 instructions) walks the fall-through path past the consumer to prove a register or the flags dead, so an instruction well after the pair can also suppress a finding. The scan only gates emission -- it never widens a match, so every reported rewrite still replaces only the adjacent instructions shown.
- Liveness is proved structurally, or by a bounded forward scan. A
producer-into-consumer fold fires when the consumer overwrites the
producer's destination register, proving the intermediate value is
dead. Folds whose saving is a deleted write with no such overwrite
defer instead: a bounded forward scan of the fall-through path must
see the register overwritten before any read or control transfer.
This is how the address folds admit stores and loads into a fresh
register --
add x8, sp, #32 ; str x0, [x8]folds tostr x0, [sp, #0x20]only oncex8provably dies -- and how the producer folds (shift, funnel, extend,MUL/SMULL,NEG,MVN) admit consumers that write a register other than the producer's:lsl w8, w1, #3 ; add w9, w2, w8folds toadd w9, w2, w1, lsl #3under the same proof. The single-bit and CSET branch folds additionally require the folded branch's taken edge to land inside that proven-clean span -- a general-purpose register, unlike NZCV, is routinely live into a branch target, so no block-locality assumption is made for it. - MOV-chain folds verify the constant dies. Folds that absorb a
materialized constant --
MUL/MNEG/UDIVby a constant,MOV+ADD/AND/ORR/EOR/CCMP/FMOV,MOV #0, the register-offset and MOVI-zeroing folds -- report only once the consumer's own overwrite or the forward scan proves the constant register dead. The consumer rewrite itself stays valid regardless. - Flag liveness uses a bounded forward scan. The branch- and
flag-folding checks drop a
CMP/TSTonly after a bounded scan of the fall-through path confirms that no later instruction reads N/C/V before they are overwritten. Every NZCV reader is recognized -- the integer conditionals (B.cond,CSEL/CSINC/...,CCMP/CCMN,ADC/SBC) and the floating-point ones (FCSEL,FCCMP/FCCMPE) -- and any branch off the path whose destination the scan cannot see -- an unconditionalB, or a conditionalCBZ/CBNZ/TBZ/TBNZ(which do not themselves touch NZCV but whose taken target may still observe it) -- ends it conservatively. The scan does not follow the folded branch's own taken edge, so these folds assume N/C/V is dead at every branch target. That holds for compiled code, where the flags are defined within a basic block, but not for hand-written assembly that deliberately keeps a flag live into a branch target. The compare-and-branch fold (-m cmpbr) is the one check that does not make the assumption: its producer is a general two-register compare, which clang's three-way comparator really does read again at the branch target, so it proves the taken edge with a second scan starting there.
Findings are opportunities, not guaranteed speedups: some -- the pre- and post-indexed addressing folds -- are code-size and front-end wins that are backend-neutral. Each check's notes say what its rewrite actually saves.
Each row links to its full description -- mechanics, soundness, and what the rewrite saves -- in analyses.md. Candidate checks not yet implemented live in TODO.md.
| Pattern | Rewrite |
|---|---|
movz/movn + movk (over-long constant) |
bitmask-immediate mov, or minimal movz/movn + movk chain |
lsl/lsr/asr/ror + add/sub/and/orr/eor |
add Rd, Rn, Rm, <shift> #n |
lsl/lsr + shifted orr/eor/add (funnel/rotate) |
extr Rd, Rhi, Rlo, #lsb (or ror when both halves match) |
sxtw/uxtb/sxtb + add/sub |
add Rd, Rn, Wm, sxtw |
cmp/cmn/tst zero-test + b.eq/b.ne (b.hi/b.ls after cmp) |
cbz/cbnz |
cmp/cmn/tst zero-test + b.lt/b.ge/b.mi/b.pl |
tbnz/tbz Rn, #(msb) |
tst #(1<<k) + b.eq/b.ne |
tbz/tbnz Rn, #k |
tst #(1<<k) + cset/csetm |
ubfx/sbfx Rd, Rn, #k, #1 |
single-bit and/ubfx/lsr #31 + cbz/cbnz |
tbz/tbnz Rs, #k |
cset + cbz/cbnz |
b.<cond> / b.<inverse cond> |
cset + eor #1 |
cset <inverse cond> |
cset + neg |
csetm |
br x30 |
ret (engages the return-address predictor) |
| branch to the next instruction | delete (both outcomes fall through; bl excluded) |
lsl + lsr/asr |
ubfx/sbfx/ubfiz/sbfiz |
lsr + and #mask |
ubfx |
and #mask + lsr |
ubfx |
and #mask/uxtb/uxth/uxtw/mov + lsl (or lsr + lsl) |
ubfiz (or clearing and) |
zeroing producer + uxtb/uxth/uxtw/and |
drop the zero-extension |
mov xd, xd |
remove (architectural no-op) |
sign-extending producer + sxtb/sxth/sxtw |
drop the sign-extension |
and/orr/eor/sub/bic/orn/eon with Rs, Rs |
mov / zero / all-ones |
ldr+ldr / str+str (consecutive) |
ldp/stp (integer or FP/SIMD, and ldpsw) |
str wzr+str wzr (consecutive zero stores) |
str/stur xzr |
stp wzr, wzr (W-form) |
str/stur xzr |
movi #0 + vector cmeq/cmge/cmgt (or FP fcm*) |
cmeq/cmge/cmgt/cmle/cmlt Vd, X, #0 (drop the movi) |
and + and/ubfiz + orr (clear/isolate/merge) |
bfxil/bfi |
csel Rd, Rn, Rn, cond |
mov Rd, Rn |
fcsel Vd, Vn, Vn, cond |
fmov Vd, Vn |
add/sub Rd, Rn, #0 |
mov Rd, Rn, or remove |
add/sub #a + add/sub #b (same register) |
one add/sub carrying the sum (or mov) |
adds/subs/ands + cmp #0 + b.eq/b.ne |
drop the redundant cmp/tst |
add/sub/and/bic + cmp #0 + b.eq/b.ne |
adds/subs/ands/bics (drop the cmp/tst) |
sub + cmp / add + cmn of the same operands (either order) |
subs/adds (flag-exact; drop the compare) |
subs/adds + cmp/cmn of its own operands |
drop the compare (flags already set) |
cmp/cmn/tst/ccmp whose flags are overwritten unread |
delete (the compare writes no register) |
cmp #0 + cset/csetm of lt/mi |
lsr/asr Rd, Rn, #(msb) (the sign bit; drop the compare) |
mov #2^N + mul |
lsl, or add Rd, Ra, Ra, lsl #N |
mov #C + mneg |
neg, or shifted neg/sub |
mov #2^N + madd/msub |
add/sub Rd, Ra, Rn, lsl #N |
mov #2^N + udiv |
lsr |
mov #2^N + udiv + msub (remainder) |
and Rd, Rn, #(2^N-1) |
mov #C + add/sub |
add/sub Rd, Rn, #C (sign-crossed add↔sub, cmp↔cmn for #-C) |
mov #C + and/orr/eor/ands or bic/orn/eon/bics |
and/orr/eor/ands Rd, Rn, #C (#~C for the inverting forms) |
mov #C + ccmp/ccmn |
ccmp/ccmn Rn, #C, #nzcv, cond (sign-crossed for #-C) |
mov #1 + csel |
csinc Rd, Rn, wzr, cc (cset when the other operand is ZR) |
mov #-1 + csel |
csinv Rd, Rn, wzr, cc (csetm when the other operand is ZR) |
mov #C + lsl/lsr/asr/ror (register amount) |
immediate-form shift, amount C mod 32/64 |
mov #bits/#C + fmov/scvtf/ucvtf from GPR |
fmov Sd/Dd, #imm |
fmov/scvtf/dup of wzr/xzr (or mov #0 + transfer) |
movi dN, #0 / movi vN.T, #0 |
sxtw/mov w, w + scvtf/ucvtf Xn |
scvtf/ucvtf of Wn |
ldr w8 + scvtf/ucvtf from GPR |
ldr s0 + FP-side convert (no cross-file transfer) |
ldr/ldrsw (literal) of an encodable constant |
mov #imm / fmov #imm8 / movi+mvni for Q (no memory access) |
adr + ldr [x8]/br x16 |
ldr Rt, <literal> / b L |
umov Wd, Vn.s[0] / Xd, Vn.d[0] |
fmov Wd, Sn / Xd, Dn (cheaper port; .h[0] under -m fp16) |
and Xd, Xn, #0xffffffff / ubfx Xd, Xn, #0, #32 |
mov Wd, Wn (a rename on Neoverse rather than an ALU op) |
cmp + csel (max/min shape) |
smax/smin/umax/umin (-m cssc) |
cmp #0 + cneg |
abs (-m cssc) |
rbit + clz |
ctz (-m cssc) |
| NEON popcount round trip | cnt Xd, Xn (-m cssc) |
cmp + b.<cond> |
cb<cond> Rn, Rm/#imm, label (-m cmpbr) |
eor + eor (16B vectors) |
eor3 Vd, Vn, Vm, Va (-m sha3) |
bic + eor (16B vectors) |
bcax Vd, Vn, Vm, Va (-m sha3) |
autiasp/autibsp + ret |
retaa/retab (-m pauth; auto-armed on arm64e) |
unsigned LR spill; raw br/blr; zero-discriminator braaz/blraaz |
audit-only review items (-a pac; auto-armed on arm64e) |
ldxr/stxr fetch-op retry loop |
ldadd/ldset/ldeor/ldclr (+ mvn/neg/mov pre-op) (-m lse) |
ldxr/stxr exchange retry loop |
swp (-m lse) |
ldxr + cmp + b.ne + stxr CAS retry loop |
mov + cas + cmp (-m lse) |
fmul + in-place fneg |
fnmul (bit-exact in every rounding mode) |
mov #0 + str/add/and/csel/ccmp use |
use wzr/xzr |
mov #C + ldr/str [xn, xc] |
ldr/str [xn, #C] (or ldur/stur) |
mul + add/sub |
madd/msub (or mneg) |
smull/umull + add/sub |
smaddl/umaddl/smsubl/umsubl |
neg + add/sub |
sub/add |
neg + csel |
csneg (inverted cond for the then slot) |
mvn + csel |
csinv (inverted cond for the then slot) |
add #1 + csel |
csinc (inverted cond for the then slot) |
mvn + and/orr/eor/ands |
bic/orn/eon/bics |
add + ldr/str [xt] |
ldr/str [xn, xm{, lsl #s}] |
sxtw + ldr/str [xn, xt] |
ldr/str [xn, ws, sxtw {#s}] |
ldrb/ldrh/ldr (or ldrsb/ldrsh Wt) + sxtb/sxth/sxtw |
ldrsb/ldrsh/ldrsw (Xt for the re-widened sign loads) |
add #a + ldr/str [xt] (incl. mov xt, sp) |
ldr/str [xn, #a] / ldr [sp] |
ldr/ldp [xn] + add/sub xn |
ldr [xn], #±imm / ldp [sp], #imm (post-index) |
add/sub xn + ldr/stp [xn] |
ldr [xn, #±imm]! / stp [sp, #-imm]! (pre-index) |
armlint depends on Capstone and uses
pkg-config to locate it. On macOS:
brew install capstoneOn Debian/Ubuntu:
apt install libcapstone-dev pkg-configBuild:
git clone https://github.com/gaul/armlint.git armlint
cd armlint
make allTwo test suites are available. make test runs the unit tests against
fabricated byte sequences, exercising the check registry directly.
make integration-test runs the snapshot suite under fixtures/:
each .s is assembled with clang -arch arm64 and armlint's
output is diffed against a checked-in .expected file. The
integration suite covers the Mach-O parser and the report formatting,
which the unit tests bypass. It needs a clang that can assemble
AArch64 and fails without one rather than reporting a pass it did not
earn; off an arm64 host it names the target explicitly, so an x86-64
Linux box runs the suite too. After an intentional output change,
regenerate the
snapshots with make integration-test-regen and review the diff
before committing.
Setting ARMLINT_LIVENESS_SWEEP=1 extends the unit tests' liveness
cross-checks against Capstone to the entire 2^32 encoding space --
minutes of single-threaded CPU time, so CI runs it on pushes rather
than PRs. Both classifiers are covered off one decode per word: the
NZCV one (classify_liveness) and the register one
(classify_reg_liveness), whose property is that a register Capstone
reports as read must never be classified as dead or as no-effect.
Three Capstone over-reports are documented and skipped; everything
else is a defect on one side or the other, and the register half found
four on its first run. ARMLINT_LIVENESS_SWEEP_THREADS divides the
sweep across that many worker threads:
ARMLINT_LIVENESS_SWEEP=1 ARMLINT_LIVENESS_SWEEP_THREADS="$(sysctl -n hw.ncpu)" \
make test(nproc on Linux.)
Capstone 6 (in alpha as of mid-2026) rewrote the AArch64 module from
LLVM. armlint compiles against it unchanged via Capstone's
compatibility header, and CI tracks the pinned revision below. It is a
commit rather than the newest tag (6.0.0-Alpha10) because that tag
still leaves insn->alias_id stale on non-alias instructions, which
makes the sweep's oracle report a phantom x30 read after every
ret. To reproduce locally:
git init capstone6
git -C capstone6 remote add origin \
https://github.com/capstone-engine/capstone.git
git -C capstone6 fetch --depth 1 origin \
862b59717d54769036f89fd9f780f634e030cf56
git -C capstone6 checkout FETCH_HEAD
cmake -B capstone6/build -S capstone6 -DCMAKE_BUILD_TYPE=Release
cmake --build capstone6/build -j8
make clean # never mix objects built against different Capstone ABIs
make CAPSTONE_CFLAGS="-I$PWD/capstone6/include -DCAPSTONE_AARCH64_COMPAT_HEADER" \
CAPSTONE_LIBS="$PWD/capstone6/build/libcapstone.a" allThe overrides must be make arguments (not environment variables) to
beat the Makefile's pkg-config defaults. The unit tests and the
ARMLINT_LIVENESS_SWEEP=1 sweep pass under both major versions, and
running the sweep under both is the point rather than a
formality: the two model AArch64 register access differently, so each
version corroborates cases the other cannot. Capstone 6's model is the
sharper oracle -- it is what caught armlint treating a FEAT_MOPS main
stage as a kill of its own source pointer -- while 5.x is what armlint
ships against and so is what its corrections are written for;
make integration-test is expected to show a handful of cosmetic
snapshot diffs under v6 (it prints shift/bitfield immediates in
decimal and drops # on adr/ldr-literal operands), so the
fixtures remain pinned to Capstone 5.x rendering until v6 stabilizes.
armlint is intended to be part of compiler test suites which should
#include "armlint.h" and link libarmlint.a. Disassemble the
just-emitted machine code with check_instructions; its return value is
the number of opportunities found, which a test can assert is zero:
#include "armlint.h" // also includes <capstone/capstone.h>
// code/code_len: the AArch64 bytes to check (e.g. a function the
// compiler just emitted); base_addr is the address they load at.
// Returns the opportunity count (0 == clean), or -1 on a decode error.
int lint(const uint8_t *code, size_t code_len, uint64_t base_addr)
{
csh handle;
if (cs_open(CS_ARCH_ARM64, CS_MODE_ARM, &handle) != CS_ERR_OK) {
return -1;
}
cs_option(handle, CS_OPT_DETAIL, CS_OPT_ON);
armlint_summary *summary = armlint_summary_create();
int findings = check_instructions(
handle, code, code_len, base_addr, /*verbose=*/true, summary,
/*features=*/0, // or ARMLINT_FEATURE_CSSC etc.
/*symbols=*/NULL, /*nsymbols=*/0); // see armlint_symbol
armlint_summary_print(summary); // optional by-type tally
armlint_summary_destroy(summary);
cs_close(&handle);
return findings;
}The summary is optional -- pass NULL to skip the by-type tally --
and verbose controls whether each opportunity is printed as it is
found. armlint can also read arbitrary AArch64 binaries (ELF, thin
Mach-O, or universal/fat Mach-O) directly:
./armlint /path/to/aarch64/binary
./armlint /bin/ls
./armlint -m cssc /bin/ls # also suggest CSSC instructionsLinker-synthesized import glue is excluded from both the scan and
the census: Mach-O __stubs, __stub_helper, and __objc_stubs,
and ELF .plt, .iplt, and the .plt.* variants. Their shape is
the dynamic-linking ABI's business, not the compiler's -- the
classic lazy-binding __stub_helper alone would otherwise
contribute one spurious "LDR literal foldable" finding per imported
symbol (each fixed entry LDRs its lazy-bind-info offset into w16
from an inline literal), a couple of hundred lines of noise on a
typical minos < 12 Mach-O binary.
-m <feature> enables checks whose rewrites use ISA-extension
instructions the target must support: cssc (Armv8.9/9.4 Common
Short Sequence Compression: smax/smin/umax/umin, abs,
ctz), lrcpc2 (Armv8.4 unscaled store-release: stlur), pauth
(Armv8.3 pointer authentication: retaa/retab), lse
(Armv8.1 atomics: ldadd/ldset/ldeor/ldclr/swp), cmpbr
(Armv9.6 compare-and-branch: cbgt/cbeq/...), sha3
(Armv8.2 three-operand vector logic: eor3, bcax), and fp16
(FEAT_FP16 half-precision transfers: fmov Wd, Hn). pauth
arms automatically on arm64e slices, whose ABI mandates FEAT_PAuth
(the same auto-arm as the PAC audit); the rest stay opt-in. cssc,
sha3 and fp16 name extensions that are never mandatory at any
architecture version, so those three assert a specific target rather
than a version floor.
-a <audit> enables opt-in informational checks that flag missing
hardening rather than missed folds; pac audits the binary against
the arm64e-style full pointer-authentication contract (return
addresses spilled unsigned, unauthenticated br/blr, and
zero-discriminator braaz/blraaz -- authenticated, but against a
modifier of zero, so any same-key zero-discriminator pointer in the
process substitutes). Audit findings are review items on a ladder:
the raw-br check recognizes and auto-dismisses the clang jump-table
idiom, so what remains is BLRs, linker veneers, and genuinely
unclassified branches; the zero-discriminator rung marks where the
C-ABI IA+0 signing floor could upgrade to __ptrauth-style
diversified braa/blraa. The PAC audit arms automatically on
arm64e slices (whose ABI already assumes full signing), so macOS
system binaries surface their worklist with no flag; a plain arm64
slice never opted in, so it stays silent unless you pass -a pac
explicitly.
By default armlint prints only a summary: the opportunities grouped by type and sorted by prevalence, so it is clear which to look at first, followed by a total and the number of instructions scanned. A large binary can have hundreds of thousands of opportunities, so the per-opportunity detail is suppressed unless requested:
$ ./armlint /bin/ls
Optimization opportunities by type:
38 ADD + LDR foldable to immediate-offset LDR
2 AUTIASP/AUTIBSP + RET foldable to RETAA/RETAB (PAuth)
2 CBZ/CBNZ of a live single-bit test foldable to TBZ/TBNZ
42 optimization opportunities in 4153 instructionsPass -v to also print each opportunity -- its one-line summary plus
the offending instructions, as shown below -- ahead of the summary:
$ ./armlint -v /bin/ls
ADD + LDR foldable to immediate-offset LDR at offset: 0x60 <0x10000071c+0x44>: -> ldr w8, [x8, #0x2c] (2 instructions)
add x8, x8, #0x2c
ldr w8, [x8]
...The <...> names the containing function so findings can be triaged
per function: <_addhistnode+0x58> when the symbol table carries a
name (Mach-O nlist, or the ELF .symtab with a .dynsym
fallback), or
the function's start address as above when a stripped binary's
LC_FUNCTION_STARTS still records its boundaries. A binary carrying
neither -- Go's linker, for one, emits its runtime symtab instead of
either structure -- prints the historic unannotated form.
The process exits non-zero when any opportunity is found, so armlint can gate a compiler test suite.
armlint -i replaces the lint scan with a census: every instruction in
the binary's executable sections, attributed to the FEAT_* group it
requires, each group carrying the architecture version it became
mandatory at -- LSE and CRC32 at Armv8.1, LRCPC/FCMA/JSCVT and the
register-form PAC at 8.3, DotProd/LRCPC2/FlagM at 8.4, up through the
MOPS memcpy instructions at 8.8. The resulting ladder answers "what was
this binary compiled for": a -march=armv8.1-a build shows LSE atomics
woven through every mutex, a baseline build shows LDXR/STXR loops with
LSE only inside runtime-dispatched thunks (glibc's outline atomics).
$ ./armlint -i libc.so.6
ISA census: 275103 instructions, 1 undecodable words skipped
Armv8.0 baseline: 275043
mandatory from Armv8.1: LSE (21)
mandatory from Armv8.3: none
...
optional features: none
branch protection (hint space): BTI (21), PAC (18)
highest mandatory-from level: Armv8.1Three groups never raise the ladder, each for a soundness reason of its
own. Features that never become mandatory in the v8 line (the crypto
extensions, FP16, SVE, MTE) are listed as optional: any of them can
be bolted onto an old target with a single +feature flag, so their
presence says nothing about -march. The hint-space branch-protection
forms (PACIASP/AUTIASP/BTI/XPACLRI) execute as NOPs on cores without
the extension -- -mbranch-protection=standard emits them precisely so
the binary stays v8.0-compatible -- so they get their own line and no
version claim; only the register-form PAC instructions (PACIA, RETAA,
BRAA, ...) evidence a real Armv8.3 target. And an undecodable word is
either data in text or an extension this Capstone build cannot decode,
so the skipped count bounds what the census could have missed.
The census reports presence, not requirement: dispatched fast paths
count even though the binary runs without them. Treat small exotic
tallies in a binary with many skipped words with suspicion -- string
pools embedded in text sometimes decode as valid SVE or atomics -- and
use -v, which prints up to four sample addresses per feature, to
check a surprising tally in a disassembler before believing it.
When the binary carries function boundaries (Mach-O
LC_FUNCTION_STARTS/nlist, ELF .symtab/.dynsym -- the same
sources that symbolize -v findings), the census adds per-function
pac-ret coverage:
pac-ret coverage: 866 of 1113 functions sign the return address
A function counts as signed when its span contains PACIASP/PACIBSP or
the register-form pacia/pacib x30, sp. The ratio is a fingerprint,
not a target: leaf functions never spill the return address and
legitimately never sign, so Apple's fully signed arm64e binaries sit
around 78-85% (zsh 866/1113, ssh 912/1073, sshd 360/430) with the
remainder leaves -- the -a pac audit separately confirms zero
unsigned spills there, which is the claim that matters. The
signature of never opting in looks entirely different: Homebrew's
plain-arm64 libcapstone reads 0 of 1441. The line is omitted when no
boundary information exists (Go binaries carry neither structure).
tools/ holds the research utilities that feed armlint's check
backlog, built separately with make tools:
tools/pairscancounts adjacent-instruction pairs by normalized shape (registers collapsed to classes, immediates to#0/#i) across the executable sections of ELF and Mach-O binaries, surfacing frequent patterns worth a new check.-e SUBSTRprints example sites for shapes matching a substring.tools/defuseprofiles block-local def-to-use distances (how far a value's sole consumer sits from its producer) and multi-instruction redundancies no pair statistic can see: dead definitions, redundant reloads of the same address, re-materialized constants, and zero compares of a value whose producer could have set the flags.tools/shapescan.pycounts a fixed list of specific candidates -- the rows TODO.md tracks -- with their real operand, range and encodability conditions applied, which is what separates a population from a pair count.adrp+addis the standing example: 753,648 adjacent dependent pairs across the corpus, of which 43,434 have a target inside ADR's reach. Runtools/shapescan.py --selftestfirst: it assembles every reference instance with clang and checks each mask in both directions -- that it matches no instruction belonging to another mask, and that it matches every spelling of its own listed inALSO. The second half is the one that matters most, because a mask too narrow to see thestur, theldp q, or the 64-bit form of its shape reports a small number rather than a wrong one, and nothing looks broken.-e SHAPEprints example sites. Needs numpy.tools/addpairscan.pysizes the ADD-immediate +LDP/STPfamily, which one shape mask cannot split honestly: the half whose combined offset fits the pair's signed, pre-scaledimm7folds two instructions into one, while the half that overflows it can only be split into two singles at no size saving. 8,775 against 17,565 across the corpus. Carries its own--selftest; needs numpy.
The workflow that produced several of the current checks: compile a
representative corpus, run pairscan to rank pair shapes, classify
the top shapes as by-design or foldable, then use defuse to decide
whether a candidate needs adjacency only or a liveness window, and
shapescan.py to size the survivor with its real conditions applied
before writing any code. pairscan and defuse lean on Capstone's
register-access model, which mis-reports the compare aliases
(CMP/CMN/TST mark their first operand as a write); defuse
corrects for this, and armlint's own checks decode the raw encodings
precisely to avoid that class of problem. shapescan.py decodes raw
encodings for the same reason, and self-tests them because that is
where its own bugs live: every wrong figure it has produced came from
a mask one bit too loose or a destination modelled as write-only when
the instruction merges into it.
- Arm A-profile A64 Instruction Set Architecture - per-instruction reference, including alias conditions
- Arm Cortex-A optimization guides - per-microarchitecture tuning notes
- Apple Silicon CPU Optimization Guide - Apple M-series tuning notes
- Encoding of immediate values on AArch64 - the bitmask-immediate scheme explained
- Capstone disassembly framework - library to parse instructions
- x86lint - x86-64 equivalent of armlint
Copyright (C) 2026 Andrew Gaul
Licensed under the Apache License, Version 2.0