Timeline lanes: resolve each clock once per sample, not on every lane read (partial bank -37%) #93

Closed
opened 2026-09-13 20:55:27 +00:00 by bcox · 1 comment
Owner

Every timeline lane read in dsp/frame_envelope.h recomputes its clock's position from scratch. FrameEnvelope::Value(lane) → Clock::Value calls FrameIndex(), and on an active clock also NextStep(), with its clamps and loop and bounce logic, before the one lerp the read actually needs. None of that depends on the lane. Within one sample it is the same answer for every lane on a clock.

The partial bank reads 2 × N + 9 lanes per sample, so it pays this hardest. //plugin:instrument_bench's own comment already attributes most of a partial's cost to lane reads rather than to the sine. Resolving each clock once per sample and reading lanes against the result takes 37% off the partial bank, bit-exact.

The experiment

Commit 82705b6, on local branch worktree-lane-read-cache off main at fd59eca (worktree .claude/worktrees/lane-read-cache). Not pushed.

  • FrameEnvelope::ClockRead holds {origin row, target row, weight, empty, active}. FrameEnvelope::Resolve(Reads*) fills one per clock. The origin row is the current frame's values, or release_origin_ while releasing. The target row is NextStep()'s frame.
  • FrameEnvelope::ValueResolved(reads, lane) does a timeline_of_ lookup, an empty/active test and one lerp.
  • PartialLayer::Render calls Resolve at its top and Modulated() reads through it. It's behind TYMBAL_LANE_CACHE (default 0). This is valid because every lane read in Render happens before envelope_.Advance() at its end, and nothing moves a clock in between.

Evidence

Bit-exact: under --config=opt, converging-cloud, octave-lock and shepard render byte-identical with the flag at 0 and at 1 (cmp). //dsp:partial_layer_test and //dsp:frame_envelope_test pass with it on.

Cost: //plugin:instrument_bench, --config=opt, AMD Ryzen 7 7840HS, interleaved three runs each. Medians in ns/sample, min–max in brackets.

row today with cache ratio
C2, 1 voice, 8 partials 279 (278–286) 199 (199–199) 0.71×
C2, 1 voice, 16 partials 403 (402–413) 268 (267–274) 0.67×
C2, 1 voice, 32 partials 649 (647–669) 408 (403–408) 0.63×
C2, 4 voices, 32 partials 2550 (2524–2586) 1572 (1563–1599) 0.62×
C2, 8 voices, 32 partials 5081 (5044–5166) 3132 (3124–3189) 0.62×
C6, 1 voice, 32 partials 607 (600–619) 368 (367–373) 0.61×
C8, 1 voice, 32 partials 531 (528–542) 307 (305–308) 0.58×

Per partial, from the slope between 8 and 32 partials: 15.4 ns → 8.7 ns.

Why this matters for #92: with the cache, a per-partial noisiness lane (#92 option A) costs what today's layer does, 660 against 649 ns. See #92 comments 419–420.

Constraints and open questions

  • Where the reads live. A first cut stored them inside FrameEnvelope::Clock. Two pointers plus weight and flags came to 96 bytes per envelope at four clocks, and that trips plugin/program.h's sizeof(FmPairLayer) <= 9024: the FM pair is the alternative a voice slot is sized by, and it holds an envelope. Options:

    • Engine-owned Reads, as the experiment does. The partial layer has headroom under its 6400 B bound.
    • Find the bytes in the FM pair.
    • Store row indices instead of pointers.
  • The other engines. FmPairLayer and UnvoicedLayer also read through FrameEnvelope::Value. They read far fewer lanes per sample than a 32-partial bank, so their gain is likely small. It's unmeasured.

  • The validity window is a new invariant. Reads are good from Resolve() until the next Advance, NoteOn or NoteOff. Today nothing can violate it, because Value re-derives on every call. A version without the flag should make a stale read fail loudly in tests, for example with a resolve generation checked by an assert in ValueResolved, so a later edit that reads a lane after Advance doesn't silently lag a sample.

  • Coverage beyond three patches. Those patches don't exercise every clock state. Before the flag goes away, add a differential test, cache against Value, sample by sample, covering:

    • release from a loop, and the release origin;
    • alternating and forward loops;
    • jump segments;
    • speed modulation;
    • a shape emptied under a sounding voice;
    • all four clocks with distinct lane maps.

    As always, inject a mismatch to prove it bites.

  • Fabric. Clock position is already per-clock state, so this is the natural hardware shape anyway: resolve the row addresses once, then each lane is a memory read and a lerp multiply.

Every timeline lane read in `dsp/frame_envelope.h` recomputes its clock's position from scratch. `FrameEnvelope::Value(lane)` → `Clock::Value` calls `FrameIndex()`, and on an active clock also `NextStep()`, with its clamps and loop and bounce logic, before the one lerp the read actually needs. None of that depends on the lane. Within one sample it is the same answer for every lane on a clock. The partial bank reads 2 × N + 9 lanes per sample, so it pays this hardest. `//plugin:instrument_bench`'s own comment already attributes most of a partial's cost to lane reads rather than to the sine. Resolving each clock **once per sample** and reading lanes against the result takes **37%** off the partial bank, bit-exact. ## The experiment Commit `82705b6`, on local branch `worktree-lane-read-cache` off `main` at `fd59eca` (worktree `.claude/worktrees/lane-read-cache`). **Not pushed.** - `FrameEnvelope::ClockRead` holds `{origin row, target row, weight, empty, active}`. `FrameEnvelope::Resolve(Reads*)` fills one per clock. The origin row is the current frame's values, or `release_origin_` while releasing. The target row is `NextStep()`'s frame. - `FrameEnvelope::ValueResolved(reads, lane)` does a `timeline_of_` lookup, an empty/active test and one lerp. - `PartialLayer::Render` calls `Resolve` at its top and `Modulated()` reads through it. It's behind `TYMBAL_LANE_CACHE` (default 0). This is valid because every lane read in `Render` happens before `envelope_.Advance()` at its end, and nothing moves a clock in between. ## Evidence **Bit-exact:** under `--config=opt`, `converging-cloud`, `octave-lock` and `shepard` render byte-identical with the flag at 0 and at 1 (`cmp`). `//dsp:partial_layer_test` and `//dsp:frame_envelope_test` pass with it on. **Cost:** `//plugin:instrument_bench`, `--config=opt`, AMD Ryzen 7 7840HS, interleaved three runs each. Medians in ns/sample, min–max in brackets. | row | today | with cache | ratio | |---|---|---|---| | C2, 1 voice, 8 partials | 279 (278–286) | 199 (199–199) | 0.71× | | C2, 1 voice, 16 partials | 403 (402–413) | 268 (267–274) | 0.67× | | C2, 1 voice, 32 partials | 649 (647–669) | 408 (403–408) | 0.63× | | C2, 4 voices, 32 partials | 2550 (2524–2586) | 1572 (1563–1599) | 0.62× | | C2, 8 voices, 32 partials | 5081 (5044–5166) | 3132 (3124–3189) | 0.62× | | C6, 1 voice, 32 partials | 607 (600–619) | 368 (367–373) | 0.61× | | C8, 1 voice, 32 partials | 531 (528–542) | 307 (305–308) | 0.58× | **Per partial,** from the slope between 8 and 32 partials: 15.4 ns → 8.7 ns. **Why this matters for #92:** with the cache, a per-partial noisiness lane (#92 option A) costs what today's layer does, 660 against 649 ns. See #92 comments 419–420. ## Constraints and open questions - **Where the reads live.** A first cut stored them inside `FrameEnvelope::Clock`. Two pointers plus weight and flags came to 96 bytes per envelope at four clocks, and that trips `plugin/program.h`'s `sizeof(FmPairLayer) <= 9024`: the FM pair is the alternative a voice slot is sized by, and it holds an envelope. Options: - **Engine-owned `Reads`,** as the experiment does. The partial layer has headroom under its 6400 B bound. - **Find the bytes in the FM pair.** - **Store row indices instead of pointers.** - **The other engines.** `FmPairLayer` and `UnvoicedLayer` also read through `FrameEnvelope::Value`. They read far fewer lanes per sample than a 32-partial bank, so their gain is likely small. It's unmeasured. - **The validity window is a new invariant.** Reads are good from `Resolve()` until the next `Advance`, `NoteOn` or `NoteOff`. Today nothing can violate it, because `Value` re-derives on every call. A version without the flag should make a stale read fail loudly in tests, for example with a resolve generation checked by an `assert` in `ValueResolved`, so a later edit that reads a lane after `Advance` doesn't silently lag a sample. - **Coverage beyond three patches.** Those patches don't exercise every clock state. Before the flag goes away, add a differential test, cache against `Value`, sample by sample, covering: - release from a loop, and the release origin; - alternating and forward loops; - jump segments; - speed modulation; - a shape emptied under a sounding voice; - all four clocks with distinct lane maps. As always, inject a mismatch to prove it bites. - **Fabric.** Clock position is already per-clock state, so this is the natural hardware shape anyway: resolve the row addresses once, then each lane is a memory read and a lerp multiply.
Author
Owner

Landed on local main as d88991c, unconditionally — TYMBAL_LANE_CACHE is gone from the tree.

A note on how it got there: the experiment's code had already reached main dormant via 0a98484 (the #92 bench WIP), so 82705b6 and the worktree-lane-read-cache branch held nothing main lacked. Both are now deleted. The work of this commit was turning the switch on and making the tree honest about it.

Cost, re-measured on the current tree

//plugin:instrument_bench, --config=opt, AMD Ryzen 7 7840HS — the same machine as the table above. The baseline moved a long way in the 313 commits between fd59eca and here (32 partials at one voice was 649 ns then, 790–802 now), so these supersede rather than confirm those figures. Two runs each way, plus a third of the landed default.

row before after
C2, 1 voice, 8 partials 340–348 242–255
C2, 1 voice, 16 partials 493–497 325–330
C2, 1 voice, 32 partials 790–802 498–500
C2, 4 voices, 32 partials 3142 1902–1933
C2, 8 voices, 32 partials 6461 3806–3851
C6, 1 voice, 32 partials 750 448–461
C8, 1 voice, 32 partials 655 377–383

Per partial, from the slope across the 8/16/32 rows: 18.9 ns → 10.6 ns. The slope is stable across runs (10.27, 10.69, 10.72); the fit's intercept is not (170, 156, 157) and is written into the header comment as "about 160" for that reason.

Against one core at 48 kHz that is about 43 32-partial voices where there were 26.

Bit-exactness

Wider than the three patches above: the entire factory bank, all 45 patches, renders byte-identical before and after (//patches:audition both ways at --config=opt, diff -rq). //plugin:partial_golden_test passes forced with --nocache_test_results at opt. Full suite 199/199 at both fastbuild and --config=opt, and a cache-busted rebuild reports zero warnings.

Where the reads live

Engine-owned Reads, as the experiment had it. Measured rather than assumed: sizeof(PartialLayerType) 2416 → 2512 (+96 B), while sizeof(Layer) stays 9008 and sizeof(VoiceType) stays 663896 — the 96 B lands inside slack the modal engine already sizes, so the voice pool does not move at all. The FM pair is untouched. The per-partial size slope is unchanged at 43.1 B/partial, the cache being a flat constant.

What this issue asked for that did NOT land

Both were listed here as preconditions for the flag going away, and the flag went away without them:

  1. The validity window is still an unguarded invariant. There is no resolve generation and no assert in ValueResolved. Today nothing violates it — every lane read in Render precedes Advance — but a later edit that reads a lane afterwards would silently lag a sample rather than fail.
  2. No differential test of ValueResolved against Value across release-from-loop, alternating and forward loops, jump segments, speed modulation, a shape emptied under a sounding voice, and four clocks with distinct lane maps. What stands in its place is the 45-patch byte-identical render and the golden, which is broad coverage of the states those patches reach and no coverage of the ones they do not.

Closing on the measurement, not on completeness. The two items above are real and want their own ticket.

Landed on local `main` as `d88991c`, unconditionally — `TYMBAL_LANE_CACHE` is gone from the tree. A note on how it got there: the experiment's code had already reached `main` dormant via `0a98484` (the #92 bench WIP), so `82705b6` and the `worktree-lane-read-cache` branch held nothing `main` lacked. Both are now deleted. The work of this commit was turning the switch on and making the tree honest about it. ## Cost, re-measured on the current tree `//plugin:instrument_bench`, `--config=opt`, AMD Ryzen 7 7840HS — the same machine as the table above. The baseline moved a long way in the 313 commits between `fd59eca` and here (32 partials at one voice was 649 ns then, 790–802 now), so these supersede rather than confirm those figures. Two runs each way, plus a third of the landed default. | row | before | after | |---|---|---| | C2, 1 voice, 8 partials | 340–348 | 242–255 | | C2, 1 voice, 16 partials | 493–497 | 325–330 | | C2, 1 voice, 32 partials | 790–802 | 498–500 | | C2, 4 voices, 32 partials | 3142 | 1902–1933 | | C2, 8 voices, 32 partials | 6461 | 3806–3851 | | C6, 1 voice, 32 partials | 750 | 448–461 | | C8, 1 voice, 32 partials | 655 | 377–383 | **Per partial,** from the slope across the 8/16/32 rows: **18.9 ns → 10.6 ns.** The slope is stable across runs (10.27, 10.69, 10.72); the fit's intercept is not (170, 156, 157) and is written into the header comment as "about 160" for that reason. Against one core at 48 kHz that is about **43** 32-partial voices where there were **26**. ## Bit-exactness Wider than the three patches above: the **entire factory bank, all 45 patches**, renders byte-identical before and after (`//patches:audition` both ways at `--config=opt`, `diff -rq`). `//plugin:partial_golden_test` passes forced with `--nocache_test_results` at opt. Full suite 199/199 at both fastbuild and `--config=opt`, and a cache-busted rebuild reports zero warnings. ## Where the reads live Engine-owned `Reads`, as the experiment had it. Measured rather than assumed: `sizeof(PartialLayerType)` 2416 → 2512 (+96 B), while `sizeof(Layer)` stays **9008** and `sizeof(VoiceType)` stays **663896** — the 96 B lands inside slack the modal engine already sizes, so the voice pool does not move at all. The FM pair is untouched. The per-partial size slope is unchanged at 43.1 B/partial, the cache being a flat constant. ## What this issue asked for that did NOT land Both were listed here as preconditions for the flag going away, and the flag went away without them: 1. **The validity window is still an unguarded invariant.** There is no resolve generation and no assert in `ValueResolved`. Today nothing violates it — every lane read in `Render` precedes `Advance` — but a later edit that reads a lane afterwards would silently lag a sample rather than fail. 2. **No differential test** of `ValueResolved` against `Value` across release-from-loop, alternating and forward loops, jump segments, speed modulation, a shape emptied under a sounding voice, and four clocks with distinct lane maps. What stands in its place is the 45-patch byte-identical render and the golden, which is broad coverage of the states those patches reach and no coverage of the ones they do not. Closing on the measurement, not on completeness. The two items above are real and want their own ticket.
bcox closed this issue 2026-09-19 00:53:47 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
bcox/tymbal#93
No description provided.