Skip to content

Repository files navigation

per-function assembly inspection and comparison, across a compiler matrix or from a shipped ELF

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

asmdiff comparing gcc and clang assembly for paired C functions, with a fold-vs-libcall summary

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.)


Try it yourself:

$ uvx asmdiff / $ pipx run asmdiff

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.

Install

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 pip

For 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).

Shell completion

asmdiff --install-completion        # bash, zsh or fish, taken from $SHELL

This 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 && compinit

To keep everything in one rc file instead, --completion SHELL prints the script to stdout:

eval "$(asmdiff --completion bash)"   # in .bashrc

The 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.

Quick start

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

Example: catching a silent software divide

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.

Quick inspect: one function, no comparison

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.

ELF input: what actually shipped after LTO

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 the loop spans column at a glance: render_lut shipped its 32-instruction body as a ZOL, render_lut_cub fell back to a 74-instruction branch loop.
  • -mlongcalls calls are resolved. An out-of-range call survives linking as l32r 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 raw callx8 a8 and the calls column reports indirect(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 break kills it.
  • A spilled loop counter (register pressure) kills it.
  • Nested loops: only the innermost can be a ZOL.

Command reference

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-errno

Compilers 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.

Shaping the output for reading vs deciding

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-stats are 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-stats follows 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      174
    

    This 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 calls column) — 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 loop and its end label, so the loop machinery contributes zero and any branch counted there is real per-iteration control flow — early exits, per-sample if/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 the calls column already flagged.

  • --cost adds a cost column 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 NAME prices 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 -s

Exit 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 column

--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.

Config file: named targets

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.

Target groups

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.

Cost profiles

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 __divdf3 are 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-iram and 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-stats gives 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.

Bundled ESP profiles

asmdiff.example.toml ships ready-made targets for the common ESP32 devkits, grouped by toolchain family:

  • riscv32-esp — esp32c3, esp32c6, esp32h2, esp32p4. One shared riscv32-esp-elf-gcc binary; 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 unified xtensa-esp-elf toolchain 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, esp32p4

A 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.

Creating and editing a config from the command line

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 table

Borrowing includes from compile_commands.json

A 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../include flags still resolves when asmdiff runs from anywhere.
  • @file response files are expanded. Build systems park flags in them — ESP-IDF v6 hides -specs=picolibc.specs in build/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_commands expands ~ and $VARS. The legend line always prints the resolved compiler command; run with -- -v if you want to see every include path the compiler actually received.
  • --db-includes restricts 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.specs and 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) whose stdio.h would shadow the host's under plain -I; demoted, the project's own headers still resolve and the host's libc wins.

Auto-discovering the database

Real Project Example

All Esp32 Xtensa toolchains in one call from the same compile_commands

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:

asmdiff running a three-chip xtensa matrix (esp32/s2/s3) on one firmware source via -db

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 path
asmdiff biquad.c --compile-commands            # same, for any matrix
asmdiff biquad.c --compile-commands path/to/compile_commands.json

Both 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-commands is 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 = true in 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 no build/ under it gets target [esp32s3]: no compile_commands.json found near the current directory; compiling without borrowed flags on stderr and compiles with the target's own cc/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.

Comparing a modified copy of a project source

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.c

Both 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.

Whole-file summary

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.

Comparing the same function across two builds (--across)

--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_ansi

Here 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.

Worked example: exp2f vs ldexpf in shorepine/AMY sources

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-baseline

src/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.

How it works

  1. Each compiler runs with -S to emit assembly text.
  2. Function bodies are sliced out between the function's label and its .size directive (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.
  3. Instruction counts and outbound calls come from a mnemonic scan covering x86 (call, jmp tail 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-operand jalr, blx r3) are reported as indirect(<reg>).
  4. Loop spans come from label references alone — no mnemonic tables, no control-flow analysis. The next section walks through it.

How a span is found

The parser sees only the cleaned -S text of one function: instructions and local labels, as line positions rather than addresses. Two passes:

  1. Record the position of every local label line (.L2:).
  2. 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.

Interpreting the numbers

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 insns column counts lines of assembly, not time. A call8 __udivdi3 is 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. --cost splits the same body into instruction classes and libcall tiers, so that one __udivdi3 line reads as an int-div call 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 -O3 version of new_rt above is two instructions, but its runtime is still ldexpf'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-stats gives the per-span mix and its nesting depth, and --cost counts the call sites a span holds apart from the rest.

  • Bigger is often faster. Unrolling and vectorization raise every count on purpose - the -Os vs -O3 biquad above is bigger by every number and does far more per iteration. If smaller meant faster, -Os would 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.

Writing good harnesses

  • 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-static so the compiler must emit them standalone.
  • Compile at the flags your project ships with — a construct that folds at -O3 -ffast-math may not fold at plain -O3. Encode them once as a config target and make it the default.
  • Beware of over-synthetic harnesses: a function whose whole body is the construct can tail-call (jmp f) where real surrounding code would call f and continue. Same libcall either way, but instruction counts read differently.

Porting to another project

The tool is one stdlib-only Python 3 file with no imports outside the standard library, and contains no project-specific constants. To port:

  1. Copy this directory (or just asmdiff.py).
  2. Write an asmdiff.toml for the new project's toolchain and flags (start from asmdiff.example.toml) and drop it next to your harnesses, in your working directory, or in ~/.config/.
  3. Run the self-tests: python3 test_asmdiff.py -v (no compiler needed).

Limitations

  • Parses GNU-as ELF assembly (gcc, clang, and GNU cross-compilers targeting ELF). macOS Mach-O asm (_name labels, 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 as indirect(<reg>) (e.g. Xtensa callx8 a10 -> indirect(a10)), which errs toward visibility rather than silence. ELF input narrows this: -mlongcalls sequences 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 #4 immediate has no space after the hash and is untouched. Each side gets half the column budget, so --width widens them; -l list, the single-column listings and --json keep the line as extracted, comment included.

  • Loop spans are layout facts, not loop analysis. Label numbers are compiler-assigned, so a baseline's .L27 and a candidate's .L26 may 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.


Comparison with alternatives

Why not just run objdump by hand?

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.o

Drop 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.o

For 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.

Why not just run gcc -S by hand?

-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.s

2. 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?"

Why not use Godbolt / Compiler Explorer?

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.h and a generated sdkconfig.h, reachable only through dozens of build-computed -I/-D flags. Pasting such a file into Compiler Explorer means hand-inlining that whole header environment; asmdiff borrows it from compile_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. --across over 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.

About

asmdiff — per-function assembly comparison for paired C implementations

Resources

Stars

11 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages