asmdiff 0.2.0__py3-none-any.whl → 0.3.0__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
1
  Metadata-Version: 2.4
2
2
  Name: asmdiff
3
- Version: 0.2.0
3
+ Version: 0.3.0
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
+ [--summary-only] [--span-stats]
364
366
  ```
365
367
 
366
368
  | Option | Meaning |
@@ -376,6 +378,12 @@ asmdiff FIRMWARE.elf [FUNC...] [--filter REGEX] [--objdump PATH]
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, elide 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,66 @@ 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* includes calls (outbound
448
+ calls are already itemised in the `calls` column), and anything the
449
+ tables don't know lands in *other*.
450
+
451
+ All three combine with every compile mode; `--summary-only` and
452
+ `--span-stats` also apply to ELF input.
453
+
454
+ For scripted callers, `--json` replaces the tables entirely with one
455
+ JSON document on stdout: a flat `results` list holding one record per
456
+ function per compiler — `cc`, `tag` (source label in two-file runs),
457
+ `role` (`baseline`/`candidate` in paired runs), `insns`, `loop_spans`,
458
+ `calls`, and `span_stats` when `--span-stats` is given. Flat records
459
+ keep it one `jq` expression away from any question the tables answer:
460
+
461
+ ```bash
462
+ asmdiff old.c new.c -a stereo_reverb --json --span-stats \
463
+ | jq '.results[] | {role, insns, spans: .loop_spans}'
464
+ ```
465
+
466
+ The top level carries `asmdiff` (version) and `mode`
467
+ (`pairs`/`across`/`inspect`/`summary`/`elf`; ELF runs add the binary's
468
+ path as `elf`). Warnings and errors stay plain text on stderr, so a
469
+ failed run never emits half a document.
405
470
 
406
471
  ## Config file: named targets
407
472
 
@@ -573,6 +638,17 @@ Details that make it robust:
573
638
  - **`compile_commands` expands `~` and `$VARS`.** The `==` header always
574
639
  prints the resolved compiler command; run with `-- -v` if you want to see
575
640
  every include path the compiler actually received.
641
+ - **`--db-includes` restricts the borrow to header-search paths**,
642
+ dropping `-D`/`-U`, forced includes, and `-specs`/`--sysroot`. Use it
643
+ when the *target* doesn't match the arch the database was built for —
644
+ host gcc given an ESP-IDF database would otherwise inherit
645
+ `-specs=picolibc.specs` and cross-only defines it cannot compile
646
+ with, yet still needs the project's include paths to find its
647
+ headers. The kept paths are re-emitted as `-idirafter`, searched
648
+ *after* the compiler's own system directories: ESP-IDF's include set
649
+ contains a libc-overlay directory (`esp_libc/platform_include`) whose
650
+ `stdio.h` would shadow the host's under plain `-I`; demoted, the
651
+ project's own headers still resolve and the host's libc wins.
576
652
 
577
653
  ### Auto-discovering the database
578
654
 
@@ -665,6 +741,11 @@ baseline (`../baseline/src/oscillators.c`), which is the same file under a
665
741
  path the database has never heard of. The absent-source error and the
666
742
  soft-miss note both point at this flag when a same-name entry exists.
667
743
 
744
+ To contrast the same pair on a host compiler (does gcc-on-x86 make the
745
+ same choice?), add `--db-includes`: the borrow then carries only the
746
+ project's include paths, which are target-portable, and none of the
747
+ cross-only defines or `-specs` a host gcc would choke on.
748
+
668
749
  ## Whole-file summary
669
750
 
670
751
  With no `--pair`, no `--across`, and no `old_*`/`new_*` functions to
@@ -0,0 +1,6 @@
1
+ asmdiff.py,sha256=JRhmdQKEuaT2pnqMDoFVr7CKhFCACHb8i2HljBfO8k4,84779
2
+ asmdiff-0.3.0.dist-info/METADATA,sha256=Kg2z3K5V7VlM7agNMTz13yNNQvEHwt1TtcHubC5nnx0,60801
3
+ asmdiff-0.3.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
4
+ asmdiff-0.3.0.dist-info/entry_points.txt,sha256=bVeIyjLm3KgWALbgRSeQo16pZ89GXZZ4sFTSWJUMdTI,41
5
+ asmdiff-0.3.0.dist-info/licenses/LICENSE,sha256=Y8JW9wzNeVnfyw62X3qC9cuwZCv_U67rNSF9QUE0HJs,1072
6
+ asmdiff-0.3.0.dist-info/RECORD,,
asmdiff.py CHANGED
@@ -13,12 +13,14 @@ 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
+ [--summary-only] [--collapse] [--span-stats]
18
+ [--json] [--layout list|side-by-side] [-v]
18
19
  [-- EXTRA_FLAGS...]
19
20
  tools/asmdiff/asmdiff.py FIRMWARE.elf [FUNC...] [--filter REGEX]
20
- [--objdump PATH]
21
- tools/asmdiff/asmdiff.py --edit-config | --example-config
21
+ [--objdump PATH] [--summary-only]
22
+ [--span-stats] [--json]
23
+ tools/asmdiff/asmdiff.py --edit-config | --example-config | --version
22
24
 
23
25
  Five modes:
24
26
  SOURCE.c FUNC inspect: print the named function's assembly, no
@@ -44,6 +46,14 @@ Five modes:
44
46
  (neither) whole-file summary: per-function counts plus a file
45
47
  total, for one file or side by side for two.
46
48
 
49
+ Four flags shape the output: --summary-only prints only the stats
50
+ tables, --collapse elides identical runs in side-by-side listings
51
+ keeping context around each difference, --span-stats follows each
52
+ stats table with a per-loop-span instruction mix (load/store/mul/
53
+ branch/other counts; branch includes calls), and --json replaces the
54
+ tables with one JSON document of per-function records (implies
55
+ --summary-only) for scripted callers.
56
+
47
57
  Compilers come from --cc strings, from named targets in an asmdiff.toml
48
58
  config file (--target NAME), from the config's `default` entry, or —
49
59
  failing all of those — plain `gcc -O3` and `clang -O3`.
@@ -62,7 +72,11 @@ the CWD up to the repository root, and its build/ subdirectory; a source
62
72
  absent from a database found that way is compiled without borrowed flags
63
73
  (with a note) rather than being an error. --flags-like PATH lets such a
64
74
  source borrow the flags recorded for PATH, so a modified copy compares
65
- against its original under one header environment.
75
+ against its original under one header environment. --db-includes limits
76
+ any borrow to the header-search paths, re-emitted as -idirafter so they
77
+ cannot shadow the host's own system headers: a host target can then
78
+ compile a cross project's source without inheriting cross-only defines,
79
+ -specs, or a libc-overlay include directory.
66
80
  """
67
81
  import argparse
68
82
  import difflib
@@ -84,6 +98,10 @@ try:
84
98
  except ModuleNotFoundError:
85
99
  tomllib = None
86
100
 
101
+ # Single source of truth for the package version: pyproject.toml reads
102
+ # it from here (hatchling dynamic version).
103
+ __version__ = "0.3.0"
104
+
87
105
  DEFAULT_COMPILERS = ["gcc", "clang"]
88
106
  FALLBACK_FLAGS = "-O3"
89
107
  CONFIG_NAME = "asmdiff.toml"
@@ -120,6 +138,12 @@ flags = [
120
138
  cc = "clang"
121
139
  flags = ["-O3", "-Wall", "-Wextra"]
122
140
 
141
+ # The name the docs use for "your native compiler" (--target host): an
142
+ # alias of [gcc] kept as its own table so the examples work verbatim.
143
+ [host]
144
+ cc = "gcc"
145
+ flags = ["-O3", "-Wall", "-Wextra"]
146
+
123
147
  # cc values may use ~, $VARS, and glob patterns. A glob that matches
124
148
  # several installed toolchains resolves to the highest version-sorted
125
149
  # one (announced on stderr); pin the exact esp-NN directory instead if
@@ -213,6 +237,13 @@ _CC_DB_EQ_FLAGS = ("-specs", "--specs", "--sysroot")
213
237
  _DB_CACHE = {}
214
238
  _MISS_NOTED = set()
215
239
  _BORROW_NOTED = set()
240
+ # --db-includes: borrow only the header-search paths from the database.
241
+ # A project's -I paths are valid on any target; its -D defines, forced
242
+ # includes, and -specs/--sysroot are tied to the arch the database was
243
+ # built for — this is how a host target compiles a cross project's
244
+ # source without inheriting cross-only flags.
245
+ DB_INCLUDES = False
246
+ _DB_SEARCH_PATH_FLAGS = ("-I", "-iquote", "-isystem", "-idirafter")
216
247
  # --flags-like PATH: a source with no database entry borrows the flags
217
248
  # recorded for PATH. This is how a modified copy of a project source gets
218
249
  # the same header environment as its original, so an --across between them
@@ -383,6 +414,32 @@ def load_compile_commands(path):
383
414
  return data
384
415
 
385
416
 
417
+ def _borrowed(flags):
418
+ """Apply --db-includes to one borrowed flag list: keep the
419
+ header-search pairs (-I/-iquote/-isystem/-idirafter PATH), drop
420
+ defines, forced includes, and driver-level flags. Identity when
421
+ the option is off.
422
+
423
+ Kept paths are re-emitted as -idirafter — searched AFTER the
424
+ compiler's own system directories — because a cross project's
425
+ include set can overlay libc headers (ESP-IDF ships an esp_libc/
426
+ platform_include whose stdio.h would otherwise shadow the host's);
427
+ project-only headers still resolve, the host's libc wins.
428
+ """
429
+ if not DB_INCLUDES:
430
+ return flags
431
+ kept, i = [], 0
432
+ while i < len(flags):
433
+ f = flags[i]
434
+ if f in _CC_DB_PATH_FLAGS: # split "-I path" pair
435
+ if f in _DB_SEARCH_PATH_FLAGS:
436
+ kept += ["-idirafter", flags[i + 1]]
437
+ i += 2
438
+ else: # -DFOO / -specs=... token
439
+ i += 1
440
+ return kept
441
+
442
+
386
443
  def compile_commands_flags(db_path, source, missing_ok=False):
387
444
  """Header/define flags for ``source`` taken from a compile_commands.json.
388
445
 
@@ -401,7 +458,7 @@ def compile_commands_flags(db_path, source, missing_ok=False):
401
458
  want = Path(source).resolve()
402
459
  flags, name_seen = _lookup_entry(entries, want)
403
460
  if flags is not None:
404
- return flags
461
+ return _borrowed(flags)
405
462
  if FLAGS_LIKE:
406
463
  like = Path(FLAGS_LIKE).resolve()
407
464
  like_flags, _ = _lookup_entry(entries, like)
@@ -413,7 +470,7 @@ def compile_commands_flags(db_path, source, missing_ok=False):
413
470
  _BORROW_NOTED.add(key)
414
471
  print(f"note: {source} not in {db_path}; borrowing the flags "
415
472
  f"recorded for {FLAGS_LIKE}", file=sys.stderr)
416
- return like_flags
473
+ return _borrowed(like_flags)
417
474
  suggest = ("; --flags-like PATH can borrow another entry's flags"
418
475
  if name_seen else "")
419
476
  if missing_ok:
@@ -685,8 +742,8 @@ def analyze(lines):
685
742
  LABEL_REF = re.compile(r"\.L[\w$.]+")
686
743
 
687
744
 
688
- def loop_spans(lines):
689
- """Return [(label, insns)] spans for cleaned asm lines.
745
+ def loop_span_ranges(lines):
746
+ """Return [(label, lo, hi)] line-index ranges for loop spans.
690
747
 
691
748
  A span is the run of instructions from a local label to the last
692
749
  instruction that references it from below — a backward branch, which
@@ -694,9 +751,7 @@ def loop_spans(lines):
694
751
  Xtensa zero-overhead loops (loop/loopnez/loopgt) reference their END
695
752
  label instead; there the span is the instructions the loop encloses.
696
753
  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.
754
+ labels yield nested spans.
700
755
  """
701
756
  label_at = {ln[:-1]: i for i, ln in enumerate(lines)
702
757
  if ln.endswith(":")}
@@ -719,13 +774,129 @@ def loop_spans(lines):
719
774
  lo = min(lo, spans[ref][0])
720
775
  hi = max(hi, spans[ref][1])
721
776
  spans[ref] = (lo, hi)
777
+ return [(ref, lo, hi) for ref, (lo, hi)
778
+ in sorted(spans.items(), key=lambda kv: kv[1])]
779
+
780
+
781
+ def loop_spans(lines):
782
+ """Return [(label, insns)] spans for cleaned asm lines.
783
+
784
+ The count states how many instructions lie in the span — nothing
785
+ about trip count or hotness, which the reader must judge from the
786
+ source.
787
+ """
722
788
  result = []
723
- for ref, (lo, hi) in sorted(spans.items(), key=lambda kv: kv[1]):
789
+ for ref, lo, hi in loop_span_ranges(lines):
724
790
  insns = sum(1 for ln in lines[lo:hi + 1] if not ln.endswith(":"))
725
791
  result.append((ref, insns))
726
792
  return result
727
793
 
728
794
 
795
+ # --span-stats buckets, by mnemonic table. Covers the targets the tool
796
+ # routinely meets: Xtensa, RISC-V, and ARM by exact (or dot-stripped)
797
+ # mnemonic; x86 AT&T by the memory-operand heuristic in classify_insn,
798
+ # since there loads and stores are mostly spellings of mov.
799
+ _LOAD_MNEMONICS = frozenset("""
800
+ l8ui l16ui l16si l32i l32i.n l32r l32e l32ai lsi lsiu lsx lsxu
801
+ lb lbu lh lhu lw lwu ld flw fld c.lw c.lwsp c.ld c.ldsp c.flw c.fld
802
+ ldr ldrb ldrh ldrsb ldrsh ldrd ldm ldmia vldr vldm
803
+ pop popl popq
804
+ """.split())
805
+ _STORE_MNEMONICS = frozenset("""
806
+ s8i s16i s32i s32i.n s32e s32ri s32c1i ssi ssiu ssx ssxu
807
+ sb sh sw sd fsw fsd c.sw c.swsp c.sd c.sdsp c.fsw c.fsd
808
+ str strb strh strd stm stmia vstr vstm
809
+ push pushl pushq
810
+ """.split())
811
+ # Any multiply, including multiply-accumulate and FP forms (mull,
812
+ # mulsh, mula.dd.*, fmadd.s, vmla.f32, imull, mulss, ...).
813
+ _MUL_PREFIXES = ("mul", "imul", "fmul", "fmadd", "fmsub", "fnmadd",
814
+ "fnmsub", "madd", "msub", "mla", "mls", "smul", "umul",
815
+ "smla", "umla", "vmul", "vmla", "vmls", "vfma", "vfms")
816
+ _BRANCH_MNEMONICS = frozenset("""
817
+ beq bne blt bge bltu bgeu beqz bnez beqi bnei blti bgei bltui bgeui
818
+ bbci bbsi bbc bbs bany bnone ball bnall bt bf
819
+ blez bgez bltz bgtz bgt ble bgtu bleu
820
+ b bl bx blx cbz cbnz bcs bcc bmi bpl bvs bvc bhi bls bal
821
+ jr jal jalr ret retw tail
822
+ c.j c.jr c.jal c.jalr c.beqz c.bnez
823
+ """.split())
824
+
825
+
826
+ def classify_insn(line):
827
+ """Bucket one cleaned instruction: load/store/mul/branch/other.
828
+
829
+ Branch means any control transfer, calls included (a span's
830
+ outbound calls are already itemised in the calls column). For
831
+ mnemonics no table knows, an AT&T-style memory operand decides
832
+ load (source side) vs store (destination, the last operand);
833
+ lea and alignment nops are excluded from that heuristic first.
834
+ """
835
+ parts = line.split(None, 1)
836
+ mnem = parts[0].lower()
837
+ rest = parts[1] if len(parts) > 1 else ""
838
+ base = mnem.split(".", 1)[0]
839
+ if mnem in _LOAD_MNEMONICS or base in _LOAD_MNEMONICS:
840
+ return "load"
841
+ if mnem in _STORE_MNEMONICS or base in _STORE_MNEMONICS:
842
+ return "store"
843
+ if base.startswith(_MUL_PREFIXES):
844
+ return "mul"
845
+ if mnem in _BRANCH_MNEMONICS or base in _BRANCH_MNEMONICS:
846
+ return "branch"
847
+ if base.startswith(("j", "call", "loop")):
848
+ return "branch"
849
+ if base.startswith(("lea", "nop")):
850
+ return "other"
851
+ if "(" in rest:
852
+ depth, cut = 0, -1
853
+ for i, ch in enumerate(rest):
854
+ if ch == "(":
855
+ depth += 1
856
+ elif ch == ")":
857
+ depth -= 1
858
+ elif ch == "," and depth == 0:
859
+ cut = i
860
+ return "store" if "(" in rest[cut + 1:] else "load"
861
+ return "other"
862
+
863
+
864
+ _MIX_KEYS = ("load", "store", "mul", "branch", "other")
865
+
866
+
867
+ def span_mix(lines):
868
+ """[{label, insns, load, store, mul, branch, other}] per loop span
869
+ of one function — the data behind --span-stats, table and JSON."""
870
+ result = []
871
+ for label, lo, hi in loop_span_ranges(lines):
872
+ body = [ln for ln in lines[lo:hi + 1] if not ln.endswith(":")]
873
+ counts = dict.fromkeys(_MIX_KEYS, 0)
874
+ for ln in body:
875
+ counts[classify_insn(ln)] += 1
876
+ entry = {"label": label, "insns": len(body)}
877
+ entry.update(counts)
878
+ result.append(entry)
879
+ return result
880
+
881
+
882
+ def span_stats_table(fn_names, funcs, max_width=None):
883
+ """Per-span instruction mix for the named functions.
884
+
885
+ One row per loop span: how many of its instructions load, store,
886
+ multiply, or branch. This weighs the span the way the doctrine
887
+ asks — the hand-written awk this replaces kept counting past the
888
+ loop end into the epilogue.
889
+ """
890
+ rows = [("function", "span", "insns") + _MIX_KEYS]
891
+ for name in fn_names:
892
+ for mix in span_mix(funcs[name]):
893
+ rows.append((name, mix["label"], str(mix["insns"]))
894
+ + tuple(str(mix[k]) for k in _MIX_KEYS))
895
+ if len(rows) == 1:
896
+ return "(no loop spans)"
897
+ return render_table(rows, max_width)
898
+
899
+
729
900
  def auto_pairs(names):
730
901
  """Pair old_X with new_X for every X present in both."""
731
902
  names = list(names)
@@ -941,10 +1112,62 @@ def build_matrix(cc_args, target_args, config, config_path, db_arg=None,
941
1112
 
942
1113
  # --verbose: print full compiler command lines and untrimmed stderr.
943
1114
  VERBOSE = False
1115
+ # --summary-only: print the stats tables and skip every listing. The
1116
+ # tables are the decision input; a listing is pulled on a second run
1117
+ # when a delta needs explaining.
1118
+ SUMMARY_ONLY = False
1119
+ # --span-stats: follow each stats table with the per-loop-span
1120
+ # instruction mix (see span_stats_table).
1121
+ SPAN_STATS = False
1122
+ # --json: collect summary records here instead of printing tables;
1123
+ # None means normal table output. main() dumps the collected list.
1124
+ JSON_OUT = None
1125
+
1126
+
1127
+ def span_stats_block(fn_names, funcs):
1128
+ """Print the per-span mix table after a stats table when
1129
+ --span-stats is on; a silent no-op otherwise."""
1130
+ if SPAN_STATS and JSON_OUT is None:
1131
+ print()
1132
+ print(span_stats_table(fn_names, funcs, table_width()))
1133
+
1134
+
1135
+ def json_record(name, lines, cc=None, tag=None, role=None):
1136
+ """One --json result: a summary-table row as data. cc is the
1137
+ resolved compiler command, tag the source/side label of a two-file
1138
+ comparison, role baseline/candidate for paired rows; each is
1139
+ omitted where the mode has no such notion."""
1140
+ insns, calls = analyze(lines)
1141
+ rec = {"function": name}
1142
+ if cc is not None:
1143
+ rec["cc"] = str(cc)
1144
+ if tag is not None:
1145
+ rec["tag"] = tag
1146
+ if role is not None:
1147
+ rec["role"] = role
1148
+ rec["insns"] = insns
1149
+ rec["loop_spans"] = [{"label": label, "insns": n}
1150
+ for label, n in loop_spans(lines)]
1151
+ rec["calls"] = calls
1152
+ if SPAN_STATS:
1153
+ rec["span_stats"] = span_mix(lines)
1154
+ return rec
944
1155
  # Without --verbose, a failed compile shows this many stderr lines — enough
945
1156
  # for the include chain plus the first error, which is the actionable part.
946
1157
  MAX_STDERR_LINES = 20
947
1158
 
1159
+ # Compilers skipped because their binary was missing, in matrix order.
1160
+ # The final no-usable-compiler error repeats them (via _skipped_suffix)
1161
+ # so the fix is visible in the error itself, not only in an earlier
1162
+ # stderr warning that may have scrolled away.
1163
+ _SKIPPED_CCS = []
1164
+
1165
+
1166
+ def _skipped_suffix():
1167
+ """" (cc: not found on PATH; ...)" naming every skipped compiler,
1168
+ empty when nothing was skipped."""
1169
+ return f" ({'; '.join(_SKIPPED_CCS)})" if _SKIPPED_CCS else ""
1170
+
948
1171
 
949
1172
  def _compile_failure(cmd, stderr):
950
1173
  """Exit for a failed compile.
@@ -989,6 +1212,9 @@ def compile_to_asm(cc_cmd, extra_flags, harness, out_dir):
989
1212
  if shutil.which(argv[0]) is None:
990
1213
  print(f"warning: {argv[0]} not found on PATH, skipping",
991
1214
  file=sys.stderr)
1215
+ miss = f"{argv[0]}: not found on PATH"
1216
+ if miss not in _SKIPPED_CCS:
1217
+ _SKIPPED_CCS.append(miss)
992
1218
  return None
993
1219
  db = getattr(cc_cmd, "compile_commands", None)
994
1220
  db_flags = (compile_commands_flags(
@@ -1039,14 +1265,53 @@ def mangled_pair_hint(names):
1039
1265
  return any(MANGLED_PAIR.match(n) for n in names)
1040
1266
 
1041
1267
 
1042
- def side_by_side(left, right, ltitle, rtitle, width=44):
1043
- """Two-column view of a pair's asm lines."""
1268
+ # --collapse keeps this many line pairs of context around each
1269
+ # differing pair; longer identical runs are elided with a count.
1270
+ COLLAPSE_CONTEXT = 3
1271
+ # --collapse: elide identical runs in side-by-side listings.
1272
+ COLLAPSE = False
1273
+
1274
+
1275
+ def side_by_side(left, right, ltitle, rtitle, width=44, collapse=False):
1276
+ """Two-column view of a pair's asm lines.
1277
+
1278
+ With collapse, runs of identical line pairs shrink to the
1279
+ COLLAPSE_CONTEXT pairs around each difference plus an elision
1280
+ marker — two ~500-insn functions differing by a handful of
1281
+ instructions render as a few hunks instead of ~1000 lines. The
1282
+ sides are aligned first (difflib), not paired by position: an
1283
+ inserted instruction gets a gap opposite it instead of desyncing
1284
+ every following pair.
1285
+ """
1044
1286
  rows = [f"{ltitle:<{width}} | {rtitle}",
1045
1287
  f"{'-' * width}-+-{'-' * width}"]
1046
- for l, r in zip_longest(left, right, fillvalue=""):
1288
+ if collapse:
1289
+ pairs = []
1290
+ matcher = difflib.SequenceMatcher(a=left, b=right, autojunk=False)
1291
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
1292
+ pairs += zip_longest(left[i1:i2], right[j1:j2], fillvalue="")
1293
+ keep = [False] * len(pairs)
1294
+ for i, (l, r) in enumerate(pairs):
1295
+ if l != r:
1296
+ for j in range(max(0, i - COLLAPSE_CONTEXT),
1297
+ min(len(pairs), i + COLLAPSE_CONTEXT + 1)):
1298
+ keep[j] = True
1299
+ else:
1300
+ pairs = list(zip_longest(left, right, fillvalue=""))
1301
+ keep = [True] * len(pairs)
1302
+ elided = 0
1303
+ for (l, r), kept in zip(pairs, keep):
1304
+ if not kept:
1305
+ elided += 1
1306
+ continue
1307
+ if elided:
1308
+ rows.append(f" ... {elided} identical lines ...")
1309
+ elided = 0
1047
1310
  l = l.expandtabs(8)[:width]
1048
1311
  r = r.expandtabs(8)[:width]
1049
1312
  rows.append(f"{l:<{width}} | {r}")
1313
+ if elided:
1314
+ rows.append(f" ... {elided} identical lines ...")
1050
1315
  return "\n".join(rows)
1051
1316
 
1052
1317
 
@@ -1187,7 +1452,8 @@ def file_tags(a, b):
1187
1452
  return str(a), str(b)
1188
1453
 
1189
1454
 
1190
- def report_across(fn_names, left_funcs, right_funcs, left_tag, right_tag):
1455
+ def report_across(fn_names, left_funcs, right_funcs, left_tag, right_tag,
1456
+ ccs=(None, None)):
1191
1457
  """Side-by-side + summary for the same functions from two compilations."""
1192
1458
  missing = sorted({f for f in fn_names
1193
1459
  if f not in left_funcs or f not in right_funcs})
@@ -1195,15 +1461,25 @@ def report_across(fn_names, left_funcs, right_funcs, left_tag, right_tag):
1195
1461
  sys.exit("error: function(s) not in asm: " + ", ".join(missing)
1196
1462
  + f"; {left_tag} has: " + (", ".join(left_funcs) or "none")
1197
1463
  + f"; {right_tag} has: " + (", ".join(right_funcs) or "none"))
1464
+ if JSON_OUT is not None:
1465
+ for f in fn_names:
1466
+ JSON_OUT.append(json_record(f, left_funcs[f], cc=ccs[0],
1467
+ tag=left_tag, role="baseline"))
1468
+ JSON_OUT.append(json_record(f, right_funcs[f], cc=ccs[1],
1469
+ tag=right_tag, role="candidate"))
1470
+ return
1198
1471
  decorated, pairs = {}, []
1199
1472
  for f in fn_names:
1200
1473
  lt, rt = f"{f} [{left_tag}]", f"{f} [{right_tag}]"
1201
1474
  decorated[lt], decorated[rt] = left_funcs[f], right_funcs[f]
1202
1475
  pairs.append((lt, rt))
1203
- for lt, rt in pairs:
1204
- print(side_by_side(decorated[lt], decorated[rt], lt, rt))
1205
- print()
1476
+ if not SUMMARY_ONLY:
1477
+ for lt, rt in pairs:
1478
+ print(side_by_side(decorated[lt], decorated[rt], lt, rt,
1479
+ collapse=COLLAPSE))
1480
+ print()
1206
1481
  print(summary_table(pairs, decorated, table_width()))
1482
+ span_stats_block([n for p in pairs for n in p], decorated)
1207
1483
 
1208
1484
 
1209
1485
  def run_across(sources, matrix, fn_names, extra_flags, tmp):
@@ -1227,10 +1503,13 @@ def run_across(sources, matrix, fn_names, extra_flags, tmp):
1227
1503
  ran_any = True
1228
1504
  tags = mark_db_misses(cc_cmd, sources,
1229
1505
  file_tags(sources[0], sources[1]))
1230
- print(f"\n== {cc_cmd} ==\n")
1231
- report_across(fn_names, sides[0], sides[1], *tags)
1506
+ if JSON_OUT is None:
1507
+ print(f"\n== {cc_cmd} ==\n")
1508
+ report_across(fn_names, sides[0], sides[1], *tags,
1509
+ ccs=(cc_cmd, cc_cmd))
1232
1510
  if not ran_any:
1233
- sys.exit("error: no usable compiler in the matrix")
1511
+ sys.exit("error: no usable compiler in the matrix"
1512
+ + _skipped_suffix())
1234
1513
  return 0
1235
1514
 
1236
1515
  usable = []
@@ -1240,14 +1519,17 @@ def run_across(sources, matrix, fn_names, extra_flags, tmp):
1240
1519
  usable.append((f"cc#{idx}", cc_cmd, extract_functions(asm)))
1241
1520
  if len(usable) < 2:
1242
1521
  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)
1522
+ "in the matrix" + _skipped_suffix())
1523
+ if JSON_OUT is None:
1524
+ print()
1525
+ for tag, cc_cmd, _ in usable:
1526
+ print(f"{tag}: {cc_cmd}")
1527
+ base_tag, base_cc, base_funcs = usable[0]
1528
+ for tag, cc_cmd, funcs in usable[1:]:
1529
+ if JSON_OUT is None:
1530
+ print(f"\n== {base_tag} vs {tag} ==\n")
1531
+ report_across(fn_names, base_funcs, funcs, base_tag, tag,
1532
+ ccs=(base_cc, cc_cmd))
1251
1533
  return 0
1252
1534
 
1253
1535
 
@@ -1268,15 +1550,25 @@ def run_summary(sources, matrix, extra_flags, tmp):
1268
1550
  ran_any = True
1269
1551
  shown = (mark_db_misses(cc_cmd, sources, tags)
1270
1552
  if len(sources) == 2 else tags)
1271
- print(f"\n== {cc_cmd} ==")
1553
+ if JSON_OUT is None:
1554
+ print(f"\n== {cc_cmd} ==")
1272
1555
  for tag, funcs in zip(shown, sections):
1556
+ if JSON_OUT is not None:
1557
+ JSON_OUT.extend(
1558
+ json_record(name, lines, cc=cc_cmd,
1559
+ tag=tag if len(sections) > 1 else None)
1560
+ for name, lines in funcs.items())
1561
+ continue
1273
1562
  if len(sections) > 1:
1274
1563
  print(f"\n-- {tag} --")
1275
1564
  print()
1276
1565
  print(file_summary_table(funcs, table_width()) if funcs
1277
1566
  else "(no functions found)")
1567
+ if funcs:
1568
+ span_stats_block(list(funcs), funcs)
1278
1569
  if not ran_any:
1279
- sys.exit("error: no usable compiler in the matrix")
1570
+ sys.exit("error: no usable compiler in the matrix"
1571
+ + _skipped_suffix())
1280
1572
  return 0
1281
1573
 
1282
1574
 
@@ -1300,22 +1592,39 @@ def run_pairs(source, matrix, pair_specs, extra_flags, tmp):
1300
1592
  print('note: C++ mangling defeats old_*/new_* auto-pairing; '
1301
1593
  'declare the pairs extern "C" or use --pair with the '
1302
1594
  'mangled names', file=sys.stderr)
1595
+ if JSON_OUT is not None:
1596
+ JSON_OUT.extend(json_record(name, lines, cc=cc_cmd)
1597
+ for name, lines in funcs.items())
1598
+ continue
1303
1599
  print(f"\n== {cc_cmd} ==\n")
1304
1600
  print(file_summary_table(funcs, table_width()) if funcs
1305
1601
  else "(no functions found)")
1602
+ if funcs:
1603
+ span_stats_block(list(funcs), funcs)
1306
1604
  continue
1307
1605
  missing = sorted({n for p in pairs for n in p if n not in funcs})
1308
1606
  if missing:
1309
1607
  sys.exit("error: function(s) not in asm: "
1310
1608
  + ", ".join(missing)
1311
1609
  + "; functions seen: " + ", ".join(funcs))
1610
+ if JSON_OUT is not None:
1611
+ for old, new in pairs:
1612
+ JSON_OUT.append(json_record(old, funcs[old], cc=cc_cmd,
1613
+ role="baseline"))
1614
+ JSON_OUT.append(json_record(new, funcs[new], cc=cc_cmd,
1615
+ role="candidate"))
1616
+ continue
1312
1617
  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()
1618
+ if not SUMMARY_ONLY:
1619
+ for old, new in pairs:
1620
+ print(side_by_side(funcs[old], funcs[new], old, new,
1621
+ collapse=COLLAPSE))
1622
+ print()
1316
1623
  print(summary_table(pairs, funcs, table_width()))
1624
+ span_stats_block([n for p in pairs for n in p], funcs)
1317
1625
  if not ran_any:
1318
- sys.exit("error: no usable compiler in the matrix")
1626
+ sys.exit("error: no usable compiler in the matrix"
1627
+ + _skipped_suffix())
1319
1628
  return 0
1320
1629
 
1321
1630
 
@@ -1333,13 +1642,19 @@ def run_inspect(source, matrix, fn_names, layout, extra_flags, tmp):
1333
1642
  if asm is not None:
1334
1643
  usable.append((f"cc#{idx}", cc_cmd, extract_functions(asm)))
1335
1644
  if not usable:
1336
- sys.exit("error: no usable compiler in the matrix")
1645
+ sys.exit("error: no usable compiler in the matrix"
1646
+ + _skipped_suffix())
1337
1647
  for _, cc_cmd, funcs in usable:
1338
1648
  missing = sorted({f for f in fn_names if f not in funcs})
1339
1649
  if missing:
1340
1650
  sys.exit("error: function(s) not in asm: " + ", ".join(missing)
1341
1651
  + f" under {cc_cmd}; functions seen: "
1342
1652
  + ", ".join(funcs))
1653
+ if JSON_OUT is not None:
1654
+ for _, cc_cmd, funcs in usable:
1655
+ JSON_OUT.extend(json_record(name, funcs[name], cc=cc_cmd)
1656
+ for name in fn_names)
1657
+ return 0
1343
1658
  if layout == "side-by-side" and len(usable) < 2:
1344
1659
  sys.exit("error: --layout side-by-side needs at least two usable "
1345
1660
  "compilers in the matrix")
@@ -1357,11 +1672,13 @@ def run_inspect(source, matrix, fn_names, layout, extra_flags, tmp):
1357
1672
  for _, cc_cmd, funcs in usable:
1358
1673
  if len(usable) > 1:
1359
1674
  print(f"\n== {cc_cmd} ==")
1360
- for name in fn_names:
1361
- print()
1362
- print(listing(name, funcs[name]))
1675
+ if not SUMMARY_ONLY:
1676
+ for name in fn_names:
1677
+ print()
1678
+ print(listing(name, funcs[name]))
1363
1679
  print()
1364
1680
  print(inspect_table(fn_names, funcs, table_width()))
1681
+ span_stats_block(fn_names, funcs)
1365
1682
  return 0
1366
1683
 
1367
1684
 
@@ -1420,11 +1737,16 @@ def run_elf(elf, fn_names, filter_regex, objdump):
1420
1737
  if not selected:
1421
1738
  sys.exit(f"error: --filter {filter_regex!r} matched no function "
1422
1739
  f"among the {len(funcs)} in {elf}")
1423
- for name in fn_names:
1424
- print()
1425
- print(listing(name, funcs[name]))
1740
+ if JSON_OUT is not None:
1741
+ JSON_OUT.extend(json_record(name, funcs[name]) for name in selected)
1742
+ return 0
1743
+ if not SUMMARY_ONLY:
1744
+ for name in fn_names:
1745
+ print()
1746
+ print(listing(name, funcs[name]))
1426
1747
  print()
1427
1748
  print(inspect_table(selected, funcs, table_width()))
1749
+ span_stats_block(selected, funcs)
1428
1750
  return 0
1429
1751
 
1430
1752
 
@@ -1487,6 +1809,14 @@ def main(argv=None):
1487
1809
  help=f"config file; default search: {CONFIG_NAME} "
1488
1810
  "next to SOURCE.c, in the current directory, "
1489
1811
  "then in ~/.config/")
1812
+ parser.add_argument("--db-includes", action="store_true",
1813
+ help="borrow only the header-search paths from the "
1814
+ "database (re-emitted as -idirafter, so they "
1815
+ "never shadow the host's system headers), "
1816
+ "dropping its defines, forced includes, and "
1817
+ "-specs/--sysroot - lets a host/foreign-arch "
1818
+ "target resolve a cross project's headers "
1819
+ "without inheriting cross-only flags")
1490
1820
  parser.add_argument("--flags-like", metavar="PATH",
1491
1821
  help="when a source has no compile_commands entry, "
1492
1822
  "borrow the include/define flags recorded for "
@@ -1502,14 +1832,45 @@ def main(argv=None):
1502
1832
  help="print the built-in example config (the "
1503
1833
  "repository's asmdiff.example.toml) to stdout, "
1504
1834
  "ready to redirect into a config file")
1835
+ parser.add_argument("--span-stats", action="store_true",
1836
+ help="follow each stats table with a per-loop-span "
1837
+ "instruction mix (load/store/mul/branch/other "
1838
+ "counts) - weighs the span instead of the "
1839
+ "whole function")
1840
+ parser.add_argument("--collapse", action="store_true",
1841
+ help="in side-by-side listings, elide runs of "
1842
+ "identical line pairs, keeping "
1843
+ f"{COLLAPSE_CONTEXT} lines of context around "
1844
+ "each difference - near-identical functions "
1845
+ "render as a few hunks")
1846
+ parser.add_argument("--json", action="store_true",
1847
+ help="emit the summary as JSON on stdout instead "
1848
+ "of tables: one record per function per "
1849
+ "compiler (insns, loop spans, calls; span "
1850
+ "mix with --span-stats). Implies "
1851
+ "--summary-only; errors stay plain text on "
1852
+ "stderr")
1853
+ parser.add_argument("-s", "--summary-only", action="store_true",
1854
+ help="print only the summary/stats tables, "
1855
+ "suppressing every assembly listing - the "
1856
+ "scripted-caller view (pull a listing with a "
1857
+ "second run when a delta needs explaining)")
1858
+ parser.add_argument("--version", action="version",
1859
+ version=f"asmdiff {__version__}")
1505
1860
  parser.add_argument("-v", "--verbose", action="store_true",
1506
1861
  help="on compile failure, print the full compiler "
1507
1862
  "command and complete error output instead of "
1508
1863
  "the first lines")
1509
1864
  args = parser.parse_args(argv)
1510
- global VERBOSE, FLAGS_LIKE
1865
+ global VERBOSE, FLAGS_LIKE, SUMMARY_ONLY, COLLAPSE, SPAN_STATS
1866
+ global DB_INCLUDES, JSON_OUT
1511
1867
  VERBOSE = args.verbose
1512
1868
  FLAGS_LIKE = args.flags_like
1869
+ SUMMARY_ONLY = args.summary_only or args.json
1870
+ COLLAPSE = args.collapse
1871
+ SPAN_STATS = args.span_stats
1872
+ DB_INCLUDES = args.db_includes
1873
+ JSON_OUT = [] if args.json else None
1513
1874
 
1514
1875
  if args.example_config:
1515
1876
  sys.stdout.write(EXAMPLE_CONFIG)
@@ -1542,7 +1903,12 @@ def main(argv=None):
1542
1903
  sys.exit("error: no gcc in the matrix to derive an objdump "
1543
1904
  "from (" + "; ".join(matrix) + "); pass --objdump "
1544
1905
  "PATH or a gcc-based --cc/--target")
1545
- return run_elf(sources[0], fn_names, args.filter, objdump)
1906
+ status = run_elf(sources[0], fn_names, args.filter, objdump)
1907
+ if JSON_OUT is not None:
1908
+ print(json.dumps({"asmdiff": __version__, "mode": "elf",
1909
+ "elf": sources[0], "results": JSON_OUT},
1910
+ indent=2))
1911
+ return status
1546
1912
  if args.filter or args.objdump:
1547
1913
  parser.error("--filter/--objdump apply to ELF input only")
1548
1914
  if len(sources) > 2:
@@ -1573,15 +1939,24 @@ def main(argv=None):
1573
1939
 
1574
1940
  with tempfile.TemporaryDirectory(prefix="asmdiff") as tmp:
1575
1941
  if fn_names:
1576
- return run_inspect(sources[0], matrix, fn_names, args.layout,
1942
+ mode = "inspect"
1943
+ status = run_inspect(sources[0], matrix, fn_names, args.layout,
1944
+ extra_flags, tmp)
1945
+ elif args.across:
1946
+ mode = "across"
1947
+ status = run_across(sources, matrix, args.across,
1948
+ extra_flags, tmp)
1949
+ elif len(sources) == 2:
1950
+ mode = "summary"
1951
+ status = run_summary(sources, matrix, extra_flags, tmp)
1952
+ else:
1953
+ mode = "pairs"
1954
+ status = run_pairs(sources[0], matrix, args.pair,
1577
1955
  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)
1956
+ if JSON_OUT is not None:
1957
+ print(json.dumps({"asmdiff": __version__, "mode": mode,
1958
+ "results": JSON_OUT}, indent=2))
1959
+ return status
1585
1960
 
1586
1961
 
1587
1962
  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,,