# S3-Amysynth codegen hotspots - checkpoint 2026-07-07

Handoff for a fresh session in THIS repo (`~/esp-idf/S3-Amysynth`). Origin:
field-testing asmdiff's new ELF input mode (built in `~/esp-idf/tools/asmdiff`,
its own handoff: `2026-07-07-elf-mode-handoff.md` there) against
`build/S3-Amysynth.elf` surfaced likely codegen problems in NON-amy code
(synth_core). All evidence is disassembly-level; no source has been read and
no code changed. The amy-owned side is already tracked in
`docs/zol-audit-2026-07-07.md` and is out of scope here.

## Evidence base

- ELF: `build/S3-Amysynth.elf` built 2026-07-07 ~16:32, esp-15.2.0
  xtensa-esp32s3-elf, -O2 + LTO. A rebuild invalidates the exact
  `.L<off>` labels quoted below; function-level facts should survive.
- Tool: `asmdiff build/S3-Amysynth.elf FUNC...` or `--filter REGEX`,
  run from this repo (objdump is auto-derived from the esp32s3 target
  in `~/.config/asmdiff.toml`; `python3 ~/esp-idf/tools/asmdiff/asmdiff.py`
  if the package isn't installed). Longcalls are resolved to real
  callees; a bare `aN` in the calls column is genuine function-pointer
  dispatch. LTO renames survivors (`$lto_priv$0`, `$constprop$0`,
  `$isra$0`), so sweep with `--filter`, don't guess names.

## Findings, ordered by suspected payoff

1. **`sequencer_timer_callback$lto_priv$0`** - 77 insns, calls
   `__divsf3` AND `__udivdi3`, and its single loop span is `.L21:41`,
   i.e. most of the function repeats. A software float divide plus a
   64-bit integer divide in a timer callback is the exact pattern the
   asmdiff README's "silent software divide" example warns about.
   Hypothesis: per-callback recomputation of a period/rate that only
   changes on tempo edits. Fix shape: precompute at tempo-change time,
   or narrow to 32-bit / reciprocal-multiply math.

2. **`sequencer_recompute`** - 46 insns, call chain `__extendsfdf2 ->
   __divdf3 -> __muldf3 -> __fixunsdfsi` plus `__divsf3`, no loops.
   That chain is the double-promotion smell: the S3 FPU is
   single-precision only, so an unsuffixed literal (`60.0`, `1000.0`)
   or a non-f math call promotes the whole expression to softdouble.
   Fix is usually mechanical (f-suffix literals, float-only helpers).
   Likely called only on tempo change - cheap to fix, but confirm call
   rate before counting it as a win.

3. **`arp_rebuild$lto_priv$0`** - 362 insns, spans `.L29:319` /
   `.L77:318` containing `__divsf3` and queue sends. Config-path code
   (runs on patch/settings changes), so probably acceptable - look
   only if arp edits audibly stutter.

4. **`sequencer_process_tick$lto_priv$0`** - 117 insns, loops
   `.L2e:96` / `.L48:85` containing `xQueueSemaphoreTake` /
   `xQueueGenericSend` and one indirect call (`a8`). Per-tick queue
   traffic is an architecture fact, not a bug; noted as the place to
   look during any future tick-latency work.

Checked and healthy (no action): `quantizer_snap_midi_note` (51 insns,
call-free, divide-by-12 folded to mulsh, inner 12-insn scan shipped as
a ZOL nested in a branch loop over octaves), `arp_core_refresh` /
`arp_collect_up$lto_priv$0` (call-free collect loops, one ZOL),
`render_clock_on_alarm$lto_priv$0` (13 insns, just the ISR notify),
the `arp_set_*` setters (all tiny). Fully inlined by LTO, confirming
the audit's picture: `arp_collect_down`, `arp_snap`, `arp_sort_asc`
(bodies live inside `arp_core_refresh`).

## Domain facts to reuse

- ZOL kill rules (verified on esp-15.2.0 -O2, now documented in the
  asmdiff README's ELF section): one surviving call anywhere in the
  loop body kills ZOL even behind a never-taken condition; early
  `break` kills it; a spilled loop counter kills it; nested loops get
  ZOL on the innermost only.
- asmdiff's branch-span counts include unconditional backward `j`
  latches, so they read higher than the July audit's awk (which
  counted conditional backedges only). ZOL counts are identical.
- objdump quirk seen once: a function-tail `retw.n` misframed with a
  padding byte makes a few branches print as `sym+0xNN` instead of a
  synthesized label (`quantizer_snap_midi_note+0x88`). Cosmetic;
  spans and call counts are unaffected.

## Hypotheses

Stated inline per finding above; none tested against source yet. The
whole list is one build old - re-run the sweeps before acting:

```
asmdiff build/S3-Amysynth.elf --filter 'sequencer_(process_tick|recompute|timer_callback|add_event)'
asmdiff build/S3-Amysynth.elf --filter '^arp_'
```

## Next steps

1. Read the timer-callback and recompute source under
   `components/synth_core/sequencer_core/`; identify the divides'
   operands and the callback rate.
2. Apply the fix shapes from findings 1-2.
3. Iterate on the changed TU with compile-mode asmdiff (borrow real
   flags: `asmdiff FILE.c FUNC --target esp32s3 -db
   build/compile_commands.json`) until the libcalls are gone from the
   changed functions.
4. `idf.py build`, re-run the ELF sweeps, confirm the calls column is
   clean and the loops that matter still (or now) ship as ZOL.
5. Only then measure on target - instruction counts are not cycles;
   the win to verify is tick jitter / CPU headroom.

## Hardware-verify pending

Timing claims (callback cost, tick jitter) need on-target measurement;
every codegen claim above is settled by the ELF alone.

## Source evaluation 2026-07-07 (second session)

Sweeps re-run against the current ELF; all four functions unchanged
(same insn counts and libcall lists). Hypotheses checked against source.

**Correction to the origin note:** findings 1 and 2 are NOT synth_core.
`sequencer_timer_callback` / `sequencer_check_and_fill` /
`sequencer_recompute` live in vendored AMY (`components/amy/src/sequencer.c`),
and the float divide inside them comes from `amy_sysclock()`
(`components/amy/src/api.c:214`). Any fix is an AMY edit -> classify per
the upstream bug policy. Only `arp_rebuild` (finding 3) is synth_core.

### Finding 1 - CONFIRMED, and upgraded to a correctness bug

The callback (2 kHz on the Core-0 esp_timer task) inlines
`sequencer_check_and_fill()`:

- `__udivdi3`: `amy_global.next_amy_tick_us / 1000L` in the while
  condition (`sequencer.c:280`). GCC cannot strength-reduce a 64-bit
  divide by constant on a 32-bit target; it is a real libcall per
  loop-condition check.
- `__divsf3`: inlined `amy_sysclock()` =
  `(uint32_t)((total_blocks * AMY_BLOCK_SIZE / (float)AMY_SAMPLE_RATE) * 1000)`
  - called twice per callback (line 275 and the while condition), each
  with u32->float and float->u32 conversion traffic around the divide.

Beyond cost, `amy_sysclock()` has two upstream correctness bugs:

1. **Wraps at 24.85 h, not the documented 49.7 days.** `total_blocks`
   is `uint32_t` and `AMY_BLOCK_SIZE` is int, so `total_blocks * 256`
   wraps in u32 at 2^32 samples = 89478 s. The "rollover is 49.7 days"
   comment assumes ms-domain wrap; the samples-domain multiply wraps
   ~48x sooner.
2. **Float precision decay.** The u32 sample count passes through
   float (24-bit mantissa). Past 2^24 samples (~6 min) the clock
   quantizes; by ~12 h uptime the quantum is ~128 samples (~2.7 ms) -
   against an 11.57 ms tick at 108 BPM the sequencer the whole synth
   slaves to gets visibly lumpy on long uptimes.

Fix shape (both cost and correctness):
- `amy_sysclock()` -> integer math:
  `(uint32_t)(((uint64_t)total_blocks * (AMY_BLOCK_SIZE * 1000u)) / AMY_SAMPLE_RATE)`
  - exact, monotonic, true 49.7-day ms rollover. Generic form keeps one
  `__udivdi3` per call; for this target the 48 kHz / 256-block case
  reduces exactly to `total_blocks * 5 + total_blocks / 3` (u32
  divide-by-constant -> hw `quou` / reciprocal-mul, call-free).
- `sequencer_check_and_fill()` -> compare in the us domain: compute
  `now_us` once, `while (now_us >= amy_global.next_amy_tick_us)`.
  Kills the per-iteration `__udivdi3` AND the second sysclock call.
  Semantic delta: the loop no longer re-samples the clock mid-catch-up;
  the existing 1 s skip-ahead guard already bounds that path.

CPU impact honest estimate: ~150-400 cycles/callback saved at 2 kHz =
roughly 0.1-0.35% of Core 0 - small. The real wins are (a) less
occupancy/jitter on the shared esp_timer dispatch task (same rationale
as moving button dispatch off it) and (b) the long-uptime clock
stability, which matters for a box left droning overnight.

Classification: universal upstream bugs (hit every 32-bit port incl.
Pico) -> PR candidates for shorepine/amy; do as LOCAL EDIT first per
established pattern, log in AMY-EDITS.md.

### Finding 2 - CONFIRMED, trivial

`sequencer_recompute()` (`sequencer.c:141`): `1000000.0` and `60.0`
double literals promote the whole tempo expression to softdouble;
`__divsf3` in its calls column is the inlined `amy_sysclock()` on the
next line. Called only on tempo change / init - CPU impact ~zero.
Fix: f-suffix the two literals. Bundle into the same upstream PR.

### Finding 3 - no action (hypothesis confirmed)

`arp_rebuild` divide is `s_arp.lfo.depth / 100.0f`
(`components/synth_core/arp_core.c:238`), config-path only, runs on
settings edits. Leave alone.

### Finding 4 - no action (architecture, as suspected)

`sequencer_process_tick` per-tick mutex + queue sends are the LOCAL
EDIT seq-lock design; the `%` modulo at `sequencer.c:157` compiles to
the S3 hardware divide (no libcall in the sweep). Nothing to fix;
remains the marker for future tick-latency work.

### Recommended order

1. LOCAL EDIT: integer `amy_sysclock()` + us-domain compare in
   `sequencer_check_and_fill()` + f-suffixes in `sequencer_recompute()`.
2. Re-run the ELF sweep: callback calls column should be empty (or
   `quou`-only); recompute column should drop all softdouble calls.
3. HW verify per policy (tick jitter / esp_timer task headroom), then
   draft the upstream PR (correctness framing: rollover + precision;
   the codegen win is a bonus).

### Status 2026-07-07 (same second session): steps 1-2 DONE

Edits applied to `components/amy/src/api.c` + `sequencer.c` (marked
LOCAL EDIT, logged in AMY-EDITS.md), `build_project` green, host-side
math check green (new == 128-bit reference, 0 mismatches; old version
demonstrated wrapping at 24.85 h and stepping 8 ms at 12 h uptime).
Post-fix sweep: callback 77 -> 53 insns, calls `__divsf3, __udivdi3`
-> one `__udivdi3` (inlined sysclock's u64 divide-by-constant);
`sequencer_recompute` and `amy_sysclock` fully inlined away (no
softdouble possible - source is float/integer only now). PR draft:
`docs/pr-draft-sysclock-integer-clock.md` + verification program
`docs/pr-draft-sysclock-check.c`. Remaining: HW soak (overnight uptime
+ tick jitter), then take the draft to the amy fork.
