Timeline lanes: resolve each clock once per sample, not on every lane read (partial bank -37%) #93
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
bcox/tymbal#93
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Every timeline lane read in
dsp/frame_envelope.hrecomputes its clock's position from scratch.FrameEnvelope::Value(lane)→Clock::ValuecallsFrameIndex(), and on an active clock alsoNextStep(), 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 branchworktree-lane-read-cacheoffmainatfd59eca(worktree.claude/worktrees/lane-read-cache). Not pushed.FrameEnvelope::ClockReadholds{origin row, target row, weight, empty, active}.FrameEnvelope::Resolve(Reads*)fills one per clock. The origin row is the current frame's values, orrelease_origin_while releasing. The target row isNextStep()'s frame.FrameEnvelope::ValueResolved(reads, lane)does atimeline_of_lookup, an empty/active test and one lerp.PartialLayer::RendercallsResolveat its top andModulated()reads through it. It's behindTYMBAL_LANE_CACHE(default 0). This is valid because every lane read inRenderhappens beforeenvelope_.Advance()at its end, and nothing moves a clock in between.Evidence
Bit-exact: under
--config=opt,converging-cloud,octave-lockandshepardrender byte-identical with the flag at 0 and at 1 (cmp).//dsp:partial_layer_testand//dsp:frame_envelope_testpass 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.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 tripsplugin/program.h'ssizeof(FmPairLayer) <= 9024: the FM pair is the alternative a voice slot is sized by, and it holds an envelope. Options:Reads, as the experiment does. The partial layer has headroom under its 6400 B bound.The other engines.
FmPairLayerandUnvoicedLayeralso read throughFrameEnvelope::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 nextAdvance,NoteOnorNoteOff. Today nothing can violate it, becauseValuere-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 anassertinValueResolved, so a later edit that reads a lane afterAdvancedoesn'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: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.
Landed on local
mainasd88991c, unconditionally —TYMBAL_LANE_CACHEis gone from the tree.A note on how it got there: the experiment's code had already reached
maindormant via0a98484(the #92 bench WIP), so82705b6and theworktree-lane-read-cachebranch held nothingmainlacked. 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 betweenfd59ecaand 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.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:auditionboth ways at--config=opt,diff -rq).//plugin:partial_golden_testpasses forced with--nocache_test_resultsat 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), whilesizeof(Layer)stays 9008 andsizeof(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:
ValueResolved. Today nothing violates it — every lane read inRenderprecedesAdvance— but a later edit that reads a lane afterwards would silently lag a sample rather than fail.ValueResolvedagainstValueacross 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.