asmdiff 0.1.7__tar.gz → 0.2.0__tar.gz

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.
Files changed (35) hide show
  1. asmdiff-0.2.0/2026-07-07-codegen-hotspots-handoff.md:Zone.Identifier +0 -0
  2. asmdiff-0.2.0/2026-07-07-table-layout-handoff.md +90 -0
  3. asmdiff-0.1.7/README.md → asmdiff-0.2.0/PKG-INFO +77 -6
  4. asmdiff-0.1.7/PKG-INFO → asmdiff-0.2.0/README.md +59 -24
  5. asmdiff-0.2.0/amy-hotspot-analysis.md +214 -0
  6. {asmdiff-0.1.7 → asmdiff-0.2.0}/asmdiff.py +2 -2
  7. asmdiff-0.2.0/demo/esp32-matrix.gif +0 -0
  8. asmdiff-0.2.0/demo/quickstart.gif +0 -0
  9. asmdiff-0.2.0/docs/superpowers/plans/2026-07-08-asmdiff-demo-gifs.md +277 -0
  10. asmdiff-0.2.0/docs/superpowers/specs/2026-07-08-asmdiff-demo-gifs-design.md +147 -0
  11. asmdiff-0.2.0/docs/superpowers/specs/2026-07-08-asmdiff-demo-gifs-pipeline.mmd +16 -0
  12. asmdiff-0.2.0/gif-plan.md +248 -0
  13. asmdiff-0.2.0/how-could-i-create-velvety-hollerith.md:Zone.Identifier +3 -0
  14. {asmdiff-0.1.7 → asmdiff-0.2.0}/pyproject.toml +2 -2
  15. {asmdiff-0.1.7 → asmdiff-0.2.0}/test_asmdiff.py +40 -0
  16. {asmdiff-0.1.7 → asmdiff-0.2.0}/.claude/settings.local.json +0 -0
  17. {asmdiff-0.1.7 → asmdiff-0.2.0}/.gitignore +0 -0
  18. {asmdiff-0.1.7 → asmdiff-0.2.0}/2026-07-06-inspect-mode.md +0 -0
  19. {asmdiff-0.1.7 → asmdiff-0.2.0}/2026-07-07-elf-mode-handoff.md +0 -0
  20. {asmdiff-0.1.7 → asmdiff-0.2.0}/CLAUDE.md +0 -0
  21. {asmdiff-0.1.7 → asmdiff-0.2.0}/FIELD-TEST-REPORT.md +0 -0
  22. {asmdiff-0.1.7 → asmdiff-0.2.0}/LICENSE +0 -0
  23. {asmdiff-0.1.7 → asmdiff-0.2.0}/Screenshot 2026-07-07 225145.jpg +0 -0
  24. {asmdiff-0.1.7 → asmdiff-0.2.0}/asmdiff.example.toml +0 -0
  25. {asmdiff-0.1.7 → asmdiff-0.2.0}/asmdiff_example.c +0 -0
  26. {asmdiff-0.1.7 → asmdiff-0.2.0}/dist_1/.gitignore +0 -0
  27. {asmdiff-0.1.7 → asmdiff-0.2.0}/dist_1/asmdiff-0.1.1-py3-none-any.whl +0 -0
  28. {asmdiff-0.1.7 → asmdiff-0.2.0}/dist_1/asmdiff-0.1.1.tar.gz +0 -0
  29. {asmdiff-0.1.7 → asmdiff-0.2.0}/dist_1/asmdiff-0.1.2-py3-none-any.whl +0 -0
  30. {asmdiff-0.1.7 → asmdiff-0.2.0}/dist_1/asmdiff-0.1.2.tar.gz +0 -0
  31. {asmdiff-0.1.7 → asmdiff-0.2.0}/dist_1/asmdiff-0.1.3-py3-none-any.whl +0 -0
  32. {asmdiff-0.1.7 → asmdiff-0.2.0}/dist_1/asmdiff-0.1.3.tar.gz +0 -0
  33. {asmdiff-0.1.7 → asmdiff-0.2.0}/dist_1/asmdiff-0.1.4-py3-none-any.whl +0 -0
  34. {asmdiff-0.1.7 → asmdiff-0.2.0}/dist_1/asmdiff-0.1.4.tar.gz +0 -0
  35. {asmdiff-0.1.7 → asmdiff-0.2.0}/docs/superpowers/specs/2026-07-07-edit-config-design.md +0 -0
@@ -0,0 +1,90 @@
1
+ # asmdiff summary-table legibility - checkpoint 2026-07-07
2
+
3
+ Handoff for a fresh session in THIS repo (`~/esp-idf/tools/asmdiff`).
4
+ Feature is COMPLETE and verified, NOTHING COMMITTED. Origin: ELF-mode
5
+ output (`asmdiff build/S3-Amysynth.elf --filter 'core_'`) was unreadable -
6
+ every row hard-wrapped in the terminal (screenshot in repo root,
7
+ `Screenshot 2026-07-07 225145.jpg`).
8
+
9
+ ## Root cause
10
+
11
+ `render_table` pads every column to its widest cell. The `calls` column
12
+ sat in the middle; with real firmware call lists (~150 chars at the
13
+ 8-callee cap) plus 50-char LTO symbol names, `loop spans` started past
14
+ column ~210 and every row wrapped: dangling `-` lines, split headers,
15
+ call lists colliding with the next function name.
16
+
17
+ ## What changed (asmdiff.py)
18
+
19
+ 1. **Column order**: all three builders now emit
20
+ `function / [role] / insns / loop spans / calls` - calls (the one
21
+ unbounded column) moved last, ragged right. Builders:
22
+ `summary_table`, `file_summary_table` (TOTAL row spans cell stays
23
+ `-`), `inspect_table`. Each gained a `max_width=None` passthrough
24
+ param.
25
+ 2. **Width-aware trim**: `render_table(rows, max_width=None)` - when
26
+ set, fixed columns keep full width, the last column's over-budget
27
+ cells are trimmed by `_fit_calls`: whole callees dropped from the
28
+ end, closed with `Total Calls:N` (N = full callee count, preserved
29
+ from an existing summary). The FIRST callee is never dropped - a
30
+ single huge symbol overflows/wraps instead of vanishing
31
+ (`sequencer_core_set_melodic_envelope2` is the real-world case).
32
+ Cells that fit, `-`, and headers are untouched. `TOTAL_CALLS_RE`
33
+ guards summary parsing.
34
+ 3. **`format_calls`** cap suffix changed from `+N more` to
35
+ `Total Calls:N` (user-requested format; one consistent suffix for
36
+ both the 8-callee cap and the width trim).
37
+ 4. **`table_width()`**: `shutil.get_terminal_size().columns` when
38
+ `sys.stdout.isatty()`, else `None` - piped/redirected output skips
39
+ the trim so it stays deterministic and greppable. Passed at all six
40
+ print sites (both summary_table sites, both file_summary_table
41
+ sites, both inspect_table sites).
42
+
43
+ All new code sits together around `render_table` (~line 1053 region).
44
+
45
+ ## Tests (test_asmdiff.py)
46
+
47
+ - New `TestTableMaxWidth` (9 tests): trim-to-fit, fitting cell left
48
+ alone, merges existing `Total Calls:N` (does not recount), single
49
+ callee never dropped, dash untouched, `max_width=None` unchanged,
50
+ builder passthrough, `table_width()` tty/piped (via `mock.patch` on
51
+ `asmdiff.sys.stdout.isatty` / `asmdiff.shutil.get_terminal_size`).
52
+ - Updated for new column order / suffix: `TestFormatCalls`
53
+ `test_long_list_capped`, `test_summary_table`,
54
+ `test_summary_table_loop_spans_column`,
55
+ `test_file_summary_totals_and_call_union`, both
56
+ `TestInspectRendering` inspect_table tests.
57
+ - Suite: 191 tests, sole failure the KNOWN env-dependent
58
+ `TestShortAliases.test_p_is_pair` (global ~/.config/asmdiff.toml with
59
+ target esp32s3 short-circuits it; fails at HEAD too).
60
+
61
+ ## Docs
62
+
63
+ README: all 8 example tables reordered; the truncation paragraph (ELF
64
+ section, "Only labels the assembler types...") now documents
65
+ `Total Calls:N`, the tty-only width trim, and the piped-output
66
+ exemption; intro prose order updated. Module docstring summary line
67
+ reordered to match. No flags or config keys changed, so
68
+ `asmdiff.example.toml` / `EXAMPLE_CONFIG` are untouched (the
69
+ byte-match unit test still applies to any future config edit).
70
+
71
+ ## Verification done
72
+
73
+ - Full suite as above.
74
+ - Real ELF (`~/esp-idf/S3-Amysynth/build/S3-Amysynth.elf`, config-derived
75
+ xtensa objdump), `--filter 'core_'`:
76
+ - piped: full capped lists, new column order, aligned.
77
+ - fake tty via `script -qec "stty cols 190 ..."`: all rows one line
78
+ except the designed first-callee-floor overflow (4 chars over).
79
+ - 110-col tty: fixed columns alone can exceed width when a loopy
80
+ function widens `loop spans`; calls floor at one callee - accepted
81
+ graceful degradation, only `calls` ever trims (scope decision).
82
+
83
+ ## Next steps
84
+
85
+ 1. Commit (user has not asked yet; suggested scope: asmdiff.py,
86
+ test_asmdiff.py, README.md in one commit - pure output-format
87
+ change, no behavior change to analysis).
88
+ 2. Optional follow-up the user explicitly deferred: making the
89
+ `loop spans` cells themselves friendlier (e.g. "2 ZOL + 3 br"
90
+ summary) - scoped OUT of this change ("layout only").
@@ -1,7 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: asmdiff
3
+ Version: 0.2.0
4
+ Summary: Per-function assembly inspection and comparison, across a compiler matrix or from a shipped ELF
5
+ Project-URL: Homepage, https://github.com/rt-rtos/asmdiff
6
+ Project-URL: Repository, https://github.com/rt-rtos/asmdiff
7
+ Author: Rasmus Tikkanen
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: arm,assembly,cfg,clang,cli,codegen,compiler,devtools,diff,disassembly,dsp,embedded,firmware,gcc,loop-analysis,objdump,riscv,static-analysis,xtensa
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Compilers
15
+ Classifier: Topic :: Software Development :: Debuggers
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+
1
19
  # [asmdiff](https://pypi.org/project/asmdiff/)
2
- ## per-function assembly comparison for paired C implementations
20
+ ## per-function assembly inspection and comparison, across a compiler matrix or from a shipped ELF
21
+
22
+ > asmdiff is a stdlib only command-line tool for comparing the generated assembly of individual C functions across implementations, compiler flags, compiler versions, and source revisions. It is intended for
23
+ investigating compiler code generation rather than benchmarking runtime performance.
24
+
25
+ <details>
26
+
27
+ <summary>Quickstart Example</summary>
28
+
29
+ [![asmdiff comparing gcc and clang assembly for paired C functions, with a fold-vs-libcall summary](demo/quickstart.gif)](demo/quickstart.gif)
30
+
31
+ *Each function appears twice - `old_*` and `new_*`, the same routine
32
+ before and after a rewrite - compiled across a gcc/clang matrix and diffed
33
+ side by side. The summary's `insns` and `calls` columns are the payoff:
34
+ `old_rt`'s 9-instruction `exp2f` call collapses to `new_rt`'s 2-instruction
35
+ `ldexpf`. (Click to enlarge.)*
3
36
 
4
- > asmdiff is a stdlib only command-line tool for comparing the generated assembly of individual C functions across implementations, compiler flags, compiler versions, and source revisions. It is intended for investigating compiler code generation rather than benchmarking runtime performance.
37
+ ---
38
+
39
+ </details>
5
40
 
6
41
  ### Try it yourself:
7
42
 
@@ -255,9 +290,10 @@ everywhere else; two things are ELF-specific:
255
290
  evidence is missing or the register is overwritten in between -
256
291
  a genuine function-pointer dispatch still reads as indirect.
257
292
 
258
- What this looks like on real firmware: the same binary's sequencer
259
- core (project code, not a synth library) surfaces three findings in
260
- one row scan.
293
+ What this looks like on real firmware: this binary's sequencer path -
294
+ here in the vendored AMY synth engine (`components/amy/src/sequencer.c`,
295
+ with the divide sourced from `amy_sysclock()` in `api.c`) - surfaces
296
+ three findings in one row scan.
261
297
 
262
298
  ```
263
299
  $ asmdiff build/S3-Amysynth.elf --filter 'sequencer_(process_tick|recompute|timer_callback)'
@@ -271,7 +307,12 @@ sequencer_process_tick$lto_priv$0 117 .L2e:96 .L48:85 .L8d:6 xQueueSemaph
271
307
  A timer callback pays a software float divide *and* a 64-bit
272
308
  `__udivdi3` inside its 41-instruction loop - the
273
309
  [silent software divide](#example-catching-a-silent-software-divide)
274
- pattern, caught in shipped firmware instead of a harness.
310
+ pattern, caught in shipped firmware instead of a harness. asmdiff was
311
+ built to hunt exactly this in AMY, and this callback was the payoff: the
312
+ pairing was confirmed as both a codegen cost and an upstream correctness
313
+ bug - `amy_sysclock()`'s `uint32_t` sample count wraps far sooner than
314
+ its comment claims and loses precision on long uptimes - and is fixed
315
+ locally with a PR pending to shorepine/amy.
275
316
  `sequencer_recompute`'s `__extendsfdf2 -> __divdf3 -> __muldf3` chain
276
317
  is the double-promotion smell (typically an unsuffixed `60.0`-style
277
318
  literal) on a chip whose FPU is single-precision only. And the
@@ -535,6 +576,35 @@ Details that make it robust:
535
576
 
536
577
  ### Auto-discovering the database
537
578
 
579
+ <details>
580
+
581
+ <summary>Real Project Example</summary>
582
+
583
+ #### All Esp32 Xtensa toolchains in one call from the same compile_commands
584
+
585
+ Below, `-db` (short for `--compile-commands`) does this on a real ESP-IDF
586
+ synth firmware: one `synth_core` effects source, compiled across the
587
+ esp32 / esp32s2 / esp32s3 cross-toolchains in a single run, with each
588
+ chip's include/define flags auto-borrowed from `build/compile_commands.json`
589
+ so the component actually compiles. No `--target` and no path are given -
590
+ the config's `default` names the three chips and `-db` walks up to find the
591
+ database. Reading it left to right: the three `==` banners are the three
592
+ toolchains; each table is that chip's per-function `insns` / loop spans /
593
+ `calls`. The tell is in the `calls` column - the esp32-S2 (the one variant
594
+ with no hardware FPU) picks up softfloat conversion libcalls
595
+ `__floatsisf`, `__floatunsisf`, `__ltsf2` that its FPU-equipped siblings
596
+ never emit, so the same source is measurably heavier there:
597
+
598
+ [![asmdiff running a three-chip xtensa matrix (esp32/s2/s3) on one firmware source via -db](demo/esp32-matrix.gif)](demo/esp32-matrix.gif)
599
+
600
+ *One source, three real chip toolchains, build flags borrowed
601
+ automatically - the kind of comparison that otherwise needs three separate
602
+ build trees. (Click to enlarge.)*
603
+
604
+ ---
605
+
606
+ </details>
607
+
538
608
  When you run asmdiff from inside the project anyway, the path is
539
609
  redundant. Two opt-ins skip it:
540
610
 
@@ -550,6 +620,7 @@ asmdiff biquad.c --compile-commands # same, for any matrix
550
620
  asmdiff biquad.c --compile-commands path/to/compile_commands.json
551
621
  ```
552
622
 
623
+
553
624
  Both walk up from the current directory, checking each level for
554
625
  `compile_commands.json` and then `build/compile_commands.json` (where
555
626
  CMake and idf.py leave it) — first hit wins. The walk stops at the
@@ -1,25 +1,24 @@
1
- Metadata-Version: 2.4
2
- Name: asmdiff
3
- Version: 0.1.7
4
- Summary: Compare per-function assembly between paired C implementations
5
- Project-URL: Homepage, https://github.com/rt-rtos/asmdiff
6
- Project-URL: Repository, https://github.com/rt-rtos/asmdiff
7
- Author: Rasmus Tikkanen
8
- License-Expression: MIT
9
- License-File: LICENSE
10
- Keywords: arm,assembly,cfg,clang,cli,codegen,compiler,devtools,diff,disassembly,dsp,embedded,firmware,gcc,loop-analysis,objdump,riscv,static-analysis,xtensa
11
- Classifier: Environment :: Console
12
- Classifier: Intended Audience :: Developers
13
- Classifier: Programming Language :: Python :: 3
14
- Classifier: Topic :: Software Development :: Compilers
15
- Classifier: Topic :: Software Development :: Debuggers
16
- Requires-Python: >=3.8
17
- Description-Content-Type: text/markdown
18
-
19
1
  # [asmdiff](https://pypi.org/project/asmdiff/)
20
- ## per-function assembly comparison for paired C implementations
2
+ ## per-function assembly inspection and comparison, across a compiler matrix or from a shipped ELF
3
+
4
+ > asmdiff is a stdlib only command-line tool for comparing the generated assembly of individual C functions across implementations, compiler flags, compiler versions, and source revisions. It is intended for
5
+ investigating compiler code generation rather than benchmarking runtime performance.
6
+
7
+ <details>
8
+
9
+ <summary>Quickstart Example</summary>
10
+
11
+ [![asmdiff comparing gcc and clang assembly for paired C functions, with a fold-vs-libcall summary](demo/quickstart.gif)](demo/quickstart.gif)
12
+
13
+ *Each function appears twice - `old_*` and `new_*`, the same routine
14
+ before and after a rewrite - compiled across a gcc/clang matrix and diffed
15
+ side by side. The summary's `insns` and `calls` columns are the payoff:
16
+ `old_rt`'s 9-instruction `exp2f` call collapses to `new_rt`'s 2-instruction
17
+ `ldexpf`. (Click to enlarge.)*
21
18
 
22
- > asmdiff is a stdlib only command-line tool for comparing the generated assembly of individual C functions across implementations, compiler flags, compiler versions, and source revisions. It is intended for investigating compiler code generation rather than benchmarking runtime performance.
19
+ ---
20
+
21
+ </details>
23
22
 
24
23
  ### Try it yourself:
25
24
 
@@ -273,9 +272,10 @@ everywhere else; two things are ELF-specific:
273
272
  evidence is missing or the register is overwritten in between -
274
273
  a genuine function-pointer dispatch still reads as indirect.
275
274
 
276
- What this looks like on real firmware: the same binary's sequencer
277
- core (project code, not a synth library) surfaces three findings in
278
- one row scan.
275
+ What this looks like on real firmware: this binary's sequencer path -
276
+ here in the vendored AMY synth engine (`components/amy/src/sequencer.c`,
277
+ with the divide sourced from `amy_sysclock()` in `api.c`) - surfaces
278
+ three findings in one row scan.
279
279
 
280
280
  ```
281
281
  $ asmdiff build/S3-Amysynth.elf --filter 'sequencer_(process_tick|recompute|timer_callback)'
@@ -289,7 +289,12 @@ sequencer_process_tick$lto_priv$0 117 .L2e:96 .L48:85 .L8d:6 xQueueSemaph
289
289
  A timer callback pays a software float divide *and* a 64-bit
290
290
  `__udivdi3` inside its 41-instruction loop - the
291
291
  [silent software divide](#example-catching-a-silent-software-divide)
292
- pattern, caught in shipped firmware instead of a harness.
292
+ pattern, caught in shipped firmware instead of a harness. asmdiff was
293
+ built to hunt exactly this in AMY, and this callback was the payoff: the
294
+ pairing was confirmed as both a codegen cost and an upstream correctness
295
+ bug - `amy_sysclock()`'s `uint32_t` sample count wraps far sooner than
296
+ its comment claims and loses precision on long uptimes - and is fixed
297
+ locally with a PR pending to shorepine/amy.
293
298
  `sequencer_recompute`'s `__extendsfdf2 -> __divdf3 -> __muldf3` chain
294
299
  is the double-promotion smell (typically an unsuffixed `60.0`-style
295
300
  literal) on a chip whose FPU is single-precision only. And the
@@ -553,6 +558,35 @@ Details that make it robust:
553
558
 
554
559
  ### Auto-discovering the database
555
560
 
561
+ <details>
562
+
563
+ <summary>Real Project Example</summary>
564
+
565
+ #### All Esp32 Xtensa toolchains in one call from the same compile_commands
566
+
567
+ Below, `-db` (short for `--compile-commands`) does this on a real ESP-IDF
568
+ synth firmware: one `synth_core` effects source, compiled across the
569
+ esp32 / esp32s2 / esp32s3 cross-toolchains in a single run, with each
570
+ chip's include/define flags auto-borrowed from `build/compile_commands.json`
571
+ so the component actually compiles. No `--target` and no path are given -
572
+ the config's `default` names the three chips and `-db` walks up to find the
573
+ database. Reading it left to right: the three `==` banners are the three
574
+ toolchains; each table is that chip's per-function `insns` / loop spans /
575
+ `calls`. The tell is in the `calls` column - the esp32-S2 (the one variant
576
+ with no hardware FPU) picks up softfloat conversion libcalls
577
+ `__floatsisf`, `__floatunsisf`, `__ltsf2` that its FPU-equipped siblings
578
+ never emit, so the same source is measurably heavier there:
579
+
580
+ [![asmdiff running a three-chip xtensa matrix (esp32/s2/s3) on one firmware source via -db](demo/esp32-matrix.gif)](demo/esp32-matrix.gif)
581
+
582
+ *One source, three real chip toolchains, build flags borrowed
583
+ automatically - the kind of comparison that otherwise needs three separate
584
+ build trees. (Click to enlarge.)*
585
+
586
+ ---
587
+
588
+ </details>
589
+
556
590
  When you run asmdiff from inside the project anyway, the path is
557
591
  redundant. Two opt-ins skip it:
558
592
 
@@ -568,6 +602,7 @@ asmdiff biquad.c --compile-commands # same, for any matrix
568
602
  asmdiff biquad.c --compile-commands path/to/compile_commands.json
569
603
  ```
570
604
 
605
+
571
606
  Both walk up from the current directory, checking each level for
572
607
  `compile_commands.json` and then `build/compile_commands.json` (where
573
608
  CMake and idf.py leave it) — first hit wins. The walk stops at the
@@ -0,0 +1,214 @@
1
+ # S3-Amysynth codegen hotspots - checkpoint 2026-07-07
2
+
3
+ Handoff for a fresh session in THIS repo (`~/esp-idf/S3-Amysynth`). Origin:
4
+ field-testing asmdiff's new ELF input mode (built in `~/esp-idf/tools/asmdiff`,
5
+ its own handoff: `2026-07-07-elf-mode-handoff.md` there) against
6
+ `build/S3-Amysynth.elf` surfaced likely codegen problems in NON-amy code
7
+ (synth_core). All evidence is disassembly-level; no source has been read and
8
+ no code changed. The amy-owned side is already tracked in
9
+ `docs/zol-audit-2026-07-07.md` and is out of scope here.
10
+
11
+ ## Evidence base
12
+
13
+ - ELF: `build/S3-Amysynth.elf` built 2026-07-07 ~16:32, esp-15.2.0
14
+ xtensa-esp32s3-elf, -O2 + LTO. A rebuild invalidates the exact
15
+ `.L<off>` labels quoted below; function-level facts should survive.
16
+ - Tool: `asmdiff build/S3-Amysynth.elf FUNC...` or `--filter REGEX`,
17
+ run from this repo (objdump is auto-derived from the esp32s3 target
18
+ in `~/.config/asmdiff.toml`; `python3 ~/esp-idf/tools/asmdiff/asmdiff.py`
19
+ if the package isn't installed). Longcalls are resolved to real
20
+ callees; a bare `aN` in the calls column is genuine function-pointer
21
+ dispatch. LTO renames survivors (`$lto_priv$0`, `$constprop$0`,
22
+ `$isra$0`), so sweep with `--filter`, don't guess names.
23
+
24
+ ## Findings, ordered by suspected payoff
25
+
26
+ 1. **`sequencer_timer_callback$lto_priv$0`** - 77 insns, calls
27
+ `__divsf3` AND `__udivdi3`, and its single loop span is `.L21:41`,
28
+ i.e. most of the function repeats. A software float divide plus a
29
+ 64-bit integer divide in a timer callback is the exact pattern the
30
+ asmdiff README's "silent software divide" example warns about.
31
+ Hypothesis: per-callback recomputation of a period/rate that only
32
+ changes on tempo edits. Fix shape: precompute at tempo-change time,
33
+ or narrow to 32-bit / reciprocal-multiply math.
34
+
35
+ 2. **`sequencer_recompute`** - 46 insns, call chain `__extendsfdf2 ->
36
+ __divdf3 -> __muldf3 -> __fixunsdfsi` plus `__divsf3`, no loops.
37
+ That chain is the double-promotion smell: the S3 FPU is
38
+ single-precision only, so an unsuffixed literal (`60.0`, `1000.0`)
39
+ or a non-f math call promotes the whole expression to softdouble.
40
+ Fix is usually mechanical (f-suffix literals, float-only helpers).
41
+ Likely called only on tempo change - cheap to fix, but confirm call
42
+ rate before counting it as a win.
43
+
44
+ 3. **`arp_rebuild$lto_priv$0`** - 362 insns, spans `.L29:319` /
45
+ `.L77:318` containing `__divsf3` and queue sends. Config-path code
46
+ (runs on patch/settings changes), so probably acceptable - look
47
+ only if arp edits audibly stutter.
48
+
49
+ 4. **`sequencer_process_tick$lto_priv$0`** - 117 insns, loops
50
+ `.L2e:96` / `.L48:85` containing `xQueueSemaphoreTake` /
51
+ `xQueueGenericSend` and one indirect call (`a8`). Per-tick queue
52
+ traffic is an architecture fact, not a bug; noted as the place to
53
+ look during any future tick-latency work.
54
+
55
+ Checked and healthy (no action): `quantizer_snap_midi_note` (51 insns,
56
+ call-free, divide-by-12 folded to mulsh, inner 12-insn scan shipped as
57
+ a ZOL nested in a branch loop over octaves), `arp_core_refresh` /
58
+ `arp_collect_up$lto_priv$0` (call-free collect loops, one ZOL),
59
+ `render_clock_on_alarm$lto_priv$0` (13 insns, just the ISR notify),
60
+ the `arp_set_*` setters (all tiny). Fully inlined by LTO, confirming
61
+ the audit's picture: `arp_collect_down`, `arp_snap`, `arp_sort_asc`
62
+ (bodies live inside `arp_core_refresh`).
63
+
64
+ ## Domain facts to reuse
65
+
66
+ - ZOL kill rules (verified on esp-15.2.0 -O2, now documented in the
67
+ asmdiff README's ELF section): one surviving call anywhere in the
68
+ loop body kills ZOL even behind a never-taken condition; early
69
+ `break` kills it; a spilled loop counter kills it; nested loops get
70
+ ZOL on the innermost only.
71
+ - asmdiff's branch-span counts include unconditional backward `j`
72
+ latches, so they read higher than the July audit's awk (which
73
+ counted conditional backedges only). ZOL counts are identical.
74
+ - objdump quirk seen once: a function-tail `retw.n` misframed with a
75
+ padding byte makes a few branches print as `sym+0xNN` instead of a
76
+ synthesized label (`quantizer_snap_midi_note+0x88`). Cosmetic;
77
+ spans and call counts are unaffected.
78
+
79
+ ## Hypotheses
80
+
81
+ Stated inline per finding above; none tested against source yet. The
82
+ whole list is one build old - re-run the sweeps before acting:
83
+
84
+ ```
85
+ asmdiff build/S3-Amysynth.elf --filter 'sequencer_(process_tick|recompute|timer_callback|add_event)'
86
+ asmdiff build/S3-Amysynth.elf --filter '^arp_'
87
+ ```
88
+
89
+ ## Next steps
90
+
91
+ 1. Read the timer-callback and recompute source under
92
+ `components/synth_core/sequencer_core/`; identify the divides'
93
+ operands and the callback rate.
94
+ 2. Apply the fix shapes from findings 1-2.
95
+ 3. Iterate on the changed TU with compile-mode asmdiff (borrow real
96
+ flags: `asmdiff FILE.c FUNC --target esp32s3 -db
97
+ build/compile_commands.json`) until the libcalls are gone from the
98
+ changed functions.
99
+ 4. `idf.py build`, re-run the ELF sweeps, confirm the calls column is
100
+ clean and the loops that matter still (or now) ship as ZOL.
101
+ 5. Only then measure on target - instruction counts are not cycles;
102
+ the win to verify is tick jitter / CPU headroom.
103
+
104
+ ## Hardware-verify pending
105
+
106
+ Timing claims (callback cost, tick jitter) need on-target measurement;
107
+ every codegen claim above is settled by the ELF alone.
108
+
109
+ ## Source evaluation 2026-07-07 (second session)
110
+
111
+ Sweeps re-run against the current ELF; all four functions unchanged
112
+ (same insn counts and libcall lists). Hypotheses checked against source.
113
+
114
+ **Correction to the origin note:** findings 1 and 2 are NOT synth_core.
115
+ `sequencer_timer_callback` / `sequencer_check_and_fill` /
116
+ `sequencer_recompute` live in vendored AMY (`components/amy/src/sequencer.c`),
117
+ and the float divide inside them comes from `amy_sysclock()`
118
+ (`components/amy/src/api.c:214`). Any fix is an AMY edit -> classify per
119
+ the upstream bug policy. Only `arp_rebuild` (finding 3) is synth_core.
120
+
121
+ ### Finding 1 - CONFIRMED, and upgraded to a correctness bug
122
+
123
+ The callback (2 kHz on the Core-0 esp_timer task) inlines
124
+ `sequencer_check_and_fill()`:
125
+
126
+ - `__udivdi3`: `amy_global.next_amy_tick_us / 1000L` in the while
127
+ condition (`sequencer.c:280`). GCC cannot strength-reduce a 64-bit
128
+ divide by constant on a 32-bit target; it is a real libcall per
129
+ loop-condition check.
130
+ - `__divsf3`: inlined `amy_sysclock()` =
131
+ `(uint32_t)((total_blocks * AMY_BLOCK_SIZE / (float)AMY_SAMPLE_RATE) * 1000)`
132
+ - called twice per callback (line 275 and the while condition), each
133
+ with u32->float and float->u32 conversion traffic around the divide.
134
+
135
+ Beyond cost, `amy_sysclock()` has two upstream correctness bugs:
136
+
137
+ 1. **Wraps at 24.85 h, not the documented 49.7 days.** `total_blocks`
138
+ is `uint32_t` and `AMY_BLOCK_SIZE` is int, so `total_blocks * 256`
139
+ wraps in u32 at 2^32 samples = 89478 s. The "rollover is 49.7 days"
140
+ comment assumes ms-domain wrap; the samples-domain multiply wraps
141
+ ~48x sooner.
142
+ 2. **Float precision decay.** The u32 sample count passes through
143
+ float (24-bit mantissa). Past 2^24 samples (~6 min) the clock
144
+ quantizes; by ~12 h uptime the quantum is ~128 samples (~2.7 ms) -
145
+ against an 11.57 ms tick at 108 BPM the sequencer the whole synth
146
+ slaves to gets visibly lumpy on long uptimes.
147
+
148
+ Fix shape (both cost and correctness):
149
+ - `amy_sysclock()` -> integer math:
150
+ `(uint32_t)(((uint64_t)total_blocks * (AMY_BLOCK_SIZE * 1000u)) / AMY_SAMPLE_RATE)`
151
+ - exact, monotonic, true 49.7-day ms rollover. Generic form keeps one
152
+ `__udivdi3` per call; for this target the 48 kHz / 256-block case
153
+ reduces exactly to `total_blocks * 5 + total_blocks / 3` (u32
154
+ divide-by-constant -> hw `quou` / reciprocal-mul, call-free).
155
+ - `sequencer_check_and_fill()` -> compare in the us domain: compute
156
+ `now_us` once, `while (now_us >= amy_global.next_amy_tick_us)`.
157
+ Kills the per-iteration `__udivdi3` AND the second sysclock call.
158
+ Semantic delta: the loop no longer re-samples the clock mid-catch-up;
159
+ the existing 1 s skip-ahead guard already bounds that path.
160
+
161
+ CPU impact honest estimate: ~150-400 cycles/callback saved at 2 kHz =
162
+ roughly 0.1-0.35% of Core 0 - small. The real wins are (a) less
163
+ occupancy/jitter on the shared esp_timer dispatch task (same rationale
164
+ as moving button dispatch off it) and (b) the long-uptime clock
165
+ stability, which matters for a box left droning overnight.
166
+
167
+ Classification: universal upstream bugs (hit every 32-bit port incl.
168
+ Pico) -> PR candidates for shorepine/amy; do as LOCAL EDIT first per
169
+ established pattern, log in AMY-EDITS.md.
170
+
171
+ ### Finding 2 - CONFIRMED, trivial
172
+
173
+ `sequencer_recompute()` (`sequencer.c:141`): `1000000.0` and `60.0`
174
+ double literals promote the whole tempo expression to softdouble;
175
+ `__divsf3` in its calls column is the inlined `amy_sysclock()` on the
176
+ next line. Called only on tempo change / init - CPU impact ~zero.
177
+ Fix: f-suffix the two literals. Bundle into the same upstream PR.
178
+
179
+ ### Finding 3 - no action (hypothesis confirmed)
180
+
181
+ `arp_rebuild` divide is `s_arp.lfo.depth / 100.0f`
182
+ (`components/synth_core/arp_core.c:238`), config-path only, runs on
183
+ settings edits. Leave alone.
184
+
185
+ ### Finding 4 - no action (architecture, as suspected)
186
+
187
+ `sequencer_process_tick` per-tick mutex + queue sends are the LOCAL
188
+ EDIT seq-lock design; the `%` modulo at `sequencer.c:157` compiles to
189
+ the S3 hardware divide (no libcall in the sweep). Nothing to fix;
190
+ remains the marker for future tick-latency work.
191
+
192
+ ### Recommended order
193
+
194
+ 1. LOCAL EDIT: integer `amy_sysclock()` + us-domain compare in
195
+ `sequencer_check_and_fill()` + f-suffixes in `sequencer_recompute()`.
196
+ 2. Re-run the ELF sweep: callback calls column should be empty (or
197
+ `quou`-only); recompute column should drop all softdouble calls.
198
+ 3. HW verify per policy (tick jitter / esp_timer task headroom), then
199
+ draft the upstream PR (correctness framing: rollover + precision;
200
+ the codegen win is a bonus).
201
+
202
+ ### Status 2026-07-07 (same second session): steps 1-2 DONE
203
+
204
+ Edits applied to `components/amy/src/api.c` + `sequencer.c` (marked
205
+ LOCAL EDIT, logged in AMY-EDITS.md), `build_project` green, host-side
206
+ math check green (new == 128-bit reference, 0 mismatches; old version
207
+ demonstrated wrapping at 24.85 h and stepping 8 ms at 12 h uptime).
208
+ Post-fix sweep: callback 77 -> 53 insns, calls `__divsf3, __udivdi3`
209
+ -> one `__udivdi3` (inlined sysclock's u64 divide-by-constant);
210
+ `sequencer_recompute` and `amy_sysclock` fully inlined away (no
211
+ softdouble possible - source is float/integer only now). PR draft:
212
+ `docs/pr-draft-sysclock-integer-clock.md` + verification program
213
+ `docs/pr-draft-sysclock-check.c`. Remaining: HW soak (overnight uptime
214
+ + tick jitter), then take the draft to the amy fork.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
- """Compare per-function assembly between paired C implementations.
2
+ """Per-function assembly inspection and comparison, across a compiler matrix or from a shipped ELF.
3
3
 
4
- Compiles a harness C file across a matrix of compilers, extracts each
4
+ Compiles a C source file across a matrix of compilers, extracts each
5
5
  variant function's assembly from the -S output, and prints side-by-side
6
6
  listings plus a summary of instruction counts, loop spans (instructions
7
7
  between a local label and its last backward branch), and outbound calls.
Binary file
Binary file