asmdiff is a stdlib only command-line tool for comparing the generated assembly of individual C functions across implementations, compiler flags, compiler versions, and source revisions. It is intended for investigating compiler code generation rather than benchmarking runtime performance.
Quickstart Example
Each function appears twice - old_* and new_*, the same routine
before and after a rewrite - compiled across a gcc/clang matrix and diffed
side by side. The summary's insns and calls columns are the payoff:
old_rt's 9-instruction exp2f call collapses to new_rt's 2-instruction
ldexpf. (Click to enlarge.)
asmdiff answers one question fast: when I rewrite a C construct, what
does the compiler actually emit - before and after? It compiles a small
harness file across a matrix of compilers, extracts each variant function's
assembly, and prints side-by-side listings plus a summary of instruction
counts, loop spans, and outbound calls.
Compilers and flags are configured per project through named targets in an
asmdiff.toml file, and any GNU-as ELF assembly is parsed.
Whether something constant folds or turns into a libcall is a distinction that is invisible in source review and decisive on hot paths.
Any of the standard Python tool installers puts an asmdiff command on
your PATH - the package has no dependencies outside the standard library:
uv tool install asmdiff # uv
pipx install asmdiff # pipx
pip install --user asmdiff # plain pipFor a one-off run without installing anything, uvx asmdiff HARNESS.c
or pipx run asmdiff HARNESS.c.
From a checkout of this repo, pip install -e . installs the command in
editable mode, tracking your working tree. Or skip installation entirely -
the tool is a single stdlib-only file: python3 asmdiff.py HARNESS.c.
Requires Python >= 3.8; asmdiff.toml config files need >= 3.11
(stdlib tomllib).
asmdiff --install-completion # bash, zsh or fish, taken from $SHELLThis writes a completion script into the directory your shell already
autoloads from - bash-completion's completions/,
~/.config/fish/completions/, or ~/.zfunc/ - and never touches an rc
file. A file asmdiff did not write is named and left alone rather than
replaced. zsh reads ~/.zfunc only once it is on fpath, so the command
prints the two lines to add to .zshrc yourself:
fpath=(~/.zfunc $fpath)
autoload -Uz compinit && compinitTo keep everything in one rc file instead, --completion SHELL prints
the script to stdout:
eval "$(asmdiff --completion bash)" # in .bashrcThe flag list is read off the argument parser when the script is
generated, so a script regenerated after an upgrade cannot drift from the
tool. Completing -t offers the targets and groups from the config that
run would resolve - a --config PATH already on the line is honoured -
and a comma list is completed element by element. Each tab press starts
Python and parses the config: fine for a pip, pipx or uv tool
install, noticeably slow behind a cold uvx launcher.
Portability: pure-stdlib Python with nothing intentionally
platform-specific, developed and tested on Linux. On native Windows,
HOME is usually unset, so replace $HOME with $USERPROFILE (or
%USERPROFILE%) in config cc patterns; forward slashes are fine. A
toolchain that doesn't resolve fails with a clean per-target error, and
matrix runs skip unusable compilers rather than aborting.
Write a harness with your two versions as old_* / new_* function pairs:
/* myharness.c */
#include <math.h>
float old_scale(float x) { return x * exp2f(-5); }
float new_scale(float x) { return ldexpf(x, -5); }Run:
$ asmdiff myharness.c
Output:
cc#1: gcc -O3
cc#2: clang -O3
target function role insns loop spans calls
cc#1 old_scale baseline 3 - -
cc#1 new_scale candidate 3 - ldexpf
cc#1 delta 0 - +ldexpf
cc#2 old_scale baseline 2 - -
cc#2 new_scale candidate 2 - ldexpf
cc#2 delta 0 - +ldexpf
== cc#1 ==
old_scale | new_scale
---------------------------------------------+---------------------------------------------
endbr64 | endbr64
mulss .LC0(%rip), %xmm0 | movl $-5, %edi
ret | jmp ldexpf@PLT
== cc#2 ==
old_scale | new_scale
---------------------------------------------+---------------------------------------------
mulss .LCPI0_0(%rip), %xmm0 | movl $-5, %edi
retq | jmp ldexpf@PLT
Every run has the same three parts: a legend naming each row of the
compiler matrix, one table covering the whole matrix, then the listings
grouped under == LABEL ==. The label in the target column is the
config target a row came from; the two fallback compilers here are
unnamed, so they are numbered by their position in the matrix. A single
unnamed row (one --cc) is its own label, and then the legend and the
target column are both dropped - the blocks further down this README
show both shapes.
Read the calls column first: - means the construct lowered to inline
instructions; a symbol name means a libcall. The listings under the table
are the evidence.
The loop spans column reports label:N for every local label that some
instruction branches back to: N instructions lie between the label and the
last backward branch targeting it. Whole-function insns charges loop-hoisting
changes for their one-time setup/writeback code; the span count is the part
that repeats. Which span is your hot loop — and how often it runs — the
listing and your source know, not the tool. A function with more than
six spans shows the first six and a +N more tail, since past that the
column stops being readable and the count is what is left to say;
--json carries every span.
A worked example is included — asmdiff_example.c reproduces the
exp2f/ldexpf analysis for both constant and runtime shift amounts:
$ asmdiff asmdiff_example.c
Timestamps on embedded targets are 64-bit microsecond counts, and a
32-bit MCU has no 64-bit divide instruction: divide before narrowing and
the compiler emits a call to a software divide routine; narrow before
dividing and a constant divisor becomes an inline multiply-high. The two
functions below differ only in which side of the division the cast sits
on. Both compile clean under -Wall -Wextra -Wconversion: no compiler
diagnostic reports that one of them contains a runtime library call.
The calls column does:
/* elapsed.c — timestamps are 64-bit µs; the delta fits in 32 bits */
#include <stdint.h>
uint32_t old_elapsed_ms(uint64_t now, uint64_t then) {
return (uint32_t)((now - then) / 1000);
}
uint32_t new_elapsed_ms(uint64_t now, uint64_t then) {
return (uint32_t)(now - then) / 1000;
}$ asmdiff elapsed.c --cc 'xtensa-esp32s3-elf-gcc -O2 -mlongcalls'
function role insns loop spans calls
old_elapsed_ms baseline 10 - __udivdi3
new_elapsed_ms candidate 6 - -
delta -4 - -__udivdi3
old_elapsed_ms | new_elapsed_ms
---------------------------------------------+---------------------------------------------
entry sp, 32 | entry sp, 32
saltu a11, a2, a4 | l32r a8, .LC0
sub a3, a3, a5 | sub a2, a2, a4
sub a10, a2, a4 | muluh a2, a2, a8
movi a12, 0x3e8 | srli a2, a2, 6
movi.n a13, 0 | retw.n
sub a11, a3, a11 |
call8 __udivdi3 |
mov.n a2, a10 |
retw.n |
One --cc row is its own label, so this run prints neither a legend nor
a target column and the listing needs no == LABEL == header.
The candidate is six inline instructions ending in a multiply-high by
the reciprocal constant in .LC0. The baseline hands the division to
__udivdi3 - a software divide loop whose cost the visible 10
instructions do not include. (The narrowing must of course be valid;
here the delta is known to fit 32 bits.)
Run the same file through host gcc and both columns are call-free, 8 vs 5 instructions: x86-64 divides 64-bit integers in hardware, so the host matrix reports nothing worth fixing. The mistake only exists at the target. That is why the built-in gcc/clang matrix is only a fallback, and why named cross-compiler targets (below) exist.
To just look at what a compiler emits for one function, name it after the file - no harness, no pairing:
$ asmdiff src/oscillators.c render_lut
With one usable compiler you get a stats row and the function's
listing under it; with exactly two (the default gcc + clang matrix)
the two listings are set side by side; with a bigger matrix the table
covers the whole matrix and each row's listing follows under its own
== LABEL ==. -l list / -l side-by-side forces a presentation.
Several function names can be given at once.
A bare name is inspected as a function; an argument that exists on disk is a second source file, and a path-looking argument that does not exist is an error - a mistyped filename is never silently searched for as a symbol.
With a single cross-compiler, inspecting a buffer-scaling loop looks like this:
$ asmdiff dsp_util.c apply_gain --cc 'xtensa-esp32s3-elf-gcc -O2 -mlongcalls'
function insns loop spans calls
apply_gain 13 .L3_LEND:4 -
apply_gain:
entry sp, 32
wfr f1, a4
blti a3, 1, .L1
slli a8, a3, 2
addi a8, a8, -4
srli a8, a8, 2
addi.n a8, a8, 1
loop a8, .L3_LEND
.L3:
lsi f0, a2, 0
mul.s f0, f0, f1
ssi f0, a2, 0
addi.n a2, a2, 4
.L3_LEND:
.L1:
retw.n
The span .L3_LEND:4 is the four-instruction body of the Xtensa
zero-overhead loop (loop a8, .L3_LEND: hardware repetition, no branch;
see How a span is found): the part that runs per
element, as opposed to the whole-function count of 13.
Everything above compiles one translation unit and reads its -S
output. That answers "what does the compiler do to this function" - but
under -flto it cannot answer "what did the linked firmware do to
it". LTO happily inlines a whole FX pipeline into its caller: the
functions still exist in your source, but the binary has no symbols for
them, and their loops live - transformed - inside someone else's body.
Whether a loop actually shipped as an Xtensa zero-overhead loop is a
property of the ELF, not of any single .c file.
So an ELF positional (detected by magic bytes, not file name) switches to disassembly: no compiling, no matrix - the binary is the finished answer. Name functions after it, or sweep by regex:
$ asmdiff build/S3-Amysynth.elf render_lut # listing + stats
$ asmdiff build/S3-Amysynth.elf --filter '^render_' # stats table only
function insns loop spans calls
render_lut_cub 98 .L32:74 -
render_lut 51 .L7e_LEND:32 -
render_external_audio_in 32 .L52_LEND:10 -
render_partial 112 .L66:31 exp2f, __divsf3, ...
(Four of the eleven matched rows shown.) The columns are the same as everywhere else; two things are ELF-specific:
- Labels are synthesized from addresses. A linked binary has branch
targets, not labels, so in-function targets are rewritten to
.L<hex-offset>labels (the offset from the function start - stable across rebuilds of an unchanged function, unlike link addresses). An Xtensa zero-overhead loop's end address becomes.L<off>_LEND, so a hardware loop is distinguishable from a branch loop in theloop spanscolumn at a glance:render_lutshipped its 32-instruction body as a ZOL,render_lut_cubfell back to a 74-instruction branch loop. -mlongcallscalls are resolved. An out-of-range call survives linking asl32r a8, <lit>+callx8 a8, which has no callee name of its own. When the disassembly annotates the literal with its value (l32r a8, ... (40002274 <__divsf3>)), the real callee is reported instead. When the evidence is missing or the register is overwritten in between, the listing keeps the rawcallx8 a8and thecallscolumn reportsindirect(a8)- a genuine function-pointer dispatch still reads as indirect, without the register name masquerading as a symbol.- Nearest-symbol noise is stripped. objdump names every address
after the closest preceding symbol, so a literal pool word renders
as e.g.
l32r a8, <_stext+0x44> (<some_font_table+0x707ad9>)- two unrelated symbols decorating an address and a constant. Offset-form annotations of foreign symbols become raw hex (l32r a8, 0x40370100 (0x3f8f5f80)); bare-symbol annotations (a real callee or object start) and offsets into the current function itself are kept.
What this looks like on real firmware: this binary's sequencer path -
here in the vendored AMY synth engine (components/amy/src/sequencer.c,
with the divide sourced from amy_sysclock() in api.c) - surfaces
three findings in one row scan.
$ asmdiff build/S3-Amysynth.elf --filter 'sequencer_(process_tick|recompute|timer_callback)'
function insns loop spans calls
sequencer_timer_callback$lto_priv$0 77 .L21:41 __divsf3, __udivdi3
sequencer_recompute 46 - __extendsfdf2, __divdf3, __muldf3, __fixunsdfsi, __divsf3
sequencer_process_tick$lto_priv$0 117 .L2e:96 .L48:85 .L8d:6 xQueueSemaphoreTake, xQueueGenericSend, add_delta_to_queue, indirect(a8)
A timer callback pays a software float divide and a 64-bit
__udivdi3 inside its 41-instruction loop - the
silent software divide
pattern, caught in shipped firmware instead of a harness. asmdiff was
built to hunt exactly this in AMY, and this callback was the payoff: the
pairing was confirmed as both a codegen cost and an upstream correctness
bug - amy_sysclock()'s uint32_t sample count wraps far sooner than
its comment claims and loses precision on long uptimes - and is fixed
locally with a PR pending to shorepine/amy.
sequencer_recompute's __extendsfdf2 -> __divdf3 -> __muldf3 chain
is the double-promotion smell (typically an unsuffixed 60.0-style
literal) on a chip whose FPU is single-precision only. And the
$lto_priv$0 suffixes show LTO renaming the survivors, which is why
--filter matters: you can't name symbols you don't know exist. The
lone indirect(a8) is a genuine function-pointer dispatch, reported
as such rather than guessed at.
Functions LTO inlined away don't table at all, and the error says so usefully:
$ asmdiff build/S3-Amysynth.elf arp_collect_down
error: function(s) not in build/S3-Amysynth.elf: arp_collect_down; close matches: arp_collect_up$lto_priv$0, arp_core_init, heap_bubble_down
arp_collect_down still exists in source; the binary holds its body
inlined inside its one caller.
The disassembler is the toolchain's own objdump, derived from the
first gcc in the matrix by swapping the trailing gcc for objdump -
so --target esp32s3 (or a config default) finds
xtensa-esp32s3-elf-objdump exactly the way it finds the compiler,
glob patterns included. --objdump PATH overrides. A host objdump
handed a cross ELF fails with file format not recognized; that's the
cue to pass a matching target.
A whole firmware has over a thousand functions, so a bare ELF with no
function names and no --filter is an error, not a thousand-row table.
If the question behind the sweep is "did my hot loops ship as
zero-overhead loops", the empirical rules (verified on esp-gcc 15.2,
-O2) are worth knowing before blaming the compiler:
- One call surviving to codegen anywhere in the loop body kills ZOL - even behind a runtime condition that never fires.
- An early
breakkills it. - A spilled loop counter (register pressure) kills it.
- Nested loops: only the innermost can be a ZOL.
asmdiff SOURCE.c [SOURCE2.c | FUNC...] [--pair OLD:NEW]... [--across FUNC]...
[--target NAME]... [--cc 'CC FLAGS']... [--config PATH]
[--compile-commands [PATH]] [--flags-like PATH] [--db-includes]
[--filter REGEX] [--summary-only] [--collapse] [--span-stats]
[--cost] [--costs NAME] [--fail-on-growth] [--json] [--width N]
[--layout list|side-by-side] [-v] [-- EXTRA_FLAGS...]
asmdiff FIRMWARE.elf [FUNC...] [--filter REGEX] [--objdump PATH]
[-l list] [--summary-only] [--span-stats] [--cost] [--costs NAME]
[--json] [--width N]
asmdiff --edit-config | --example-config | --list-targets | --version
asmdiff --completion bash|zsh|fish | --install-completion [SHELL]
| Option | Meaning |
|---|---|
SOURCE.c |
C file to compile — a purpose-built harness or a real project source. A second file may be given: with --across to compare a function, without it for a whole-file A/B summary. Bare names after the file are functions to inspect (see Quick inspect). An ELF binary (any name — detected by magic bytes) switches to ELF input instead of compiling. |
-p, --pair OLD:NEW |
Compare two different functions within one compilation. Repeatable. Default: every old_X is auto-paired with its new_X; with no pairs at all, the whole-file summary is printed instead. |
-a, --across FUNC |
Compare the same function across two compilations (see below). Repeatable. Mutually exclusive with --pair. |
-l, --layout list|side-by-side |
Force the inspect-mode presentation instead of the adaptive default (1 usable compiler lists, 2 go side by side, more list). With ELF input, list also prints --filter matches' listings. |
-f, --filter REGEX |
Also analyze every function whose name matches REGEX (re.search) — sweep a subsystem, or reach compiler-generated clones ($constprop$0, .isra.0), without naming each function. In compile modes matches are full peers of named functions (lenient when a match exists under only part of the matrix) and narrow the whole-file summary; in ELF mode matches appear in the stats table but are not listed in full — add -l list to print their listings too (a trailing note reminds you when listings were withheld). Not combinable with --pair/--across. |
--objdump PATH |
Disassembler for ELF input. Default: derived from the first gcc in the matrix by swapping the trailing gcc for objdump, so --target/config globs locate it like they locate the compiler. |
--cc 'CC FLAGS' |
One compiler invocation, command and flags in a single quoted string. Repeatable to build a matrix. |
-t, --target NAME |
A named target from the config file, resolved to a --cc entry. NAME may also be a [groups] entry, a comma-separated list, or a glob over target names (-t 'esp32c*'). Repeatable; appended to the matrix after --cc entries. See Target groups. |
--config PATH |
Config file to use. Default search: asmdiff.toml next to SOURCE.c, then in the current directory, then ~/.config/. First hit wins. |
--list-targets |
Print the resolved config's default, groups, cost profiles (name and measured_on), and targets (name and cc), then exit. No source file needed. |
-db, --compile-commands [PATH] |
Borrow each source's include/define flags from a compile_commands.json; with no PATH, walk up from the CWD checking each directory and its build/ until the repository root. See below. |
--flags-like PATH |
A source with no compile_commands entry borrows the flags recorded for PATH — the way to compare a modified copy of a project source under its original's header environment. |
--db-includes |
Borrow only the header-search paths from the database, dropping its defines, forced includes, and -specs/--sysroot; kept paths are re-emitted as -idirafter so they cannot shadow the host's own system headers. This is how a host target compiles a cross project's source. |
-s, --summary-only |
Print only the summary/stats tables, suppressing every assembly listing (see Shaping the output). |
--json |
Emit the summary as JSON on stdout instead of tables — one record per function per compiler, each carrying the target label of the matrix row it came from. Implies --summary-only; errors stay plain text on stderr. |
-C, --collapse |
In side-by-side listings, omit runs of identical line pairs, keeping 3 lines of context around each difference. |
--width N |
Column budget for tables and side-by-side listings. Default: the terminal's width, else $COLUMNS, else 120. 0 is unlimited, leaving the callee column untrimmed for a script to grep; a negative N is a usage error. |
--span-stats |
Follow the stats table with a per-loop-span instruction mix: nesting depth and load/store/mul/div/branch/other counts per span. |
--cost |
Add a cost column to the stats tables: the instruction classes of each function and the tier of every call site (softfp, softfp-div, int-div, libm, mem, call), with the sites a loop span holds counted apart. Counts only; a score appears when a profile prices them. See Cost column. |
--costs NAME |
Price the cost column with the config's [costs.NAME] profile on every matrix row, --cc rows and ELF input included; implies --cost. Overrides a costs = "NAME" the target names. See Cost profiles. |
--fail-on-growth |
Exit 3, naming each offender on stderr, if any candidate has more instructions than its baseline; exit 0 otherwise. Needs paired functions (--pair, auto-paired old_X/new_X, or --across). |
--completion bash|zsh|fish |
Print a completion script for that shell on stdout and exit. Its flag list is read off the argument parser at generation time, so it cannot drift from the tool. See Shell completion. |
--install-completion [SHELL] |
Write that script to the shell's own user completion directory and exit; no rc file is touched. SHELL defaults to the basename of $SHELL. An existing file is replaced only if asmdiff wrote it. |
--version |
Print the version and exit. |
-v, --verbose |
On compile failure, print the full compiler command and complete error output. Default shows only the compiler, the source, and the first error lines. |
-- FLAGS... |
Everything after a bare -- is appended to every compiler invocation. |
With no --cc and no --target, the config file's top-level
default target(s) are used; without a config file, plain gcc -O3 and
clang -O3. The tool's own advice applies: compile at the flags your
project ships with — put them in a target.
Examples:
# Explicit pairs, default compilers
asmdiff h.c --pair biquad_v1:biquad_v2 --pair svf_v1:svf_v2
# Cross-compilers: quote command and flags together
asmdiff h.c --cc 'xtensa-esp32s3-elf-gcc -O2 -mlongcalls' \
--cc 'riscv32-esp-elf-gcc -O2'
# Try a flag variant across the whole default matrix
asmdiff h.c -- -fno-math-errnoCompilers missing from PATH are skipped with a warning; the run fails only
if none are usable, and that error names each missed binary
(no-such-gcc: not found on PATH) so the fix is in the message, not in a
warning that scrolled away. A row whose compile fails is dropped the same
way: the remaining rows are compiled, their table and listings print, and
the failing row's compiler output follows on stderr under its label
(error: [esp32s3] xtensa-esp32s3-elf-gcc failed on biquad.c) once the
run has printed everything it could. One broken target no longer costs
you the others; the run still exits 1.
| Status | Meaning |
|---|---|
| 0 | The run printed what was asked for. Differing assembly is the expected result, never an error. |
| 1 | The tool failed: a compile error, an unknown --pair name, a config that does not load, no usable compiler in the matrix. |
| 2 | argparse's usage error - an unknown flag, a missing value, a negative --width. |
| 3 | --fail-on-growth found a candidate with more instructions than its baseline. |
Every run leads with the table: the legend, one summary table for the
whole matrix, the --span-stats and provenance lines that belong to it,
and then the listings, grouped per target under == LABEL ==. A
two-file compare over a three-target matrix still prints three full
side-by-side listings under that table, roughly 100 KB of them when the
table was all the decision needed. Four flags cut the output to purpose:
-
--summary-only(-s) keeps only the summary/stats tables. This is the scripted/agent view: the table is the decision input, and a listing is pulled with a second, narrower run only when a delta needs explaining. What is left is the legend, the table, and its footer. -
--collapse(-C) elides runs of identical line pairs in every side-by-side listing, keeping 3 lines of context around each difference plus a... N identical lines ...marker. Two ~500-instruction functions differing by five instructions render as a few readable hunks instead of ~1000 lines. Two sides that are equal line for line collapse to their header with(identical)on the left title and no body at all.What it cannot collapse is the case where the two sides differ on every line without differing in substance: a changed register allocation, a reordered but equivalent schedule, one extra spill slot shifting every offset. There is no identical run to elide, so the listing prints in full. The summary table and
--span-statsare the reading unit there: the instruction classes, the calls, and the per-span mix say what moved, where a line-by-line read of two differently allocated bodies does not. -
--span-statsfollows the stats table with a per-loop-span instruction mix, one row per span:function span depth insns load store mul div branch other stereo_reverb .L108 0 327 96 31 14 0 12 174This weighs the span rather than the whole function — the number that actually decides a hot-loop comparison (is the rewrite trading loads for stores? did the multiplies move?) without hand-counting a listing and accidentally tallying past the loop end into the epilogue. depth is how many other spans of the same function contain this one, so an inner loop is told from the loop around it without reading the listing (How a span is found). Buckets are by mnemonic (Xtensa, RISC-V, ARM) with an AT&T memory-operand heuristic for x86; branch means any control transfer — conditional and unconditional branches, calls, and returns (outbound calls are already itemised by name in the
callscolumn) — and anything the tables don't know lands in other. div is a hardware divide or square root; a division done by a libcall (__divsf3,__udivdi3) is a call, and the Xtensa FPU divide sequence counts as the several instructions it is.Reading branch inside a span: a software loop's own backedge is one of them, because the span runs from the label to the last backward branch inclusive. A Xtensa zero-overhead loop instead encloses only the body between
loopand its end label, so the loop machinery contributes zero and any branch counted there is real per-iteration control flow — early exits, per-sampleif/else, wrap-around checks. Track it across variants: branches replaced by conditional moves (movnez,csel,cmov— counted under other) show as branch falling while insns stays roughly flat, and a call appearing inside a hot span is the ZOL killer thecallscolumn already flagged. -
--costadds acostcolumn to the stats tables saying what each function is made of: the same instruction classes over the whole body, and the tier of every call site with the sites a loop span holds counted apart.--costs NAMEprices those counts with a measured profile from the config. Both are described under Cost column below.
All four combine with every compile mode; --summary-only,
--span-stats, --cost and --costs also apply to ELF input.
Tables and side-by-side listings take their width from the terminal,
falling back off one to $COLUMNS and then to 120 columns; --width N
sets the budget directly, and --width 0 means unlimited, which is the
untrimmed callee column a script greps.
For scripted callers, --json replaces the tables entirely with one
JSON document on stdout: a flat results list holding one record per
function per compiler — target (the matrix row's label, the same
string the table's target column prints), cc, tag (source label
in two-file runs), role (baseline/candidate in paired runs),
insns, loop_spans (each {label, insns, depth}), calls, delta
(on candidate records), cost when --cost or --costs is given, and
span_stats when --span-stats is given. An ELF run compiles nothing,
so its records have no matrix row to name and carry neither target
nor cc. Flat records keep it one jq expression away from any
question the tables answer.
The pairing questions the tool answers itself, because the pairing is
its own: every summary table closes a pair with a delta row (signed
instruction count, per-span before -> after, callees gained with +
and lost with -, and under --cost the classes and tiers that
moved), each candidate record carries the same as a delta object,
and --fail-on-growth makes it an exit status:
# Fail the job when a rewrite that was meant to shrink did not
asmdiff old.c new.c -a stereo_reverb -t esp32s3 --fail-on-growth -sExit 3 and one growth: FUNC +N insns (BASELINE -> CANDIDATE) line per
offender on stderr if any candidate has more instructions than its
baseline, exit 0 otherwise; status 1 is left meaning the tool itself
failed and 2 is argparse's usage error, so a job can tell "grew" from
"did not compile" from "misused the flags".
Arbitrary selection stays jq's: the tool prints JSON on stdout, so a
plain | jq pipe is the integration, and jq does that filtering
better than a wrapper flag could. A few recipes:
# Instruction count per function per side, as a table
asmdiff old.c new.c --json \
| jq -r '.results[] | "\(.tag)\t\(.function)\t\(.insns)"'
# Only the functions that emit libcalls (soft-float, __divdi3, memcpy…)
asmdiff old.c new.c --json \
| jq '.results[] | select(.calls | length > 0) | {function, tag, calls}'
# The full record for one hot function, spans and all
asmdiff old.c new.c -a stereo_reverb --json --span-stats \
| jq '.results[] | {role, insns, spans: .loop_spans}'
# Functions reaching a soft-float helper from inside a loop
asmdiff old.c new.c --json --cost \
| jq '.results[] | select(.cost.tiers_in_loop.softfp > 0)
| {function, tag, in_loop: .cost.tiers_in_loop}'The top level carries asmdiff (version) and mode
(pairs/across/inspect/summary/elf; ELF runs add the binary's
path as elf). Warnings and errors stay plain text on stderr, so a
failed run never emits half a document.
--cost adds a cost column before calls, saying what each function
is made of rather than how many lines long it is:
$ asmdiff asmdiff_example.c --cost -s --cc 'gcc -O2'
function role insns loop spans cost calls
old_const baseline 3 - mul:1 | br:1 | oth:1 -
new_const candidate 3 - br:1 | oth:2 | libm:1 ldexpf
delta 0 - mul:-1 | oth:+1 | libm:+1 +ldexpf
old_rt baseline 9 - st:1 | mul:1 | br:2 | oth:5 | libm:1 exp2f
new_rt candidate 2 - br:1 | oth:1 | libm:1 ldexpf
delta -7 - st:-1 | mul:-1 | br:-1 | oth:-4 +ldexpf -exp2f
Instructions fall into the six classes --span-stats uses, abbreviated
here (ld, st, mul, div, br, oth). Every call site is
additionally tiered by the callee's name:
| Tier | Callees |
|---|---|
softfp |
soft-float add, subtract, multiply, compare, convert: __addsf3, __muldf3, __floatsidf, __aeabi_dmul, __aeabi_i2d |
softfp-div |
soft-float divide: __divsf3, __divdf3, __aeabi_fdiv, __aeabi_ddiv |
int-div |
integer divide and modulo helpers: __udivdi3, __moddi3, __aeabi_idivmod, __aeabi_uldivmod |
libm |
the math families, float and double: sqrtf, sin, exp2f, ldexpf, pow, fmod |
mem |
memcpy, memset, memmove, memcmp |
call |
everything else, indirect(<reg>) included |
The libgcc and the ARM EABI spelling of one helper land in the same
tier, so a __muldf3 target and a __aeabi_dmul target read alike. A
name no pattern knows lands in call, where the calls column already
spells it out.
Each tier is followed by how many of its call sites a loop span holds -
softfp:3 (2 in loop) - since a soft-float helper reached once per
iteration is a different finding from one on an error path. The counts
are per call site, not per distinct callee: two calls to __muldf3
count twice, where the calls column names it once.
The delta row's cost cell lists only what moved. A tier whose count
came through the rewrite unchanged is absent from it even when the
callee changed: old_rt and new_rt above each hold one libm call,
so the cell says nothing about libm while the calls column carries
the swap as +ldexpf -exp2f. The two cells are read together.
With a profile (--costs NAME, or a target's costs = "NAME"), the
cell opens with a score and the number of instructions and call sites
no weight covered, and the table is followed by the line saying where
the weights came from:
score 399 (1 unweighted) | ld:40 | st:12 | mul:14 | ...
...
costs: esp32s3-iram - ESP32-S3 rev 0.2, code in IRAM, esp-15.2.0 libgcc; esp_cpu_get_cycle_count harness, median of 1000 runs
The weights behind that score are the commented template in
asmdiff --example-config, not a measurement; no measured profile
ships with the tool, and Cost profiles below is how
one is written. The delta row leads with the signed score. A cell is
capped at 56 characters and closes with ... when the mix does not fit
(as it does here), so the table stays aligned; --json carries all of
it:
"cost": {
"classes": {"load": 40, "store": 12, "mul": 14, "div": 1,
"branch": 9, "other": 61},
"tiers": {"softfp": 3, "call": 1},
"tiers_in_loop": {"softfp": 2},
"score": 399,
"unweighted": 1,
"profile": {"name": "esp32s3-iram",
"measured_on": "ESP32-S3 rev 0.2, code in IRAM, ...",
"method": "esp_cpu_get_cycle_count harness, ..."}
}classes always carries all six keys. tiers and tiers_in_loop list
only what the function reaches, so .cost.tiers.softfp on a function
with no soft-float is null rather than 0 - // 0 in the jq
expression if you want a number. Without a profile, score and
profile are null and unweighted is every instruction and call
site the function has. Candidate records also gain delta.cost
(classes, tiers, score), again signed and again only what moved.
ELF input takes --cost and --costs like a compile run does. A
target's costs = "NAME" is not consulted there: nothing is compiled,
so no matrix row stands behind the binary's instructions. Name the
profile with --costs.
Retyping a cross-compiler path and ten flags per run is the enemy of actually
looking at assembly. A TOML config (stdlib tomllib, Python ≥ 3.11) names
each compiler+flags combination once:
# asmdiff.toml — next to your harnesses, in CWD, or in ~/.config/
default = "esp32s3" # target(s) used when no --cc/--target is given
[esp32s3] # production-like ESP32-S3 codegen
cc = "$HOME/.espressif/tools/xtensa-esp-elf/esp-*/xtensa-esp-elf/bin/xtensa-esp32s3-elf-gcc"
flags = [
"-O2", "-DMY_FEATURE", "-DNDEBUG",
"-Wno-strict-aliasing", "-mlongcalls",
"-I$HOME/project/components/dsp/include",
]
[host] # same defines on host gcc
cc = "gcc"
flags = ["-O2", "-DMY_FEATURE", "-I$HOME/project/components/dsp/include"]cc values expand ~ and $VARS and may be glob patterns, so a config
survives toolchain upgrades (esp-14 → esp-15) without editing. A
pattern matching several installed toolchains resolves to the highest
version-sorted one — numerically, so esp-15 beats esp-9 — and the
choice is printed to stderr; the legend line above the table always
shows the fully resolved command that actually ran, so the short
target label never hides which binary it was. No match is an error. Pin
the exact directory instead when reproducibility matters more than
convenience. Flags expand $VARS only (no globbing).
A target is exactly a saved --cc entry — nothing else changes. Useful
shapes:
asmdiff h.c # config default target(s)
asmdiff h.c -t esp32s3 -t host # two-target matrix
asmdiff h.c -t esp32s3,host # same, as a comma list
asmdiff h.c --across f -t esp32s3 --cc 'gcc -O2' # mix freely
asmdiff --list-targets # what does my config define?A config placed next to your harness files travels with them: any invocation naming a source in that directory finds it, from any CWD.
default takes whatever -t takes: a target name, a [groups] name, a
comma list, a glob over target names, or an array mixing those
(default = ["native", "esp32s3"]). A name it cannot resolve is the
same error -t would give. One idiom worth knowing is the one-member
group: [groups] ship = ["esp32s3"] with default = "ship" gives the
standing matrix a name of its own, so widening the run later is editing
the group rather than every default and script that mentions the
target.
The included asmdiff.example.toml is a starting point. If a flag or
include path must vary per machine, that's what per-machine config files
are for — nothing lives in the tool.
A [groups] table names matrices of targets so a whole family runs
from one -t, without editing default:
[groups]
riscv32-esp = ["esp32c3", "esp32c6", "esp32h2", "esp32p4"]
native = ["gcc", "clang"]asmdiff h.c -t riscv32-esp # four RISC-V targets
asmdiff h.c -t native -t esp32s3 # a group plus a single target
asmdiff h.c -t 'esp32c*' # glob over target names (quote it)A -t value is resolved as an exact target name first, then as a group
name, then as a glob (*, ?, [...]) over target names in config
order. A group naming an undefined target, or an empty group, is an
error. --list-targets prints what the resolved config defines.
A [costs.NAME] table holds measured weights and where they were
measured. --cost alone counts; with a profile in hand it also scores:
[costs.esp32s3-iram]
measured_on = "ESP32-S3 rev 0.2, code in IRAM, esp-15.2.0 libgcc"
method = "esp_cpu_get_cycle_count harness, median of 1000 runs"
other = 1
load = 2
store = 1
mul = 2
div = 20
branch = 2
softfp = 60
softfp-div = 200
int-div = 40
"__muldf3" = 90
[esp32s3]
cc = "..."
flags = ["-O2", "-mlongcalls"]
costs = "esp32s3-iram"A key's value decides what it is. A number is a weight, keyed by an
instruction class (load, store, mul, div, branch, other),
a libcall tier (softfp, softfp-div, int-div, libm), or a call
symbol in quotes ("__muldf3" = 90). A string is provenance:
measured_on and method are required and the load fails naming the
one that is missing, and any further string key is kept and printed
after them. Anything else is an error naming the key. mem and call
are refused a weight, since a memcpy's cost is its size argument and
an unknown callee's is its body, neither of which the assembly shows;
both stay counted and land in the unweighted total. extends = "OTHER"
copies another profile's weights before this table's own apply, one
level deep - a chain is an error.
Weights resolve in this order. An instruction costs its class weight, or counts as unweighted. A call site costs its callee's own weight where the profile names that symbol, else its tier's weight, else it counts as unweighted. The score is therefore always printed with the number of instructions and call sites it left out. It is an integer while every weight is; one fractional weight anywhere rounds the total to a decimal.
Which profile a row uses: --costs NAME sets it for every matrix row
and is the only route for a --cc row, which has no target to read one
from; otherwise each target brings the one its costs = "NAME" names,
so a two-target matrix scores each target under its own. A name that
does not resolve fails before the first compile. --list-targets
prints the profiles a config defines, with their measured_on.
What a score claims, and what it does not:
- A weight is a static issue cost. Dual issue, pipeline stalls,
cache and flash-fetch latency, and the operand-dependence of helpers
like
__divdf3are not in it. Two scores that differ by a few percent say nothing. - Scores compare within one target. Cycle costs differ per core,
per memory placement, and per libgcc build, so a score under
esp32s3-iramand a score under some other profile are two different units. That is why the provenance line prints under every scored table. - Depth is reported, not multiplied. A span at depth 2 scores like
one at depth 0; the trip count is not in the assembly.
--span-statsgives the depth and the per-span mix, and the weighing is yours.
No measured profile ships with the tool. asmdiff --example-config
carries the template above as comments, with the fields a reader needs
in order to judge how approximate the numbers are.
asmdiff.example.toml ships ready-made targets for the common ESP32
devkits, grouped by toolchain family:
- riscv32-esp —
esp32c3,esp32c6,esp32h2,esp32p4. One sharedriscv32-esp-elf-gccbinary; the targets differ only in-march/-mabi(P4 is the only one with an FPU, so it uses the hard-float ABI). - xtensa-esp —
esp32,esp32s2,esp32s3. The unifiedxtensa-esp-elftoolchain ships one gcc binary per chip.
[esp32c3]
cc = "$HOME/.espressif/tools/riscv32-esp-elf/esp-*/riscv32-esp-elf/bin/riscv32-esp-elf-gcc"
flags = ["-O2", "-march=rv32imc_zicsr_zifencei", "-mabi=ilp32"]
[esp32c6]
cc = "$HOME/.espressif/tools/riscv32-esp-elf/esp-*/riscv32-esp-elf/bin/riscv32-esp-elf-gcc"
flags = ["-O2", "-march=rv32imac_zicsr_zifencei", "-mabi=ilp32"]
# ... esp32h2, esp32p4A profile is nothing more than a curated group of targets — the esp-*
glob finds the toolchains idf_tools.py install left in ~/.espressif
(the newest, by the version-sort rule above, when several are
installed). The example config defines each profile as a [groups]
entry, so -t riscv32-esp or -t xtensa-esp runs it as one matrix and
-t esp runs every ESP chip; set default to a group's list to make it
the no-argument matrix:
default = ["esp32c3", "esp32c6", "esp32h2", "esp32p4"]The bundled flags are the minimal arch selection (-O2 plus
-march/-mabi or -mlongcalls); append whatever your project ships
with (-DNDEBUG, -Os, include paths, ...).
Two more basic profiles cover non-ESP boards: stm32
(arm-none-eabi-gcc, Cortex-M4 by default — adjust -mcpu to your
family) and rp2350 (riscv64-unknown-elf-gcc targeting the Hazard3
cores in RISC-V mode). These toolchains have no single well-known
install location, so the bundled entries use bare binary names: the
compiler must be on PATH, or edit cc to a full path.
The example config is embedded in the tool itself, so a pip/uvx install never needs this repository:
asmdiff --edit-config # open the global config in $VISUAL/$EDITOR
asmdiff --example-config # print the example config to stdout--edit-config opens ~/.config/asmdiff.toml — or the file named with
--config PATH — in $VISUAL, then $EDITOR (notepad as the last
resort on Windows). A missing file is first created from the embedded
example, so a fresh global config starts fully documented, ESP profiles
included. After the editor exits, the result is checked as TOML and a
parse error is reported as a warning, without failing the command.
--example-config prints the same content for redirection or
cherry-picking targets into an existing config:
uvx asmdiff --example-config > ~/.config/asmdiff.toml # bootstrap
uvx asmdiff --example-config | less # copy a tableA real project source rarely compiles with a handful of -I flags. An
ESP-IDF component pulls in freertos/FreeRTOS.h, esp_* headers, and a
generated sdkconfig.h, reachable only through the dozens of -I/-D
flags the build system computes — none of which live in the source file.
That is why asmdiff component.c fails with freertos/FreeRTOS.h: No such file or directory: not a wrong compiler (the xtensa toolchain ships
no FreeRTOS either), just missing include paths. Transcribing them by hand
is miserable.
So don't. Any build that uses CMake or Ninja can emit a
compile_commands.json
recording the exact flags for every source it builds (ESP-IDF writes one to
build/compile_commands.json on every idf.py build). Point a target at it:
[esp32s3-idf]
cc = "xtensa-esp32s3-elf-gcc"
flags = ["-O2", "-mlongcalls"]
compile_commands = "$HOME/myproject/build/compile_commands.json"
Now asmdiff $HOME/myproject/components/dsp/biquad.c --target esp32s3-idf
finds that file's entry in the database and adds the include/define flags
it recorded — -I, -isystem, -iquote, -idirafter, -include,
-imacros, -D, -U, plus the header-environment driver flags -specs
and --sysroot — to this target's command. Everything else the
database records (its own compiler, -O/-std/-W flags, -c, -o, the
source) is ignored: you own the compiler and optimisation flags via
cc/flags; only the header environment is borrowed. That split is the
point — you can now sweep your -O/-m variations over a source that
only ever compiled one way under the build system.
Details that make it robust:
- Paths are made absolute against each entry's
directory, so a database full of build-relative-I../includeflags still resolves when asmdiff runs from anywhere. @fileresponse files are expanded. Build systems park flags in them — ESP-IDF v6 hides-specs=picolibc.specsinbuild/toolchain/cflags, and without it every libc header breaks — so the flag scan reads them (nested ones too) instead of skipping the token. A bare specs name is left for the compiler's own search directories; a specs path resolves like any other recorded path.- Per source file. The lookup keys on the source you pass (matched by
resolved absolute path), so two files in an
--across/summary run each get their own recorded flags. - Absent source is an error, not a silent empty flag set — otherwise you'd just hit the missing-header failure this feature exists to prevent. The message flags a same-basename entry recorded under a different path.
compile_commandsexpands~and$VARS. The legend line always prints the resolved compiler command; run with-- -vif you want to see every include path the compiler actually received.--db-includesrestricts the borrow to header-search paths, dropping-D/-U, forced includes, and-specs/--sysroot. Use it when the target doesn't match the arch the database was built for — host gcc given an ESP-IDF database would otherwise inherit-specs=picolibc.specsand cross-only defines it cannot compile with, yet still needs the project's include paths to find its headers. The kept paths are re-emitted as-idirafter, searched after the compiler's own system directories: ESP-IDF's include set contains a libc-overlay directory (esp_libc/platform_include) whosestdio.hwould shadow the host's under plain-I; demoted, the project's own headers still resolve and the host's libc wins.
Real Project Example
Below, -db (short for --compile-commands) does this on a real ESP-IDF
synth firmware: one synth_core effects source, compiled across the
esp32 / esp32s2 / esp32s3 cross-toolchains in a single run, with each
chip's include/define flags auto-borrowed from build/compile_commands.json
so the component actually compiles. No --target and no path are given -
the config's default names the three chips and -db walks up to find the
database. Reading it left to right: the three == banners are the three
toolchains; each table is that chip's per-function insns / loop spans /
calls. The tell is in the calls column - the esp32-S2 (the one variant
with no hardware FPU) picks up softfloat conversion libcalls
__floatsisf, __floatunsisf, __ltsf2 that its FPU-equipped siblings
never emit, so the same source is measurably heavier there:
One source, three real chip toolchains, build flags borrowed automatically - the kind of comparison that otherwise needs three separate build trees. (Click to enlarge.)
When you run asmdiff from inside the project anyway, the path is redundant. Two opt-ins skip it:
[esp32s3-idf]
cc = "xtensa-esp32s3-elf-gcc"
flags = ["-O2", "-mlongcalls"]
compile_commands = true # search instead of naming a pathasmdiff biquad.c --compile-commands # same, for any matrix
asmdiff biquad.c --compile-commands path/to/compile_commands.jsonBoth walk up from the current directory, checking each level for
compile_commands.json and then build/compile_commands.json (where
CMake and idf.py leave it) — first hit wins. The walk stops at the
repository root (the first directory with a .git), so running from any
depth of component directory finds the project database, but an unrelated
one further up the filesystem is never picked up. It is never on by
default — a target without compile_commands and no
--compile-commands flag borrows nothing.
The two opt-ins differ in what happens when the walk finds nothing:
- A bare
--compile-commandsis an error. You asked for a database in this run and there is none; compiling anyway would silently drop the flags you came for. compile_commands = truein a target is a note. The config describes where that target is usually compiled, not what this run is. A harness file compiled from a directory with nobuild/under it getstarget [esp32s3]: no compile_commands.json found near the current directory; compiling without borrowed flagson stderr and compiles with the target's owncc/flags, which is what a standalone harness wants.
compile_commands = "PATH" naming a database that is not there stays an
error under both: a path that does not resolve is a typo, not a context.
The precedence is what you'd hope: a target that names its own
compile_commands path always keeps it; --compile-commands (with or
without a path) fills in every other matrix entry, including --cc
strings and the built-in gcc/clang fallback.
One behavioral difference: with a discovered database, a source that has
no entry is compiled without borrowed flags after a one-line stderr note,
instead of being an error. That keeps standalone harness files working
when a build/ directory happens to sit nearby; an explicitly named
database still treats an absent source as the error it is.
If a two-file comparison ends up with borrowed flags on only one side,
the mismatched column is tagged [no db entry] and a warning is printed:
the two sides then differ in header configuration — defines, include
paths — not just source, and byte-identical code can compile to visibly
different assembly. Don't read codegen meaning into such a diff.
The before/after workflow — copy oscillators.c to osc_tweak.c, change
one thing, --across them — is exactly the situation above: the copy has
no database entry. --flags-like names the entry the copy should borrow:
asmdiff src/oscillators.c src/osc_tweak.c --across render_lut \
--target s3 --flags-like src/oscillators.cBoth sides now compile under the same recorded header environment, so the
diff is your edit and nothing else. This also covers a git-worktree
baseline (../baseline/src/oscillators.c), which is the same file under a
path the database has never heard of. The absent-source error and the
soft-miss note both point at this flag when a same-name entry exists.
To contrast the same pair on a host compiler (does gcc-on-x86 make the
same choice?), add --db-includes: the borrow then carries only the
project's include paths, which are target-portable, and none of the
cross-only defines or -specs a host gcc would choke on.
With no --pair, no --across, and no old_*/new_* functions to
auto-pair, the tool prints what it parsed instead of erroring: every
function's counts plus a file total. With two files, a leading file
column tells the two apart inside the one table:
$ asmdiff old/delay.c new/delay.c
file function insns loop spans calls
old/delay.c stereo_reverb 437 .L108:327 -
old/delay.c ...
old/delay.c TOTAL (13 functions) 956 - malloc_caps, free, ...
new/delay.c ...
new/delay.c TOTAL (13 functions) 1028 - malloc_caps, free, ...
Over a matrix of several targets a target column leads the file one,
so the whole run is still one table.
The TOTAL row is a coarse sanity check — did this refactor move the file's weight, did a call appear that shouldn't have? It sums parsed function bodies only (no literal pools, data, or alignment), so it is not a size measurement, and per-function rows are where the real information is.
Only labels the assembler types as functions are listed — global data
(string constants, state structs, lookup tables) gets column-0 labels too
but is not code. A calls list longer than 8 symbols is truncated to
..., ... (N total) (the leading ... marks the elision, so the
marker never reads as one more callee); real firmware dispatch
functions call dozens of distinct symbols and would otherwise make
rows thousands of characters wide. Rows are additionally trimmed to the
column budget - the terminal, else $COLUMNS, else 120: callees are
dropped from the end of the calls column (never the first one) behind
the same ... (N total) marker, so every row stays on one line.
--width 0 turns the trim off and keeps the full capped list, which is
the shape to pipe into a grep.
--pair needs both variants to coexist in one compilation. Real changes
usually don't look like that: the "old" and "new" versions are the same
function under different flags, defines, or file revisions. --across FUNC
covers both shapes:
One file, two (or more) --cc entries — flag/define variants. The first
entry is the baseline; each later entry is compared against it:
# Did dropping fixed-point change the biquad's codegen?
asmdiff src/filters.c --across dsps_biquad_f32_ansi \
--cc 'gcc -O3 -DMY_FIXED_CONFIG' --cc 'gcc -O3'
# gcc vs clang on the same function
asmdiff src/filters.c --across dsps_biquad_f32_ansi \
--cc 'gcc -O3' --cc 'clang -O3'# Size vs Performance Optimizations
asmdiff src/filters.c --across dsps_biquad_f32_ansi \
--cc 'gcc -Os' --cc 'gcc -O3'
cc#1: gcc -Os
cc#2: gcc -O3
function role insns loop spans calls
dsps_biquad_f32_ansi [cc#1] baseline 59 .L27:32 SMULR6
dsps_biquad_f32_ansi [cc#2] candidate 89 .L26:54 -
delta +30 32 -> 54 -SMULR6
== cc#1 vs cc#2 ==
dsps_biquad_f32_ansi [cc#1] | dsps_biquad_f32_ansi [cc#2]
---------------------------------------------+---------------------------------------------
endbr64 | endbr64
movl (%r8), %r11d | movdqu (%r8), %xmm0
movl 8(%r8), %r10d | pushq %r13
pushq %r15 | pushq %r12
xorl %r9d, %r9d | pshufd $255, %xmm0, %xmm1
[... 10 rows omitted ...]
.L27: | movd %xmm1, %r12d
cmpl %r9d, %r12d | movd %xmm0, %r11d
jle .L30 | movq %rsi, %r9
movl (%rbx,%r9,4), %r14d | leaq (%rdi,%rdx,4), %rbx
movl (%rcx), %edi | movq %rdi, %rsi
movl %r14d, %esi | jmp .L25
call SMULR6 | .L26:
movl 4(%rcx), %edi | movl %eax, %r10d
movl %r11d, %esi | movl %edi, %r11d
movl %eax, %edx | .L25:
call SMULR6 | movl 4(%rcx), %eax
movl 8(%rcx), %edi | movl (%rsi), %edi
movl %r15d, %esi | addl $1024, %r12d
movl %r11d, %r15d | addl $1024, %ebp
addl %eax, %edx | sarl $11, %r12d
movl %r14d, %r11d | sarl $11, %ebp
call SMULR6 | leal 1024(%rax), %edx
[... 60 rows omitted ...]
(The listing is abridged here; the tool prints all 92 rows. The columns
describe, they don't rank: here -O3 is bigger by every count, and only
the listing shows why — SMULR6 inlined into the loop body, vector setup
around it. Whether that trade is good is your call.)
The output prints a legend mapping cc#N tags to the full compiler
invocations, then the table, then one listing section per
baseline/candidate pairing. Runnable against the bundled example file:
$ asmdiff asmdiff_example.c --across new_rt --cc 'gcc -O0' --cc 'gcc -O3'
cc#1: gcc -O0
cc#2: gcc -O3
function role insns loop spans calls
new_rt [cc#1] baseline 13 - ldexpf
new_rt [cc#2] candidate 2 - ldexpf
delta -11 - -
== cc#1 vs cc#2 ==
new_rt [cc#1] | new_rt [cc#2]
---------------------------------------------+---------------------------------------------
endbr64 | endbr64
pushq %rbp | jmp ldexpf@PLT
movq %rsp, %rbp |
subq $16, %rsp |
movss %xmm0, -4(%rbp) |
movl %edi, -8(%rbp) |
movl -8(%rbp), %edx |
movl -4(%rbp), %eax |
movl %edx, %edi |
movd %eax, %xmm0 |
call ldexpf@PLT |
leave |
ret |
Two files — before/after versions of a source file (e.g. from a git
worktree, a branch checkout, or a patched copy). Every compiler in the
matrix lands in the one table under its own target label, and each
gets its own listing section:
git worktree add ../baseline main
asmdiff ../baseline/src/filters.c src/filters.c \
--across dsps_biquad_f32_ansiHere the tags in the output are the two file paths (shortened to their
distinct suffix) instead of cc#N — the worked example in the next section
shows a full result of this shape.
Because C quote-includes (#include "amy.h") resolve relative to the
including file first, each tree picks up its own headers automatically —
so a change made in a header (a macro, a typedef) is compared by pointing
--across at any .c file that uses it, without touching that .c file.
Suppose the proposal is to change AMY's float-mode shift macros in
src/amy_fixedpoint.h from (s) * exp2f(b) to ldexpf((s), (b)). No
harness needed — compare the real functions the macros expand into:
# 1. A pristine baseline tree (any ref works)
git worktree add ../amy-baseline HEAD
# 2. The macros in question only exist in the float build, so enable it in
# BOTH trees: comment out `#define AMY_USE_FIXEDPOINT` in src/amy.h
# (it is hardcoded there).
# 3. In the working tree only, apply the candidate change in
# src/amy_fixedpoint.h:
# #define SHIFTR(s, b) ldexpf((s), -(b))
# #define SHIFTL(s, b) ldexpf((s), (b))
# 4. Compare real functions containing both kinds of shift site:
asmdiff ../amy-baseline/src/log2_exp2.c src/log2_exp2.c \
--across exp2_lut --across log2_lut --cc 'gcc -O3 -Wall'
# 5. Clean up
git worktree remove ../amy-baselinesrc/log2_exp2.c is a good probe because it contains both site kinds:
exp2_lut shifts by a runtime amount, log2_lut by constants.
The summary makes the trade-off immediate:
function role insns calls
exp2_lut [amy-baseline/log2_exp2.c] baseline 65 exp2f
exp2_lut [amy/log2_exp2.c] candidate 59 ldexpf
delta -6 +ldexpf -exp2f
log2_lut [amy-baseline/log2_exp2.c] baseline 58 -
log2_lut [amy/log2_exp2.c] candidate 64 ldexpf
delta +6 +ldexpf
The runtime site improves (a leaner libcall replaces exp2f + multiply),
but the constant site regresses: baseline log2_lut had no calls —
exp2f(±1) folds to a multiply — while the candidate now pays a ldexpf
libcall inside its normalisation loop. Any other .c file whose hot
functions use the macros (filters.c, oscillators.c, delay.c) can be
probed the same way.
- Each compiler runs with
-Sto emit assembly text. - Function bodies are sliced out between the function's label and its
.sizedirective (or the next function label). CFI/section/alignment directives, comments, and compiler bracketing labels are stripped; instructions and meaningful local labels (loop targets) are kept. - Instruction counts and outbound calls come from a mnemonic scan covering
x86 (
call,jmptail calls), ARM (bl,blx), RISC-V (call,tail,jal), and Xtensa (call0/4/8/12,callx*,j). Local-label branches and register-indirect x86 jumps are not counted as calls. Register-indirect call mnemonics (callx8 a8, single-operandjalr,blx r3) are reported asindirect(<reg>). - Loop spans come from label references alone — no mnemonic tables, no control-flow analysis. The next section walks through it.
The parser sees only the cleaned -S text of one function: instructions
and local labels, as line positions rather than addresses. Two passes:
- Record the position of every local label line (
.L2:). - Scan each instruction's operands for label-shaped tokens (
.L…). A token counts only if that label exists inside this function body. That one rule filters out literal-pool references —mulss .LC0(%rip),l32r a8, .LC44— because.LC*labels are emitted in data sections outside the body and are never in the label map.
An instruction that references a label above itself is a backward
branch, whatever its mnemonic (jne, bne, bnez.n, jnz — the tool
never needs to know). The span runs from the label to the last such
branch, inclusive:
.L2: ─┐
addl $1, %eax │
cmpl $8, %eax │ span ".L2:3"
jne .L2 ─┘ backward reference
ret outside the span
Several back-edges to one label (a continue plus the loop bottom) merge
into that label's single span. Nested labels report separately — the
outer span simply contains the inner one. Forward references (loop exits
like jle .L24) are ignored.
Containment is what the depth column of --span-stats reports: a
span's depth is the number of other spans of the same function that
start at or before it and end at or after it. An outermost loop is
depth 0, a loop nested in it 1, and a span at depth 1 runs its body
once per iteration of the span at depth 0. Two labels covering exactly
the same lines (one loop body reached by two edges) contain each other
under no reading, so both keep the depth of whatever encloses them.
Depth is a nesting fact and nothing more - the trip count is not in the
assembly, so nothing in the tool multiplies a count by it.
The one arch-specific case is Xtensa zero-overhead loops, where the
hardware — not a branch — repeats the body, and the loop instruction
names its end label, forward:
loopgt a3, .L5 runs once; not part of the span
addi.n a2, a2, 1 ─┐
s32i.n a2, a4, 0 ─┘ span ".L5:2"
.L5:
retw.n
That is the entire mechanism. There is no CFG, no trip count, and no
notion of "the" loop: a backward goto produces a span exactly like a
for loop, and an unrolled loop's span is the unrolled body. The column
states where the compiler laid out a repeatable region — nothing more.
The tool prints no verdicts. It reports facts; whether a libcall on that path - or an instruction inside a span rather than outside it - matters is your judgment. If you are new to reading assembly diffs, the misreadings to avoid are few and predictable:
-
Instructions are not cycles. The
insnscolumn counts lines of assembly, not time. Acall8 __udivdi3is one line and hundreds of cycles; an integer divide costs many adds; one cache-missing load can cost more than the rest of the function. Treat the count as a size and structure fact - a call appearing, a loop body growing, a softfloat sequence materializing - and measure time on the target.--costsplits the same body into instruction classes and libcall tiers, so that one__udivdi3line reads as anint-divcall instead of as one more instruction; a[costs.NAME]profile prices those counts in measured cycles, which is still a static issue cost and still not a measurement of your program. -
The cost of a call is in the callee. The listing shows only the call site. The
-O3version ofnew_rtabove is two instructions, but its runtime is stillldexpf's. Ask what work moved, not what line count shrank. -
Weigh the span, not the function. One instruction added inside a loop that runs per sample outweighs twenty added to setup code. The whole-function count charges both the same;
--span-statsgives the per-span mix and its nesting depth, and--costcounts the call sites a span holds apart from the rest. -
Bigger is often faster. Unrolling and vectorization raise every count on purpose - the
-Osvs-O3biquad above is bigger by every number and does far more per iteration. If smaller meant faster,-Oswould be called-O3. -
Only compare like environments. Same flags, same header configuration on both sides; take the
[no db entry]warning seriously. A diff between two configurations describes the configurations, not your edit.
None of this should discourage looking - the opposite. A run costs a
second, so codegen questions that used to be settled by folklore
("everyone knows X is faster") can just be answered: sweep the -O
levels, append -fno-math-errno after --, try the other compiler,
read what actually came out. When the listing surprises you, that is
the tool doing its job.
- Give variants runtime arguments for anything that is runtime in the real code, and literals for anything that is compile-time constant there. The fold-vs-libcall answer depends on exactly this.
- Keep functions non-
staticso the compiler must emit them standalone. - Compile at the flags your project ships with — a construct that folds
at
-O3 -ffast-mathmay not fold at plain-O3. Encode them once as a config target and make it thedefault. - Beware of over-synthetic harnesses: a function whose whole body is the
construct can tail-call (
jmp f) where real surrounding code wouldcall fand continue. Same libcall either way, but instruction counts read differently.
The tool is one stdlib-only Python 3 file with no imports outside the standard library, and contains no project-specific constants. To port:
- Copy this directory (or just
asmdiff.py). - Write an
asmdiff.tomlfor the new project's toolchain and flags (start fromasmdiff.example.toml) and drop it next to your harnesses, in your working directory, or in~/.config/. - Run the self-tests:
python3 test_asmdiff.py -v(no compiler needed).
-
Parses GNU-as ELF assembly (
gcc,clang, and GNU cross-compilers targeting ELF). macOS Mach-O asm (_namelabels, no.size) is not supported — on a Mac, compare inside a Linux container or with a cross-toolchain. -
Call detection is a mnemonic heuristic. Register-indirect calls through a loaded address (other than x86
jmp *reg) are reported asindirect(<reg>)(e.g. Xtensacallx8 a10->indirect(a10)), which errs toward visibility rather than silence. ELF input narrows this:-mlongcallssequences are resolved to their real callee when objdump's literal annotation names one; genuine function-pointer dispatch still reads as indirect. -
Side-by-side columns truncate long instruction lines to keep pairs aligned, and drop a trailing assembler comment (clang's
# TAILCALL,# 8-byte Reload) before truncating, so the operands survive at half a terminal's width. An ARM#4immediate has no space after the hash and is untouched. Each side gets half the column budget, so--widthwidens them;-l list, the single-column listings and--jsonkeep the line as extracted, comment included. -
Loop spans are layout facts, not loop analysis. Label numbers are compiler-assigned, so a baseline's
.L27and a candidate's.L26may or may not be "the same" loop — match them through the listing, not by name. Unrolled or versioned loops (common at-O3) appear as several spans or as one large span; the tool reports what it sees and does not reassemble them into a source-level loop.
The two commands above (steps 4–5) replace a manual workflow with real
friction at every step. Walking through it end to end on a single,
one-sided example — did x * exp2f(-5) fold to a multiply, or did
ldexpf(x, n) become a libcall — shows where the effort goes.
1. Compile to an object, remembering every project flag by hand.
gcc -O3 -Wall -Wno-strict-aliasing -Wextra -Wno-unused-parameter \
-Wpointer-arith -Wno-float-conversion -Wno-missing-declarations \
-DAMY_WAVETABLE -Isrc -c src/log2_exp2.c -o /tmp/candidate.oDrop one flag (say -Wno-float-conversion) and nothing errors — the build
just quietly takes a different codegen path, and the comparison you're
about to make is invalid without telling you so. Repeat this for the
baseline tree with its own -I, and again for every extra compiler you
want in the matrix.
2. Disassemble the function out of the object.
objdump -dr --no-show-raw-insn -M no-aliases /tmp/candidate.oFor a libcall site (ldexpf(x, n) with a runtime n), the real output is:
0000000000000000 <g>:
0: endbr64
4: jmp 9 <g+0x9>
5: R_X86_64_PLT32 ldexpf-0x4
The call target isn't in the instruction — jmp 9 <g+0x9> points at an
unresolved stub inside the same function. The actual symbol, ldexpf, only
shows up in the relocation line underneath, and you have to know to cross-
reference it by hand. Compare that to gcc -S, which prints the symbol
inline because it hasn't been through a linker/relocation step yet:
g:
endbr64
jmp ldexpf@PLT
That's why asmdiff compiles with -S instead of going through objdump on
a linked object — the thing you're looking for (is this a libcall, and to
what) is already text, not a relocation entry you have to decode.
(When the question is about the linked binary — did LTO inline this,
did the loop ship as a ZOL — -S can't answer it, and driving objdump
by hand is this same pipeline with more steps. That case is what
ELF input automates:
address-to-label rewriting, longcall resolution, the same analyzers.)
3. Strip the noise objdump adds that -S doesn't. Every instruction
line carries a leading address and (unless --no-show-raw-insn is passed)
raw opcode bytes; there's a file format elf64-x86-64 banner, a
Disassembly of section .text: header, and an address-annotated function
label instead of a bare one. None of it is informative for a codegen diff,
all of it has to be deleted by hand before two functions are readable
side by side — and it has to be deleted from every file in the
comparison, four of them for the two-function/two-tree case above.
4. Diff the cleaned pair. diff -y --width=100 old.txt new.txt aligns
by content match, not position — once the two versions diverge even
slightly it starts pairing unrelated lines, and it has no header row to
label which side is which. asmdiff prints its own aligned columns
(side_by_side()) with the two function names as headers, and never loses
the pairing because it doesn't try to align by content — it just walks
both lists in lockstep.
5. Count instructions and classify calls by hand. Grep for call/jmp
in the cleaned text, then manually exclude the ones that are really local
branches (jmp 4011a0 <exp2_lut+0x40>) rather than calls to another
symbol — the exact distinction CALL_RE in asmdiff.py encodes once so
you don't re-derive it per function. Then hand-build a table from four
separate counts.
6. Do all of the above again per compiler. asmdiff's default matrix is gcc and clang; by hand that's every step above, twice.
For the full worked example — two functions, two trees, one compiler —
the manual version is roughly: 2 compiles (with hand-retyped flags) → 4
objdump/relocation-lookup passes → noise-stripped by hand on 4 files →
2 diff -y runs that don't survive drift → manual instruction counts and
call classification on 4 files → a hand-assembled summary table. The
asmdiff version is the one command already shown above. Neither
workflow can skip understanding why the two functions differ — that part
is still your judgment — but everything upstream of that judgment, where a
dropped flag or a misread relocation silently invalidates the comparison,
is what the tool removes.
-S output sidesteps the relocation-decoding problem above — call targets
are already symbolic text, no PLT stub to resolve. That removes step 2 of
the objdump workflow. It does not remove the rest.
1. Compile to text instead of an object — same flags, same risk of a silently dropped one:
gcc -O3 -Wall -Wno-strict-aliasing -Wextra -Wno-unused-parameter \
-Wpointer-arith -Wno-float-conversion -Wno-missing-declarations \
-DAMY_WAVETABLE -Isrc -S src/log2_exp2.c -o /tmp/log2_exp2.s2. Find where the function starts and ends in the .s file. The real
output for exp2_lut in this repo (current build, AMY_USE_FIXEDPOINT
on):
exp2_lut:
.LFB71:
.cfi_startproc
endbr64
movl %edi, %edx
leaq 2+exp2_fxpt_lutable(%rip), %rcx
...
ret
.cfi_endproc
.LFE71:
.size exp2_lut, .-exp2_lut
There's no objdump-style address column to strip, but you still have to
find the boundary by hand: the function starts at a column-0 label
(exp2_lut:, not .LFB71: — that's a bracketing label, not the function),
and ends at its .size directive — which only gcc reliably emits; on a
compiler that doesn't, you'd fall back to "next function label", which is
exactly the two-case rule extract_functions() implements once instead of
you re-deriving it per file.
3. Strip compiler furniture — but not indiscriminately. .cfi_*,
.LFB/.LFE bracket labels, and .p2align carry no information. A local
.L-numbered label sometimes does, though, and you can't tell which
without reading the body. log2_lut in the same file:
log2_lut:
.LFB70:
.cfi_startproc
endbr64
xorl %eax, %eax
cmpl $8388607, %edi
jg .L9
.p2align 4,,10
.p2align 3
.L3:
addl %edi, %edi
subl $1, %eax
cmpl $8388607, %edi
jle .L3
cmpl $16777215, %edi
jle .L11
.p2align 4,,10
.p2align 3
.L5:
sarl %edi
addl $1, %eax
.L9:
cmpl $16777215, %edi
jg .L5
.L11:
...
.L3, .L5, .L9, .L11 are live loop targets — jg .L9 and jle .L3
jump to them. A quick-and-dirty cleanup pass like grep -v '^\.' (strip
every line starting with a dot) deletes those labels along with the
.p2align noise sitting right next to them, and now the function has
dangling jumps to labels that no longer exist — silently wrong, not an
error. The correct rule is "drop this specific set of directives and this
specific set of bracketing labels, keep everything else" — which is a
narrower, easier-to-get-wrong rule than it looks, and it's what NOISE
and NOISE_LABEL encode once in asmdiff.py instead of per file.
4. Everything downstream is unchanged from the objdump case: pair the
two cleaned functions up for reading, count instructions, classify
call/jmp lines as libcalls vs. local branches, repeat per function,
per file, per compiler, and assemble a summary table by hand.
So -S over objdump buys back exactly one step — the call target is
already a name, not a relocation to look up — and leaves the rest of the
manual pipeline (locate, strip correctly, pair, count, classify, tally,
multiplied by every function/tree/compiler in the matrix) in place. That
remaining pipeline is extract_functions(), analyze(),
side_by_side(), and summary_table() in asmdiff.py — written once,
instead of re-derived by hand every time someone wants to answer "did this
still fold?"
For a self-contained snippet, Compiler Explorer is simply the better tool, and asmdiff is not trying to compete with it: instant feedback as you type, a huge hosted matrix of compilers and versions, source-to-asm line highlighting, shareable links. "What does this construct compile to, across compilers?" is a Compiler Explorer question - answer it there.
asmdiff exists for the questions that stop fitting a browser textbox:
- Real project sources. An ESP-IDF component includes
freertos/FreeRTOS.hand a generatedsdkconfig.h, reachable only through dozens of build-computed-I/-Dflags. Pasting such a file into Compiler Explorer means hand-inlining that whole header environment; asmdiff borrows it fromcompile_commands.json(see above). - Your exact toolchain. Codegen conclusions only hold at the compiler
build and flags the project actually ships with - the pinned cross-gcc
under
~/.espressif, its specs file, your project's configuration headers - not the nearest version a website happens to host. - Comparisons across revisions.
--acrossover a git worktree diffs one function between two states of a tree, each side resolving its own headers. There is no textbox equivalent of "this function, before and after this commit". - Terminal-native and offline. A one-line command next to the code, scriptable and repeatable in CI, with nothing uploaded anywhere - which also matters for source you can't paste into a public website.
So the intended scope is the awkward middle ground: more automation than
driving objdump or -S by hand (the two sections above), more
project-awareness than a snippet playground. For exploring what compilers
do to an isolated construct, keep using Compiler Explorer; when the
question involves your tree, your toolchain, and your flags, that is what
asmdiff is for.

