Metadata-Version: 2.5
Name: asmdiff
Version: 0.4.0
Summary: Per-function assembly inspection and comparison, across a compiler matrix or from a shipped ELF
Project-URL: Homepage, https://github.com/rt-rtos/asmdiff
Project-URL: Repository, https://github.com/rt-rtos/asmdiff
Author: Rasmus Tikkanen
License-Expression: MIT
License-File: LICENSE
Keywords: arm,assembly,cfg,clang,cli,codegen,compiler,devtools,diff,disassembly,dsp,embedded,firmware,gcc,loop-analysis,objdump,riscv,static-analysis,xtensa
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: Compilers
Classifier: Topic :: Software Development :: Debuggers
Requires-Python: >=3.8
Description-Content-Type: text/markdown

# [asmdiff](https://pypi.org/project/asmdiff/) 
## 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.

<details>
	
<summary>Quickstart Example</summary>
	
[![asmdiff comparing gcc and clang assembly for paired C functions, with a fold-vs-libcall summary](demo/quickstart.gif)](demo/quickstart.gif)

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

---

</details>

### 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:

```bash
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

```bash
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:

```zsh
fpath=(~/.zfunc $fpath)
autoload -Uz compinit && compinit
```

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

```bash
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:

```c
/* 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:

```c
/* 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](#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](#example-catching-a-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](#quick-inspect-one-function-no-comparison)). An ELF binary (any name — detected by magic bytes) switches to [ELF input](#elf-input-what-actually-shipped-after-lto) 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](#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](#borrowing-includes-from-compile_commandsjson). |
| `--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](#shaping-the-output-for-reading-vs-deciding)). |
| `--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](#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](#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](#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:

```bash
# 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](#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](#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:

```bash
# 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:

```bash
# 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](#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:

```json
"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:

```toml
# 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:

```bash
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`:

```toml
[groups]
riscv32-esp = ["esp32c3", "esp32c6", "esp32h2", "esp32p4"]
native = ["gcc", "clang"]
```

```bash
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:

```toml
[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.

```toml
[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:

```toml
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:

```bash
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:

```bash
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`](https://clang.llvm.org/docs/JSONCompilationDatabase.html)
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:

```toml
[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

<details>

<summary>Real Project Example</summary>

#### 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](demo/esp32-matrix.gif)](demo/esp32-matrix.gif)

*One source, three real chip toolchains, build flags borrowed
automatically - the kind of comparison that otherwise needs three separate
build trees. (Click to enlarge.)*

---

</details>

When you run asmdiff from inside the project anyway, the path is
redundant. Two opt-ins skip it:

```toml
[esp32s3-idf]
cc = "xtensa-esp32s3-elf-gcc"
flags = ["-O2", "-mlongcalls"]
compile_commands = true      # search instead of naming a path
```

```bash
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:

```bash
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:

```bash
# 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'
```
```bash
# 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:

```bash
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:

```bash
# 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.**

```bash
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.**

```bash
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](#elf-input-what-actually-shipped-after-lto) 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:

```bash
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](https://godbolt.org/) 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.

