asmdiff 0.2.0__py3-none-any.whl → 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: asmdiff
3
- Version: 0.2.0
3
+ Version: 0.3.3
4
4
  Summary: Per-function assembly inspection and comparison, across a compiler matrix or from a shipped ELF
5
5
  Project-URL: Homepage, https://github.com/rt-rtos/asmdiff
6
6
  Project-URL: Repository, https://github.com/rt-rtos/asmdiff
@@ -358,9 +358,11 @@ zero-overhead loops", the empirical rules (verified on esp-gcc 15.2,
358
358
  ```
359
359
  asmdiff SOURCE.c [SOURCE2.c | FUNC...] [--pair OLD:NEW]... [--across FUNC]...
360
360
  [--cc 'CC FLAGS']... [--target NAME]... [--config PATH]
361
- [--compile-commands [PATH]] [--flags-like PATH]
361
+ [--compile-commands [PATH]] [--flags-like PATH] [--db-includes]
362
+ [--summary-only] [--collapse] [--span-stats]
362
363
  [--layout list|side-by-side] [-v] [-- EXTRA_FLAGS...]
363
364
  asmdiff FIRMWARE.elf [FUNC...] [--filter REGEX] [--objdump PATH]
365
+ [-l list] [--summary-only] [--span-stats]
364
366
  ```
365
367
 
366
368
  | Option | Meaning |
@@ -368,14 +370,20 @@ asmdiff FIRMWARE.elf [FUNC...] [--filter REGEX] [--objdump PATH]
368
370
  | `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. |
369
371
  | `-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. |
370
372
  | `-a, --across FUNC` | Compare the *same* function across two compilations (see below). Repeatable. Mutually exclusive with `--pair`. |
371
- | `-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). Inspect mode only. |
372
- | `--filter REGEX` | ELF input only: also analyze every function whose name matches `REGEX` (`re.search`) — sweep a subsystem without naming each function. Matches appear in the stats table but are not listed in full. |
373
+ | `-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. |
374
+ | `--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`. |
373
375
  | `--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. |
374
376
  | `--cc 'CC FLAGS'` | One compiler invocation, command and flags in a single quoted string. Repeatable to build a matrix. |
375
377
  | `--target NAME` | A named target from the config file, resolved to a `--cc` entry. Repeatable; appended to the matrix after `--cc` entries. |
376
378
  | `--config PATH` | Config file to use. Default search: `asmdiff.toml` next to `SOURCE.c`, then in the current directory, then `~/.config/`. First hit wins. |
377
379
  | `-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). |
378
380
  | `--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. |
381
+ | `--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. |
382
+ | `-s`, `--summary-only` | Print only the summary/stats tables, suppressing every assembly listing (see [Shaping the output](#shaping-the-output-for-reading-vs-deciding)). |
383
+ | `--json` | Emit the summary as JSON on stdout instead of tables — one record per function per compiler. Implies `--summary-only`; errors stay plain text on stderr. |
384
+ | `--collapse` | In side-by-side listings, omit runs of identical line pairs, keeping 3 lines of context around each difference. |
385
+ | `--span-stats` | Follow each stats table with a per-loop-span instruction mix: load/store/mul/branch/other counts per span. |
386
+ | `--version` | Print the version and exit. |
379
387
  | `-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. |
380
388
  | `-- FLAGS...` | Everything after a bare `--` is appended to *every* compiler invocation. |
381
389
 
@@ -399,9 +407,103 @@ asmdiff h.c -- -fno-math-errno
399
407
  ```
400
408
 
401
409
  Compilers missing from `PATH` are skipped with a warning; the run fails only
402
- if none are usable. Exit status is non-zero only for operational failures
403
- (compile error — the compiler's stderr is shown — unknown `--pair` name, no
404
- usable compiler). Differing assembly is the expected result, never an error.
410
+ if none are usable, and that error names each missed binary
411
+ (`no-such-gcc: not found on PATH`) so the fix is in the message, not in a
412
+ warning that scrolled away. Exit status is non-zero only for operational
413
+ failures (compile error — the compiler's stderr is shown — unknown `--pair`
414
+ name, no usable compiler). Differing assembly is the expected result, never
415
+ an error.
416
+
417
+ ## Shaping the output for reading vs deciding
418
+
419
+ A two-file compare over a three-target matrix prints three full
420
+ side-by-side listings with the summary table at the *end* of each — the
421
+ right shape for reading an unfamiliar delta, and roughly 100 KB of the
422
+ wrong shape when the table is all the decision needs. Three flags cut the
423
+ output to purpose:
424
+
425
+ - **`--summary-only`** (`-s`) keeps only the summary/stats tables. This is
426
+ the scripted/agent view: the table is the decision input, and a listing
427
+ is pulled with a second, narrower run only when a delta needs
428
+ explaining.
429
+ - **`--collapse`** elides runs of identical line pairs in every
430
+ side-by-side listing, keeping 3 lines of context around each difference
431
+ plus a `... N identical lines ...` marker. Two ~500-instruction
432
+ functions differing by five instructions render as a few readable hunks
433
+ instead of ~1000 lines.
434
+ - **`--span-stats`** follows each stats table with a per-loop-span
435
+ instruction mix, one row per span:
436
+
437
+ ```
438
+ function span insns load store mul branch other
439
+ stereo_reverb .L108 327 96 31 14 12 174
440
+ ```
441
+
442
+ This weighs the span rather than the whole function — the number that
443
+ actually decides a hot-loop comparison (is the rewrite trading loads
444
+ for stores? did the multiplies move?) without hand-counting a listing
445
+ and accidentally tallying past the loop end into the epilogue.
446
+ Buckets are by mnemonic (Xtensa, RISC-V, ARM) with an AT&T
447
+ memory-operand heuristic for x86; *branch* means any control
448
+ transfer — conditional and unconditional branches, calls, and
449
+ returns (outbound calls are already itemised by name in the `calls`
450
+ column) — and anything the tables don't know lands in *other*.
451
+
452
+ Reading *branch* inside a span: a software loop's own backedge is
453
+ one of them, because the span runs from the label to the last
454
+ backward branch inclusive. A Xtensa zero-overhead loop instead
455
+ encloses only the body between `loop` and its end label, so the loop
456
+ machinery contributes zero and any branch counted there is real
457
+ per-iteration control flow — early exits, per-sample `if`/`else`,
458
+ wrap-around checks. Track it across variants: branches replaced by
459
+ conditional moves (`movnez`, `csel`, `cmov` — counted under *other*)
460
+ show as *branch* falling while *insns* stays roughly flat, and a
461
+ call appearing inside a hot span is the ZOL killer the `calls`
462
+ column already flagged.
463
+
464
+ All three combine with every compile mode; `--summary-only` and
465
+ `--span-stats` also apply to ELF input.
466
+
467
+ For scripted callers, `--json` replaces the tables entirely with one
468
+ JSON document on stdout: a flat `results` list holding one record per
469
+ function per compiler — `cc`, `tag` (source label in two-file runs),
470
+ `role` (`baseline`/`candidate` in paired runs), `insns`, `loop_spans`,
471
+ `calls`, and `span_stats` when `--span-stats` is given. Flat records
472
+ keep it one `jq` expression away from any question the tables answer.
473
+ There is no built-in jq subcommand and there won't be: the tool prints
474
+ JSON on stdout, so a plain `| jq` pipe *is* the integration, and jq
475
+ already does the filtering better than a wrapper flag could.
476
+
477
+ A few recipes that cover the common questions:
478
+
479
+ ```bash
480
+ # Instruction count per function per side, as a table
481
+ asmdiff old.c new.c --json \
482
+ | jq -r '.results[] | "\(.tag)\t\(.function)\t\(.insns)"'
483
+
484
+ # Only the functions that emit libcalls (soft-float, __divdi3, memcpy…)
485
+ asmdiff old.c new.c --json \
486
+ | jq '.results[] | select(.calls | length > 0) | {function, tag, calls}'
487
+
488
+ # Per-function old→new instruction delta (regressions have a positive delta)
489
+ asmdiff old.c new.c --json \
490
+ | jq -r '.results | group_by(.function)[] | select(length == 2)
491
+ | "\(.[0].function): \(.[0].insns) -> \(.[1].insns) (\(.[1].insns - .[0].insns))"'
492
+
493
+ # Fail CI if any function grew, using jq's exit status
494
+ asmdiff old.c new.c --json \
495
+ | jq -e '.results | group_by(.function)
496
+ | all(length < 2 or .[1].insns <= .[0].insns)' > /dev/null
497
+
498
+ # The full record for one hot function, spans and all
499
+ asmdiff old.c new.c -a stereo_reverb --json --span-stats \
500
+ | jq '.results[] | {role, insns, spans: .loop_spans}'
501
+ ```
502
+
503
+ The top level carries `asmdiff` (version) and `mode`
504
+ (`pairs`/`across`/`inspect`/`summary`/`elf`; ELF runs add the binary's
505
+ path as `elf`). Warnings and errors stay plain text on stderr, so a
506
+ failed run never emits half a document.
405
507
 
406
508
  ## Config file: named targets
407
509
 
@@ -573,6 +675,17 @@ Details that make it robust:
573
675
  - **`compile_commands` expands `~` and `$VARS`.** The `==` header always
574
676
  prints the resolved compiler command; run with `-- -v` if you want to see
575
677
  every include path the compiler actually received.
678
+ - **`--db-includes` restricts the borrow to header-search paths**,
679
+ dropping `-D`/`-U`, forced includes, and `-specs`/`--sysroot`. Use it
680
+ when the *target* doesn't match the arch the database was built for —
681
+ host gcc given an ESP-IDF database would otherwise inherit
682
+ `-specs=picolibc.specs` and cross-only defines it cannot compile
683
+ with, yet still needs the project's include paths to find its
684
+ headers. The kept paths are re-emitted as `-idirafter`, searched
685
+ *after* the compiler's own system directories: ESP-IDF's include set
686
+ contains a libc-overlay directory (`esp_libc/platform_include`) whose
687
+ `stdio.h` would shadow the host's under plain `-I`; demoted, the
688
+ project's own headers still resolve and the host's libc wins.
576
689
 
577
690
  ### Auto-discovering the database
578
691
 
@@ -665,6 +778,11 @@ baseline (`../baseline/src/oscillators.c`), which is the same file under a
665
778
  path the database has never heard of. The absent-source error and the
666
779
  soft-miss note both point at this flag when a same-name entry exists.
667
780
 
781
+ To contrast the same pair on a host compiler (does gcc-on-x86 make the
782
+ same choice?), add `--db-includes`: the borrow then carries only the
783
+ project's include paths, which are target-portable, and none of the
784
+ cross-only defines or `-specs` a host gcc would choke on.
785
+
668
786
  ## Whole-file summary
669
787
 
670
788
  With no `--pair`, no `--across`, and no `old_*`/`new_*` functions to
@@ -0,0 +1,6 @@
1
+ asmdiff.py,sha256=epHsHBHi0ApOcrhbAFG1beAU1dBA5Qo9t7ui1gOfKoM,89342
2
+ asmdiff-0.3.3.dist-info/METADATA,sha256=arU-JzHKkh751x0M9NBp2Sc9RueiD_fGDVA-tDoZ-u4,63100
3
+ asmdiff-0.3.3.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
4
+ asmdiff-0.3.3.dist-info/entry_points.txt,sha256=bVeIyjLm3KgWALbgRSeQo16pZ89GXZZ4sFTSWJUMdTI,41
5
+ asmdiff-0.3.3.dist-info/licenses/LICENSE,sha256=Y8JW9wzNeVnfyw62X3qC9cuwZCv_U67rNSF9QUE0HJs,1072
6
+ asmdiff-0.3.3.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.31.0
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
asmdiff.py CHANGED
@@ -13,24 +13,34 @@ Usage:
13
13
  [--pair OLD:NEW]... [--across FUNC]...
14
14
  [--cc 'CC FLAGS']... [--target NAME]...
15
15
  [--config PATH] [--compile-commands [PATH]]
16
- [--flags-like PATH]
17
- [--layout list|side-by-side] [-v]
16
+ [--flags-like PATH] [--db-includes]
17
+ [--filter REGEX] [--summary-only]
18
+ [--collapse] [--span-stats]
19
+ [--json] [--layout list|side-by-side] [-v]
18
20
  [-- EXTRA_FLAGS...]
19
21
  tools/asmdiff/asmdiff.py FIRMWARE.elf [FUNC...] [--filter REGEX]
20
- [--objdump PATH]
21
- tools/asmdiff/asmdiff.py --edit-config | --example-config
22
+ [--objdump PATH] [--layout list]
23
+ [--summary-only] [--span-stats] [--json]
24
+ tools/asmdiff/asmdiff.py --edit-config | --example-config | --version
22
25
 
23
26
  Five modes:
24
27
  SOURCE.c FUNC inspect: print the named function's assembly, no
25
28
  comparison. One usable compiler prints a listing
26
29
  plus a stats row, exactly two print side by side,
27
30
  more print one block per compiler; --layout forces
28
- list or side-by-side.
31
+ list or side-by-side. --filter REGEX selects
32
+ matching functions as full peers of named ones
33
+ (compiler-generated clones included; lenient when a
34
+ match exists under only part of the matrix), and in
35
+ summary mode narrows the table.
29
36
  FIRMWARE.elf ELF input (detected by magic bytes): disassemble a
30
37
  linked binary through the toolchain's objdump and
31
38
  run named and/or --filter REGEX matched functions
32
39
  through the same analyzers - post-LTO/link reality
33
- instead of single-TU codegen. objdump comes from
40
+ instead of single-TU codegen. Named functions are
41
+ listed in full; --filter matches are summarized
42
+ only, unless -l list asks for their listings too.
43
+ objdump comes from
34
44
  --objdump PATH or the first gcc in the matrix
35
45
  (trailing gcc swapped for objdump); Xtensa
36
46
  -mlongcalls call sequences are resolved to their
@@ -44,6 +54,14 @@ Five modes:
44
54
  (neither) whole-file summary: per-function counts plus a file
45
55
  total, for one file or side by side for two.
46
56
 
57
+ Four flags shape the output: --summary-only prints only the stats
58
+ tables, --collapse elides identical runs in side-by-side listings
59
+ keeping context around each difference, --span-stats follows each
60
+ stats table with a per-loop-span instruction mix (load/store/mul/
61
+ branch/other counts; branch includes calls), and --json replaces the
62
+ tables with one JSON document of per-function records (implies
63
+ --summary-only) for scripted callers.
64
+
47
65
  Compilers come from --cc strings, from named targets in an asmdiff.toml
48
66
  config file (--target NAME), from the config's `default` entry, or —
49
67
  failing all of those — plain `gcc -O3` and `clang -O3`.
@@ -62,7 +80,11 @@ the CWD up to the repository root, and its build/ subdirectory; a source
62
80
  absent from a database found that way is compiled without borrowed flags
63
81
  (with a note) rather than being an error. --flags-like PATH lets such a
64
82
  source borrow the flags recorded for PATH, so a modified copy compares
65
- against its original under one header environment.
83
+ against its original under one header environment. --db-includes limits
84
+ any borrow to the header-search paths, re-emitted as -idirafter so they
85
+ cannot shadow the host's own system headers: a host target can then
86
+ compile a cross project's source without inheriting cross-only defines,
87
+ -specs, or a libc-overlay include directory.
66
88
  """
67
89
  import argparse
68
90
  import difflib
@@ -84,6 +106,10 @@ try:
84
106
  except ModuleNotFoundError:
85
107
  tomllib = None
86
108
 
109
+ # Single source of truth for the package version: pyproject.toml reads
110
+ # it from here (hatchling dynamic version).
111
+ __version__ = "0.3.3"
112
+
87
113
  DEFAULT_COMPILERS = ["gcc", "clang"]
88
114
  FALLBACK_FLAGS = "-O3"
89
115
  CONFIG_NAME = "asmdiff.toml"
@@ -120,6 +146,12 @@ flags = [
120
146
  cc = "clang"
121
147
  flags = ["-O3", "-Wall", "-Wextra"]
122
148
 
149
+ # The name the docs use for "your native compiler" (--target host): an
150
+ # alias of [gcc] kept as its own table so the examples work verbatim.
151
+ [host]
152
+ cc = "gcc"
153
+ flags = ["-O3", "-Wall", "-Wextra"]
154
+
123
155
  # cc values may use ~, $VARS, and glob patterns. A glob that matches
124
156
  # several installed toolchains resolves to the highest version-sorted
125
157
  # one (announced on stderr); pin the exact esp-NN directory instead if
@@ -213,6 +245,13 @@ _CC_DB_EQ_FLAGS = ("-specs", "--specs", "--sysroot")
213
245
  _DB_CACHE = {}
214
246
  _MISS_NOTED = set()
215
247
  _BORROW_NOTED = set()
248
+ # --db-includes: borrow only the header-search paths from the database.
249
+ # A project's -I paths are valid on any target; its -D defines, forced
250
+ # includes, and -specs/--sysroot are tied to the arch the database was
251
+ # built for — this is how a host target compiles a cross project's
252
+ # source without inheriting cross-only flags.
253
+ DB_INCLUDES = False
254
+ _DB_SEARCH_PATH_FLAGS = ("-I", "-iquote", "-isystem", "-idirafter")
216
255
  # --flags-like PATH: a source with no database entry borrows the flags
217
256
  # recorded for PATH. This is how a modified copy of a project source gets
218
257
  # the same header environment as its original, so an --across between them
@@ -383,6 +422,32 @@ def load_compile_commands(path):
383
422
  return data
384
423
 
385
424
 
425
+ def _borrowed(flags):
426
+ """Apply --db-includes to one borrowed flag list: keep the
427
+ header-search pairs (-I/-iquote/-isystem/-idirafter PATH), drop
428
+ defines, forced includes, and driver-level flags. Identity when
429
+ the option is off.
430
+
431
+ Kept paths are re-emitted as -idirafter — searched AFTER the
432
+ compiler's own system directories — because a cross project's
433
+ include set can overlay libc headers (ESP-IDF ships an esp_libc/
434
+ platform_include whose stdio.h would otherwise shadow the host's);
435
+ project-only headers still resolve, the host's libc wins.
436
+ """
437
+ if not DB_INCLUDES:
438
+ return flags
439
+ kept, i = [], 0
440
+ while i < len(flags):
441
+ f = flags[i]
442
+ if f in _CC_DB_PATH_FLAGS: # split "-I path" pair
443
+ if f in _DB_SEARCH_PATH_FLAGS:
444
+ kept += ["-idirafter", flags[i + 1]]
445
+ i += 2
446
+ else: # -DFOO / -specs=... token
447
+ i += 1
448
+ return kept
449
+
450
+
386
451
  def compile_commands_flags(db_path, source, missing_ok=False):
387
452
  """Header/define flags for ``source`` taken from a compile_commands.json.
388
453
 
@@ -401,7 +466,7 @@ def compile_commands_flags(db_path, source, missing_ok=False):
401
466
  want = Path(source).resolve()
402
467
  flags, name_seen = _lookup_entry(entries, want)
403
468
  if flags is not None:
404
- return flags
469
+ return _borrowed(flags)
405
470
  if FLAGS_LIKE:
406
471
  like = Path(FLAGS_LIKE).resolve()
407
472
  like_flags, _ = _lookup_entry(entries, like)
@@ -413,7 +478,7 @@ def compile_commands_flags(db_path, source, missing_ok=False):
413
478
  _BORROW_NOTED.add(key)
414
479
  print(f"note: {source} not in {db_path}; borrowing the flags "
415
480
  f"recorded for {FLAGS_LIKE}", file=sys.stderr)
416
- return like_flags
481
+ return _borrowed(like_flags)
417
482
  suggest = ("; --flags-like PATH can borrow another entry's flags"
418
483
  if name_seen else "")
419
484
  if missing_ok:
@@ -685,8 +750,8 @@ def analyze(lines):
685
750
  LABEL_REF = re.compile(r"\.L[\w$.]+")
686
751
 
687
752
 
688
- def loop_spans(lines):
689
- """Return [(label, insns)] spans for cleaned asm lines.
753
+ def loop_span_ranges(lines):
754
+ """Return [(label, lo, hi)] line-index ranges for loop spans.
690
755
 
691
756
  A span is the run of instructions from a local label to the last
692
757
  instruction that references it from below — a backward branch, which
@@ -694,9 +759,7 @@ def loop_spans(lines):
694
759
  Xtensa zero-overhead loops (loop/loopnez/loopgt) reference their END
695
760
  label instead; there the span is the instructions the loop encloses.
696
761
  Spans are reported in order of appearance, one per label; nested
697
- labels yield nested spans. The count states how many instructions
698
- lie in the span — nothing about trip count or hotness, which the
699
- reader must judge from the source.
762
+ labels yield nested spans.
700
763
  """
701
764
  label_at = {ln[:-1]: i for i, ln in enumerate(lines)
702
765
  if ln.endswith(":")}
@@ -719,13 +782,129 @@ def loop_spans(lines):
719
782
  lo = min(lo, spans[ref][0])
720
783
  hi = max(hi, spans[ref][1])
721
784
  spans[ref] = (lo, hi)
785
+ return [(ref, lo, hi) for ref, (lo, hi)
786
+ in sorted(spans.items(), key=lambda kv: kv[1])]
787
+
788
+
789
+ def loop_spans(lines):
790
+ """Return [(label, insns)] spans for cleaned asm lines.
791
+
792
+ The count states how many instructions lie in the span — nothing
793
+ about trip count or hotness, which the reader must judge from the
794
+ source.
795
+ """
722
796
  result = []
723
- for ref, (lo, hi) in sorted(spans.items(), key=lambda kv: kv[1]):
797
+ for ref, lo, hi in loop_span_ranges(lines):
724
798
  insns = sum(1 for ln in lines[lo:hi + 1] if not ln.endswith(":"))
725
799
  result.append((ref, insns))
726
800
  return result
727
801
 
728
802
 
803
+ # --span-stats buckets, by mnemonic table. Covers the targets the tool
804
+ # routinely meets: Xtensa, RISC-V, and ARM by exact (or dot-stripped)
805
+ # mnemonic; x86 AT&T by the memory-operand heuristic in classify_insn,
806
+ # since there loads and stores are mostly spellings of mov.
807
+ _LOAD_MNEMONICS = frozenset("""
808
+ l8ui l16ui l16si l32i l32i.n l32r l32e l32ai lsi lsiu lsx lsxu
809
+ lb lbu lh lhu lw lwu ld flw fld c.lw c.lwsp c.ld c.ldsp c.flw c.fld
810
+ ldr ldrb ldrh ldrsb ldrsh ldrd ldm ldmia vldr vldm
811
+ pop popl popq
812
+ """.split())
813
+ _STORE_MNEMONICS = frozenset("""
814
+ s8i s16i s32i s32i.n s32e s32ri s32c1i ssi ssiu ssx ssxu
815
+ sb sh sw sd fsw fsd c.sw c.swsp c.sd c.sdsp c.fsw c.fsd
816
+ str strb strh strd stm stmia vstr vstm
817
+ push pushl pushq
818
+ """.split())
819
+ # Any multiply, including multiply-accumulate and FP forms (mull,
820
+ # mulsh, mula.dd.*, fmadd.s, vmla.f32, imull, mulss, ...).
821
+ _MUL_PREFIXES = ("mul", "imul", "fmul", "fmadd", "fmsub", "fnmadd",
822
+ "fnmsub", "madd", "msub", "mla", "mls", "smul", "umul",
823
+ "smla", "umla", "vmul", "vmla", "vmls", "vfma", "vfms")
824
+ _BRANCH_MNEMONICS = frozenset("""
825
+ beq bne blt bge bltu bgeu beqz bnez beqi bnei blti bgei bltui bgeui
826
+ bbci bbsi bbc bbs bany bnone ball bnall bt bf
827
+ blez bgez bltz bgtz bgt ble bgtu bleu
828
+ b bl bx blx cbz cbnz bcs bcc bmi bpl bvs bvc bhi bls bal
829
+ jr jal jalr ret retw tail
830
+ c.j c.jr c.jal c.jalr c.beqz c.bnez
831
+ """.split())
832
+
833
+
834
+ def classify_insn(line):
835
+ """Bucket one cleaned instruction: load/store/mul/branch/other.
836
+
837
+ Branch means any control transfer, calls included (a span's
838
+ outbound calls are already itemised in the calls column). For
839
+ mnemonics no table knows, an AT&T-style memory operand decides
840
+ load (source side) vs store (destination, the last operand);
841
+ lea and alignment nops are excluded from that heuristic first.
842
+ """
843
+ parts = line.split(None, 1)
844
+ mnem = parts[0].lower()
845
+ rest = parts[1] if len(parts) > 1 else ""
846
+ base = mnem.split(".", 1)[0]
847
+ if mnem in _LOAD_MNEMONICS or base in _LOAD_MNEMONICS:
848
+ return "load"
849
+ if mnem in _STORE_MNEMONICS or base in _STORE_MNEMONICS:
850
+ return "store"
851
+ if base.startswith(_MUL_PREFIXES):
852
+ return "mul"
853
+ if mnem in _BRANCH_MNEMONICS or base in _BRANCH_MNEMONICS:
854
+ return "branch"
855
+ if base.startswith(("j", "call", "loop")):
856
+ return "branch"
857
+ if base.startswith(("lea", "nop")):
858
+ return "other"
859
+ if "(" in rest:
860
+ depth, cut = 0, -1
861
+ for i, ch in enumerate(rest):
862
+ if ch == "(":
863
+ depth += 1
864
+ elif ch == ")":
865
+ depth -= 1
866
+ elif ch == "," and depth == 0:
867
+ cut = i
868
+ return "store" if "(" in rest[cut + 1:] else "load"
869
+ return "other"
870
+
871
+
872
+ _MIX_KEYS = ("load", "store", "mul", "branch", "other")
873
+
874
+
875
+ def span_mix(lines):
876
+ """[{label, insns, load, store, mul, branch, other}] per loop span
877
+ of one function — the data behind --span-stats, table and JSON."""
878
+ result = []
879
+ for label, lo, hi in loop_span_ranges(lines):
880
+ body = [ln for ln in lines[lo:hi + 1] if not ln.endswith(":")]
881
+ counts = dict.fromkeys(_MIX_KEYS, 0)
882
+ for ln in body:
883
+ counts[classify_insn(ln)] += 1
884
+ entry = {"label": label, "insns": len(body)}
885
+ entry.update(counts)
886
+ result.append(entry)
887
+ return result
888
+
889
+
890
+ def span_stats_table(fn_names, funcs, max_width=None):
891
+ """Per-span instruction mix for the named functions.
892
+
893
+ One row per loop span: how many of its instructions load, store,
894
+ multiply, or branch. This weighs the span the way the doctrine
895
+ asks — the hand-written awk this replaces kept counting past the
896
+ loop end into the epilogue.
897
+ """
898
+ rows = [("function", "span", "insns") + _MIX_KEYS]
899
+ for name in fn_names:
900
+ for mix in span_mix(funcs[name]):
901
+ rows.append((name, mix["label"], str(mix["insns"]))
902
+ + tuple(str(mix[k]) for k in _MIX_KEYS))
903
+ if len(rows) == 1:
904
+ return "(no loop spans)"
905
+ return render_table(rows, max_width)
906
+
907
+
729
908
  def auto_pairs(names):
730
909
  """Pair old_X with new_X for every X present in both."""
731
910
  names = list(names)
@@ -941,10 +1120,62 @@ def build_matrix(cc_args, target_args, config, config_path, db_arg=None,
941
1120
 
942
1121
  # --verbose: print full compiler command lines and untrimmed stderr.
943
1122
  VERBOSE = False
1123
+ # --summary-only: print the stats tables and skip every listing. The
1124
+ # tables are the decision input; a listing is pulled on a second run
1125
+ # when a delta needs explaining.
1126
+ SUMMARY_ONLY = False
1127
+ # --span-stats: follow each stats table with the per-loop-span
1128
+ # instruction mix (see span_stats_table).
1129
+ SPAN_STATS = False
1130
+ # --json: collect summary records here instead of printing tables;
1131
+ # None means normal table output. main() dumps the collected list.
1132
+ JSON_OUT = None
1133
+
1134
+
1135
+ def span_stats_block(fn_names, funcs):
1136
+ """Print the per-span mix table after a stats table when
1137
+ --span-stats is on; a silent no-op otherwise."""
1138
+ if SPAN_STATS and JSON_OUT is None:
1139
+ print()
1140
+ print(span_stats_table(fn_names, funcs, table_width()))
1141
+
1142
+
1143
+ def json_record(name, lines, cc=None, tag=None, role=None):
1144
+ """One --json result: a summary-table row as data. cc is the
1145
+ resolved compiler command, tag the source/side label of a two-file
1146
+ comparison, role baseline/candidate for paired rows; each is
1147
+ omitted where the mode has no such notion."""
1148
+ insns, calls = analyze(lines)
1149
+ rec = {"function": name}
1150
+ if cc is not None:
1151
+ rec["cc"] = str(cc)
1152
+ if tag is not None:
1153
+ rec["tag"] = tag
1154
+ if role is not None:
1155
+ rec["role"] = role
1156
+ rec["insns"] = insns
1157
+ rec["loop_spans"] = [{"label": label, "insns": n}
1158
+ for label, n in loop_spans(lines)]
1159
+ rec["calls"] = calls
1160
+ if SPAN_STATS:
1161
+ rec["span_stats"] = span_mix(lines)
1162
+ return rec
944
1163
  # Without --verbose, a failed compile shows this many stderr lines — enough
945
1164
  # for the include chain plus the first error, which is the actionable part.
946
1165
  MAX_STDERR_LINES = 20
947
1166
 
1167
+ # Compilers skipped because their binary was missing, in matrix order.
1168
+ # The final no-usable-compiler error repeats them (via _skipped_suffix)
1169
+ # so the fix is visible in the error itself, not only in an earlier
1170
+ # stderr warning that may have scrolled away.
1171
+ _SKIPPED_CCS = []
1172
+
1173
+
1174
+ def _skipped_suffix():
1175
+ """" (cc: not found on PATH; ...)" naming every skipped compiler,
1176
+ empty when nothing was skipped."""
1177
+ return f" ({'; '.join(_SKIPPED_CCS)})" if _SKIPPED_CCS else ""
1178
+
948
1179
 
949
1180
  def _compile_failure(cmd, stderr):
950
1181
  """Exit for a failed compile.
@@ -989,6 +1220,9 @@ def compile_to_asm(cc_cmd, extra_flags, harness, out_dir):
989
1220
  if shutil.which(argv[0]) is None:
990
1221
  print(f"warning: {argv[0]} not found on PATH, skipping",
991
1222
  file=sys.stderr)
1223
+ miss = f"{argv[0]}: not found on PATH"
1224
+ if miss not in _SKIPPED_CCS:
1225
+ _SKIPPED_CCS.append(miss)
992
1226
  return None
993
1227
  db = getattr(cc_cmd, "compile_commands", None)
994
1228
  db_flags = (compile_commands_flags(
@@ -1039,14 +1273,53 @@ def mangled_pair_hint(names):
1039
1273
  return any(MANGLED_PAIR.match(n) for n in names)
1040
1274
 
1041
1275
 
1042
- def side_by_side(left, right, ltitle, rtitle, width=44):
1043
- """Two-column view of a pair's asm lines."""
1276
+ # --collapse keeps this many line pairs of context around each
1277
+ # differing pair; longer identical runs are elided with a count.
1278
+ COLLAPSE_CONTEXT = 3
1279
+ # --collapse: elide identical runs in side-by-side listings.
1280
+ COLLAPSE = False
1281
+
1282
+
1283
+ def side_by_side(left, right, ltitle, rtitle, width=44, collapse=False):
1284
+ """Two-column view of a pair's asm lines.
1285
+
1286
+ With collapse, runs of identical line pairs shrink to the
1287
+ COLLAPSE_CONTEXT pairs around each difference plus an elision
1288
+ marker — two ~500-insn functions differing by a handful of
1289
+ instructions render as a few hunks instead of ~1000 lines. The
1290
+ sides are aligned first (difflib), not paired by position: an
1291
+ inserted instruction gets a gap opposite it instead of desyncing
1292
+ every following pair.
1293
+ """
1044
1294
  rows = [f"{ltitle:<{width}} | {rtitle}",
1045
1295
  f"{'-' * width}-+-{'-' * width}"]
1046
- for l, r in zip_longest(left, right, fillvalue=""):
1296
+ if collapse:
1297
+ pairs = []
1298
+ matcher = difflib.SequenceMatcher(a=left, b=right, autojunk=False)
1299
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
1300
+ pairs += zip_longest(left[i1:i2], right[j1:j2], fillvalue="")
1301
+ keep = [False] * len(pairs)
1302
+ for i, (l, r) in enumerate(pairs):
1303
+ if l != r:
1304
+ for j in range(max(0, i - COLLAPSE_CONTEXT),
1305
+ min(len(pairs), i + COLLAPSE_CONTEXT + 1)):
1306
+ keep[j] = True
1307
+ else:
1308
+ pairs = list(zip_longest(left, right, fillvalue=""))
1309
+ keep = [True] * len(pairs)
1310
+ elided = 0
1311
+ for (l, r), kept in zip(pairs, keep):
1312
+ if not kept:
1313
+ elided += 1
1314
+ continue
1315
+ if elided:
1316
+ rows.append(f" ... {elided} identical lines ...")
1317
+ elided = 0
1047
1318
  l = l.expandtabs(8)[:width]
1048
1319
  r = r.expandtabs(8)[:width]
1049
1320
  rows.append(f"{l:<{width}} | {r}")
1321
+ if elided:
1322
+ rows.append(f" ... {elided} identical lines ...")
1050
1323
  return "\n".join(rows)
1051
1324
 
1052
1325
 
@@ -1187,7 +1460,8 @@ def file_tags(a, b):
1187
1460
  return str(a), str(b)
1188
1461
 
1189
1462
 
1190
- def report_across(fn_names, left_funcs, right_funcs, left_tag, right_tag):
1463
+ def report_across(fn_names, left_funcs, right_funcs, left_tag, right_tag,
1464
+ ccs=(None, None)):
1191
1465
  """Side-by-side + summary for the same functions from two compilations."""
1192
1466
  missing = sorted({f for f in fn_names
1193
1467
  if f not in left_funcs or f not in right_funcs})
@@ -1195,15 +1469,25 @@ def report_across(fn_names, left_funcs, right_funcs, left_tag, right_tag):
1195
1469
  sys.exit("error: function(s) not in asm: " + ", ".join(missing)
1196
1470
  + f"; {left_tag} has: " + (", ".join(left_funcs) or "none")
1197
1471
  + f"; {right_tag} has: " + (", ".join(right_funcs) or "none"))
1472
+ if JSON_OUT is not None:
1473
+ for f in fn_names:
1474
+ JSON_OUT.append(json_record(f, left_funcs[f], cc=ccs[0],
1475
+ tag=left_tag, role="baseline"))
1476
+ JSON_OUT.append(json_record(f, right_funcs[f], cc=ccs[1],
1477
+ tag=right_tag, role="candidate"))
1478
+ return
1198
1479
  decorated, pairs = {}, []
1199
1480
  for f in fn_names:
1200
1481
  lt, rt = f"{f} [{left_tag}]", f"{f} [{right_tag}]"
1201
1482
  decorated[lt], decorated[rt] = left_funcs[f], right_funcs[f]
1202
1483
  pairs.append((lt, rt))
1203
- for lt, rt in pairs:
1204
- print(side_by_side(decorated[lt], decorated[rt], lt, rt))
1205
- print()
1484
+ if not SUMMARY_ONLY:
1485
+ for lt, rt in pairs:
1486
+ print(side_by_side(decorated[lt], decorated[rt], lt, rt,
1487
+ collapse=COLLAPSE))
1488
+ print()
1206
1489
  print(summary_table(pairs, decorated, table_width()))
1490
+ span_stats_block([n for p in pairs for n in p], decorated)
1207
1491
 
1208
1492
 
1209
1493
  def run_across(sources, matrix, fn_names, extra_flags, tmp):
@@ -1227,10 +1511,13 @@ def run_across(sources, matrix, fn_names, extra_flags, tmp):
1227
1511
  ran_any = True
1228
1512
  tags = mark_db_misses(cc_cmd, sources,
1229
1513
  file_tags(sources[0], sources[1]))
1230
- print(f"\n== {cc_cmd} ==\n")
1231
- report_across(fn_names, sides[0], sides[1], *tags)
1514
+ if JSON_OUT is None:
1515
+ print(f"\n== {cc_cmd} ==\n")
1516
+ report_across(fn_names, sides[0], sides[1], *tags,
1517
+ ccs=(cc_cmd, cc_cmd))
1232
1518
  if not ran_any:
1233
- sys.exit("error: no usable compiler in the matrix")
1519
+ sys.exit("error: no usable compiler in the matrix"
1520
+ + _skipped_suffix())
1234
1521
  return 0
1235
1522
 
1236
1523
  usable = []
@@ -1240,43 +1527,78 @@ def run_across(sources, matrix, fn_names, extra_flags, tmp):
1240
1527
  usable.append((f"cc#{idx}", cc_cmd, extract_functions(asm)))
1241
1528
  if len(usable) < 2:
1242
1529
  sys.exit("error: --across needs at least two usable compilers "
1243
- "in the matrix")
1244
- print()
1245
- for tag, cc_cmd, _ in usable:
1246
- print(f"{tag}: {cc_cmd}")
1247
- base_tag, _, base_funcs = usable[0]
1248
- for tag, _, funcs in usable[1:]:
1249
- print(f"\n== {base_tag} vs {tag} ==\n")
1250
- report_across(fn_names, base_funcs, funcs, base_tag, tag)
1530
+ "in the matrix" + _skipped_suffix())
1531
+ if JSON_OUT is None:
1532
+ print()
1533
+ for tag, cc_cmd, _ in usable:
1534
+ print(f"{tag}: {cc_cmd}")
1535
+ base_tag, base_cc, base_funcs = usable[0]
1536
+ for tag, cc_cmd, funcs in usable[1:]:
1537
+ if JSON_OUT is None:
1538
+ print(f"\n== {base_tag} vs {tag} ==\n")
1539
+ report_across(fn_names, base_funcs, funcs, base_tag, tag,
1540
+ ccs=(base_cc, cc_cmd))
1251
1541
  return 0
1252
1542
 
1253
1543
 
1254
- def run_summary(sources, matrix, extra_flags, tmp):
1255
- """No pairs to compare: whole-file summary, one block per file."""
1544
+ def _compile_filter(filter_regex):
1545
+ """Compile a --filter regex, exiting with a clean error on a bad one."""
1546
+ try:
1547
+ return re.compile(filter_regex)
1548
+ except re.error as exc:
1549
+ sys.exit(f"error: bad --filter regex: {exc}")
1550
+
1551
+
1552
+ def run_summary(sources, matrix, extra_flags, tmp, filter_regex=None):
1553
+ """No pairs to compare: whole-file summary, one block per file.
1554
+
1555
+ --filter narrows each file's table to matching functions - the
1556
+ subsystem view of a big TU, same selection idea as ELF mode.
1557
+ """
1558
+ pattern = _compile_filter(filter_regex) if filter_regex else None
1256
1559
  tags = (file_tags(*sources) if len(sources) == 2
1257
1560
  else [Path(sources[0]).name])
1258
1561
  ran_any = False
1562
+ matched_any = False
1259
1563
  for cc_cmd in matrix:
1260
1564
  sections = []
1261
1565
  for src in sources:
1262
1566
  asm = compile_to_asm(cc_cmd, extra_flags, src, tmp)
1263
1567
  if asm is None:
1264
1568
  break
1265
- sections.append(extract_functions(asm))
1569
+ funcs = extract_functions(asm)
1570
+ if pattern is not None:
1571
+ funcs = {n: v for n, v in funcs.items() if pattern.search(n)}
1572
+ sections.append(funcs)
1266
1573
  if len(sections) < len(sources):
1267
1574
  continue
1268
1575
  ran_any = True
1576
+ matched_any = matched_any or any(sections)
1269
1577
  shown = (mark_db_misses(cc_cmd, sources, tags)
1270
1578
  if len(sources) == 2 else tags)
1271
- print(f"\n== {cc_cmd} ==")
1579
+ if JSON_OUT is None:
1580
+ print(f"\n== {cc_cmd} ==")
1272
1581
  for tag, funcs in zip(shown, sections):
1582
+ if JSON_OUT is not None:
1583
+ JSON_OUT.extend(
1584
+ json_record(name, lines, cc=cc_cmd,
1585
+ tag=tag if len(sections) > 1 else None)
1586
+ for name, lines in funcs.items())
1587
+ continue
1273
1588
  if len(sections) > 1:
1274
1589
  print(f"\n-- {tag} --")
1275
1590
  print()
1276
1591
  print(file_summary_table(funcs, table_width()) if funcs
1277
- else "(no functions found)")
1592
+ else ("(no functions match --filter)" if pattern is not None
1593
+ else "(no functions found)"))
1594
+ if funcs:
1595
+ span_stats_block(list(funcs), funcs)
1278
1596
  if not ran_any:
1279
- sys.exit("error: no usable compiler in the matrix")
1597
+ sys.exit("error: no usable compiler in the matrix"
1598
+ + _skipped_suffix())
1599
+ if pattern is not None and not matched_any:
1600
+ sys.exit(f"error: --filter {filter_regex!r} matched no function "
1601
+ "in any compilation")
1280
1602
  return 0
1281
1603
 
1282
1604
 
@@ -1300,46 +1622,93 @@ def run_pairs(source, matrix, pair_specs, extra_flags, tmp):
1300
1622
  print('note: C++ mangling defeats old_*/new_* auto-pairing; '
1301
1623
  'declare the pairs extern "C" or use --pair with the '
1302
1624
  'mangled names', file=sys.stderr)
1625
+ if JSON_OUT is not None:
1626
+ JSON_OUT.extend(json_record(name, lines, cc=cc_cmd)
1627
+ for name, lines in funcs.items())
1628
+ continue
1303
1629
  print(f"\n== {cc_cmd} ==\n")
1304
1630
  print(file_summary_table(funcs, table_width()) if funcs
1305
1631
  else "(no functions found)")
1632
+ if funcs:
1633
+ span_stats_block(list(funcs), funcs)
1306
1634
  continue
1307
1635
  missing = sorted({n for p in pairs for n in p if n not in funcs})
1308
1636
  if missing:
1309
1637
  sys.exit("error: function(s) not in asm: "
1310
1638
  + ", ".join(missing)
1311
1639
  + "; functions seen: " + ", ".join(funcs))
1640
+ if JSON_OUT is not None:
1641
+ for old, new in pairs:
1642
+ JSON_OUT.append(json_record(old, funcs[old], cc=cc_cmd,
1643
+ role="baseline"))
1644
+ JSON_OUT.append(json_record(new, funcs[new], cc=cc_cmd,
1645
+ role="candidate"))
1646
+ continue
1312
1647
  print(f"\n== {cc_cmd} ==\n")
1313
- for old, new in pairs:
1314
- print(side_by_side(funcs[old], funcs[new], old, new))
1315
- print()
1648
+ if not SUMMARY_ONLY:
1649
+ for old, new in pairs:
1650
+ print(side_by_side(funcs[old], funcs[new], old, new,
1651
+ collapse=COLLAPSE))
1652
+ print()
1316
1653
  print(summary_table(pairs, funcs, table_width()))
1654
+ span_stats_block([n for p in pairs for n in p], funcs)
1317
1655
  if not ran_any:
1318
- sys.exit("error: no usable compiler in the matrix")
1656
+ sys.exit("error: no usable compiler in the matrix"
1657
+ + _skipped_suffix())
1319
1658
  return 0
1320
1659
 
1321
1660
 
1322
- def run_inspect(source, matrix, fn_names, layout, extra_flags, tmp):
1661
+ def run_inspect(source, matrix, fn_names, layout, extra_flags, tmp,
1662
+ filter_regex=None):
1323
1663
  """Inspect mode: print named functions' assembly, no comparison.
1324
1664
 
1325
1665
  The presentation adapts to how many matrix entries compiled: one
1326
1666
  gives a plain listing with a stats table, exactly two give the
1327
1667
  --across side-by-side, more give one block per compiler. --layout
1328
1668
  forces list or side-by-side instead.
1669
+
1670
+ --filter adds every matching function as a full peer of the named
1671
+ ones - the way to reach compiler-generated clones ($constprop$0,
1672
+ .isra.0) whose exact names only the -S output knows. Named
1673
+ functions must exist under every compiler; filter matches are
1674
+ lenient and appear only where that compiler emitted them, since a
1675
+ clone existing under one compiler but not another is a finding,
1676
+ not an error.
1329
1677
  """
1678
+ pattern = _compile_filter(filter_regex) if filter_regex else None
1330
1679
  usable = []
1331
1680
  for idx, cc_cmd in enumerate(matrix, start=1):
1332
1681
  asm = compile_to_asm(cc_cmd, extra_flags, source, tmp)
1333
1682
  if asm is not None:
1334
1683
  usable.append((f"cc#{idx}", cc_cmd, extract_functions(asm)))
1335
1684
  if not usable:
1336
- sys.exit("error: no usable compiler in the matrix")
1685
+ sys.exit("error: no usable compiler in the matrix"
1686
+ + _skipped_suffix())
1337
1687
  for _, cc_cmd, funcs in usable:
1338
1688
  missing = sorted({f for f in fn_names if f not in funcs})
1339
1689
  if missing:
1340
1690
  sys.exit("error: function(s) not in asm: " + ", ".join(missing)
1341
1691
  + f" under {cc_cmd}; functions seen: "
1342
1692
  + ", ".join(funcs))
1693
+ matched = []
1694
+ if pattern is not None:
1695
+ union = {}
1696
+ for _, _, funcs in usable:
1697
+ union.update(dict.fromkeys(funcs))
1698
+ matched = [n for n in union
1699
+ if pattern.search(n) and n not in fn_names]
1700
+ if not matched and not fn_names:
1701
+ sys.exit(f"error: --filter {filter_regex!r} matched no function "
1702
+ f"in {source}")
1703
+
1704
+ def selected(funcs):
1705
+ return list(fn_names) + [m for m in matched if m in funcs]
1706
+
1707
+ if JSON_OUT is not None:
1708
+ for _, cc_cmd, funcs in usable:
1709
+ JSON_OUT.extend(json_record(name, funcs[name], cc=cc_cmd)
1710
+ for name in selected(funcs))
1711
+ return 0
1343
1712
  if layout == "side-by-side" and len(usable) < 2:
1344
1713
  sys.exit("error: --layout side-by-side needs at least two usable "
1345
1714
  "compilers in the matrix")
@@ -1352,16 +1721,27 @@ def run_inspect(source, matrix, fn_names, layout, extra_flags, tmp):
1352
1721
  base_tag, _, base_funcs = usable[0]
1353
1722
  for tag, _, funcs in usable[1:]:
1354
1723
  print(f"\n== {base_tag} vs {tag} ==\n")
1355
- report_across(fn_names, base_funcs, funcs, base_tag, tag)
1724
+ both = list(fn_names) + [m for m in matched
1725
+ if m in base_funcs and m in funcs]
1726
+ report_across(both, base_funcs, funcs, base_tag, tag)
1727
+ one_sided = [m for m in matched
1728
+ if (m in base_funcs) != (m in funcs)]
1729
+ if one_sided:
1730
+ print("\nnote: --filter match(es) present under only one "
1731
+ "compiler, not compared: " + ", ".join(one_sided)
1732
+ + " - inspect with -l list")
1356
1733
  return 0
1357
1734
  for _, cc_cmd, funcs in usable:
1735
+ sel = selected(funcs)
1358
1736
  if len(usable) > 1:
1359
1737
  print(f"\n== {cc_cmd} ==")
1360
- for name in fn_names:
1361
- print()
1362
- print(listing(name, funcs[name]))
1738
+ if not SUMMARY_ONLY:
1739
+ for name in sel:
1740
+ print()
1741
+ print(listing(name, funcs[name]))
1363
1742
  print()
1364
- print(inspect_table(fn_names, funcs, table_width()))
1743
+ print(inspect_table(sel, funcs, table_width()))
1744
+ span_stats_block(sel, funcs)
1365
1745
  return 0
1366
1746
 
1367
1747
 
@@ -1392,7 +1772,7 @@ def run_objdump(objdump, elf):
1392
1772
  return proc.stdout
1393
1773
 
1394
1774
 
1395
- def run_elf(elf, fn_names, filter_regex, objdump):
1775
+ def run_elf(elf, fn_names, filter_regex, objdump, list_matches=False):
1396
1776
  """ELF mode: disassemble one linked binary and run the selected
1397
1777
  functions through the same analyzers as -S output.
1398
1778
 
@@ -1400,7 +1780,12 @@ def run_elf(elf, fn_names, filter_regex, objdump):
1400
1780
  of existence, and only the linked ELF shows whether its loops
1401
1781
  survived as zero-overhead loops inside the caller. Named functions
1402
1782
  are listed in full; --filter matches are summarized in the stats
1403
- table only, since a match can cover hundreds of functions.
1783
+ table only, since a match can cover hundreds of functions - unless
1784
+ list_matches (-l list) asks for their listings too, the way to see
1785
+ the assembly when only the filter's spelling of a mangled name is
1786
+ known. When filter matches go unlisted, a trailing note says so:
1787
+ a summary that looks complete but silently withholds the listings
1788
+ costs the caller a detour through raw objdump.
1404
1789
  """
1405
1790
  funcs = extract_functions_objdump(run_objdump(objdump, elf))
1406
1791
  missing = [f for f in fn_names if f not in funcs]
@@ -1411,20 +1796,28 @@ def run_elf(elf, fn_names, filter_regex, objdump):
1411
1796
  + ("; close matches: " + ", ".join(hints) if hints else ""))
1412
1797
  selected = list(fn_names)
1413
1798
  if filter_regex:
1414
- try:
1415
- pattern = re.compile(filter_regex)
1416
- except re.error as exc:
1417
- sys.exit(f"error: bad --filter regex: {exc}")
1799
+ pattern = _compile_filter(filter_regex)
1418
1800
  selected += [n for n in funcs
1419
1801
  if pattern.search(n) and n not in fn_names]
1420
1802
  if not selected:
1421
1803
  sys.exit(f"error: --filter {filter_regex!r} matched no function "
1422
1804
  f"among the {len(funcs)} in {elf}")
1423
- for name in fn_names:
1424
- print()
1425
- print(listing(name, funcs[name]))
1805
+ if JSON_OUT is not None:
1806
+ JSON_OUT.extend(json_record(name, funcs[name]) for name in selected)
1807
+ return 0
1808
+ listed = selected if list_matches else fn_names
1809
+ if not SUMMARY_ONLY:
1810
+ for name in listed:
1811
+ print()
1812
+ print(listing(name, funcs[name]))
1426
1813
  print()
1427
1814
  print(inspect_table(selected, funcs, table_width()))
1815
+ span_stats_block(selected, funcs)
1816
+ unlisted = len(selected) - len(listed)
1817
+ if unlisted and not SUMMARY_ONLY:
1818
+ print(f"\nnote: {unlisted} --filter match(es) summarized without "
1819
+ "listings; add -l list for their assembly, or name a "
1820
+ "function after the ELF")
1428
1821
  return 0
1429
1822
 
1430
1823
 
@@ -1456,12 +1849,18 @@ def main(argv=None):
1456
1849
  parser.add_argument("-l", "--layout", choices=["list", "side-by-side"],
1457
1850
  help="force the inspect presentation instead of "
1458
1851
  "adapting to the matrix (1 usable compiler "
1459
- "lists, 2 go side by side, more list)")
1852
+ "lists, 2 go side by side, more list); with "
1853
+ "ELF input, 'list' also prints --filter "
1854
+ "matches' listings")
1460
1855
  parser.add_argument("--filter", metavar="REGEX",
1461
- help="ELF input only: also analyze every function "
1462
- "whose name matches REGEX (re.search) - the "
1463
- "way to sweep a whole subsystem without "
1464
- "naming each function")
1856
+ help="also analyze every function whose name "
1857
+ "matches REGEX (re.search) - sweep a "
1858
+ "subsystem, or reach compiler-generated "
1859
+ "clones, without naming each function. In "
1860
+ "ELF input matches are summarized only (see "
1861
+ "-l list); in compile modes they are full "
1862
+ "peers of named functions. Not with "
1863
+ "--pair/--across")
1465
1864
  parser.add_argument("--objdump", metavar="PATH",
1466
1865
  help="disassembler for ELF input; default: derived "
1467
1866
  "from the first gcc in the matrix by swapping "
@@ -1487,6 +1886,14 @@ def main(argv=None):
1487
1886
  help=f"config file; default search: {CONFIG_NAME} "
1488
1887
  "next to SOURCE.c, in the current directory, "
1489
1888
  "then in ~/.config/")
1889
+ parser.add_argument("--db-includes", action="store_true",
1890
+ help="borrow only the header-search paths from the "
1891
+ "database (re-emitted as -idirafter, so they "
1892
+ "never shadow the host's system headers), "
1893
+ "dropping its defines, forced includes, and "
1894
+ "-specs/--sysroot - lets a host/foreign-arch "
1895
+ "target resolve a cross project's headers "
1896
+ "without inheriting cross-only flags")
1490
1897
  parser.add_argument("--flags-like", metavar="PATH",
1491
1898
  help="when a source has no compile_commands entry, "
1492
1899
  "borrow the include/define flags recorded for "
@@ -1502,14 +1909,45 @@ def main(argv=None):
1502
1909
  help="print the built-in example config (the "
1503
1910
  "repository's asmdiff.example.toml) to stdout, "
1504
1911
  "ready to redirect into a config file")
1912
+ parser.add_argument("--span-stats", action="store_true",
1913
+ help="follow each stats table with a per-loop-span "
1914
+ "instruction mix (load/store/mul/branch/other "
1915
+ "counts) - weighs the span instead of the "
1916
+ "whole function")
1917
+ parser.add_argument("--collapse", action="store_true",
1918
+ help="in side-by-side listings, elide runs of "
1919
+ "identical line pairs, keeping "
1920
+ f"{COLLAPSE_CONTEXT} lines of context around "
1921
+ "each difference - near-identical functions "
1922
+ "render as a few hunks")
1923
+ parser.add_argument("--json", action="store_true",
1924
+ help="emit the summary as JSON on stdout instead "
1925
+ "of tables: one record per function per "
1926
+ "compiler (insns, loop spans, calls; span "
1927
+ "mix with --span-stats). Implies "
1928
+ "--summary-only; errors stay plain text on "
1929
+ "stderr")
1930
+ parser.add_argument("-s", "--summary-only", action="store_true",
1931
+ help="print only the summary/stats tables, "
1932
+ "suppressing every assembly listing - the "
1933
+ "scripted-caller view (pull a listing with a "
1934
+ "second run when a delta needs explaining)")
1935
+ parser.add_argument("--version", action="version",
1936
+ version=f"asmdiff {__version__}")
1505
1937
  parser.add_argument("-v", "--verbose", action="store_true",
1506
1938
  help="on compile failure, print the full compiler "
1507
1939
  "command and complete error output instead of "
1508
1940
  "the first lines")
1509
1941
  args = parser.parse_args(argv)
1510
- global VERBOSE, FLAGS_LIKE
1942
+ global VERBOSE, FLAGS_LIKE, SUMMARY_ONLY, COLLAPSE, SPAN_STATS
1943
+ global DB_INCLUDES, JSON_OUT
1511
1944
  VERBOSE = args.verbose
1512
1945
  FLAGS_LIKE = args.flags_like
1946
+ SUMMARY_ONLY = args.summary_only or args.json
1947
+ COLLAPSE = args.collapse
1948
+ SPAN_STATS = args.span_stats
1949
+ DB_INCLUDES = args.db_includes
1950
+ JSON_OUT = [] if args.json else None
1513
1951
 
1514
1952
  if args.example_config:
1515
1953
  sys.stdout.write(EXAMPLE_CONFIG)
@@ -1524,9 +1962,13 @@ def main(argv=None):
1524
1962
  if len(sources) > 1:
1525
1963
  parser.error("ELF input analyzes one binary; a second file "
1526
1964
  "cannot be combined with it")
1527
- if args.pair or args.across or args.layout:
1528
- parser.error("--pair/--across/--layout compare compilations; "
1965
+ if args.pair or args.across:
1966
+ parser.error("--pair/--across compare compilations; "
1529
1967
  "an ELF is disassembled, not compiled")
1968
+ if args.layout == "side-by-side":
1969
+ parser.error("-l side-by-side compares compilations; with ELF "
1970
+ "input use -l list to also print --filter "
1971
+ "matches' listings")
1530
1972
  if not fn_names and not args.filter:
1531
1973
  parser.error("a whole ELF has too many functions to table; "
1532
1974
  "name functions after the file or select them "
@@ -1542,9 +1984,18 @@ def main(argv=None):
1542
1984
  sys.exit("error: no gcc in the matrix to derive an objdump "
1543
1985
  "from (" + "; ".join(matrix) + "); pass --objdump "
1544
1986
  "PATH or a gcc-based --cc/--target")
1545
- return run_elf(sources[0], fn_names, args.filter, objdump)
1546
- if args.filter or args.objdump:
1547
- parser.error("--filter/--objdump apply to ELF input only")
1987
+ status = run_elf(sources[0], fn_names, args.filter, objdump,
1988
+ list_matches=args.layout == "list")
1989
+ if JSON_OUT is not None:
1990
+ print(json.dumps({"asmdiff": __version__, "mode": "elf",
1991
+ "elf": sources[0], "results": JSON_OUT},
1992
+ indent=2))
1993
+ return status
1994
+ if args.objdump:
1995
+ parser.error("--objdump applies to ELF input only")
1996
+ if args.filter and (args.pair or args.across):
1997
+ parser.error("--filter selects functions to inspect or summarize; "
1998
+ "--pair/--across name their functions explicitly")
1548
1999
  if len(sources) > 2:
1549
2000
  parser.error("at most two source files may be given")
1550
2001
  if fn_names and len(sources) == 2:
@@ -1553,9 +2004,9 @@ def main(argv=None):
1553
2004
  if fn_names and (args.pair or args.across):
1554
2005
  parser.error("bare function names (inspect) cannot be combined "
1555
2006
  "with --pair or --across")
1556
- if args.layout and not fn_names:
2007
+ if args.layout and not fn_names and not args.filter:
1557
2008
  parser.error("--layout only applies when inspecting functions "
1558
- "(SOURCE.c FUNC ...)")
2009
+ "(SOURCE.c FUNC ... or --filter REGEX)")
1559
2010
  if args.across and args.pair:
1560
2011
  parser.error("--across and --pair are mutually exclusive")
1561
2012
  if len(sources) == 2 and args.pair:
@@ -1572,16 +2023,27 @@ def main(argv=None):
1572
2023
  parser.error(f"--pair expects OLD:NEW, got {spec!r}")
1573
2024
 
1574
2025
  with tempfile.TemporaryDirectory(prefix="asmdiff") as tmp:
1575
- if fn_names:
1576
- return run_inspect(sources[0], matrix, fn_names, args.layout,
2026
+ if fn_names or (args.filter and len(sources) == 1):
2027
+ mode = "inspect"
2028
+ status = run_inspect(sources[0], matrix, fn_names, args.layout,
2029
+ extra_flags, tmp,
2030
+ filter_regex=args.filter)
2031
+ elif args.across:
2032
+ mode = "across"
2033
+ status = run_across(sources, matrix, args.across,
2034
+ extra_flags, tmp)
2035
+ elif len(sources) == 2:
2036
+ mode = "summary"
2037
+ status = run_summary(sources, matrix, extra_flags, tmp,
2038
+ filter_regex=args.filter)
2039
+ else:
2040
+ mode = "pairs"
2041
+ status = run_pairs(sources[0], matrix, args.pair,
1577
2042
  extra_flags, tmp)
1578
- if args.across:
1579
- return run_across(sources, matrix, args.across,
1580
- extra_flags, tmp)
1581
- if len(sources) == 2:
1582
- return run_summary(sources, matrix, extra_flags, tmp)
1583
- return run_pairs(sources[0], matrix, args.pair,
1584
- extra_flags, tmp)
2043
+ if JSON_OUT is not None:
2044
+ print(json.dumps({"asmdiff": __version__, "mode": mode,
2045
+ "results": JSON_OUT}, indent=2))
2046
+ return status
1585
2047
 
1586
2048
 
1587
2049
  if __name__ == "__main__":
@@ -1,6 +0,0 @@
1
- asmdiff.py,sha256=cJrdwqqlG4ejalcvBHlSSSCylFRVIAKNYmKsiaCIer4,68504
2
- asmdiff-0.2.0.dist-info/METADATA,sha256=Gxk-QQA7YDZsmB0WPq2lKHJIg1FxZIuWIkz8TyYotKc,56049
3
- asmdiff-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
4
- asmdiff-0.2.0.dist-info/entry_points.txt,sha256=bVeIyjLm3KgWALbgRSeQo16pZ89GXZZ4sFTSWJUMdTI,41
5
- asmdiff-0.2.0.dist-info/licenses/LICENSE,sha256=Y8JW9wzNeVnfyw62X3qC9cuwZCv_U67rNSF9QUE0HJs,1072
6
- asmdiff-0.2.0.dist-info/RECORD,,