Skip to content

perf(lpc-engine): persist dataflow resolution across frames - #243

Draft
Yona-Appletree wants to merge 11 commits into
mainfrom
claude/intelligent-tu-1fd675
Draft

perf(lpc-engine): persist dataflow resolution across frames#243
Yona-Appletree wants to merge 11 commits into
mainfrom
claude/intelligent-tu-1fd675

Conversation

@Yona-Appletree

@Yona-Appletree Yona-Appletree commented Aug 1, 2026

Copy link
Copy Markdown
Member

Plan: lp2025/2026-07-31-2225-persist-dataflow-resolution
Path: ~/.photomancer/planning/lp2025/2026-07-31-2225-persist-dataflow-resolution/plan.md

Why

Frame cost on the desk ESP32-S3 was flat ~8.4 ms per fixture+output chain —
the same at 30×1 and 90×90, the same for 10 LEDs and 120. lp-cli profile
said the shader was 1.1% of self cycles; the frame was the dataflow
resolver re-resolving the binding graph from cold every tick, with
allocator+memcpy at ~44%.

See docs/debt/s3-frame-cost-scales-per-fixture.md and
docs/adr/2026-07-31-resolver-persistent-resolution.md.

What changed

Resolution decisions — which binding wins, the merge policy, the expanded
provider list — and def-sourced values (binding literals, authored-def reads)
now persist across frames and are dropped when the graph changes shape, not
when a frame ends. Producer values stay strictly per-frame.

Queries are interned to ids, which is what makes the rest cheap: array-indexed
cache lookups instead of SlotPath string compares, a Copy cycle stack, and
discarding a frame's values as a counter bump rather than a drop-and-realloc
storm.

Two invalidation gaps were found and closed on the way: Engine::add_binding
never invalidated, and clear_bindings left the resolver holding a binding
graph that no longer existed.

Measured (1-fixture oracle, steady render, esp32c6 model)

Before After
Total attributed cycles 2,468,427 1,146,110 (−54%)
allocator + memcpy 44.0% 34.4%
[jit] render 1.1% 2.4% (same cycles)

QueryKey::eq, merge_policy_for_consumed_slot, bindings_for_consumed_slot,
slot_lookup and Vec<SlotPathSegment>::clone are gone from the top twenty.

The 4-fixture project moved only −1.9%, because
HwRegistry::endpoint_status_for is 46.7% of that profile and belongs to the
sibling plan. That is the remaining cap on multi-fixture fps.

Scope

This PR owns the dataflow resolver half of the debt entry's exit criteria.
Endpoint status is explicitly out of scope and still open.

Phases

  • P1 — baseline + committed projects/test/quad-strips-1fix oracle
  • P2 — invalidation contract (begin_frame vs invalidate_structure),
    counters, a test pin per invalidation source
  • P3 — interned queries, route cache, def-value cache, differential
    oracle test
  • P4 — borrow keys instead of rebuilding them; memoize constant paths
  • P5 — measure, ADR, docs, debt entry → at gate G1

Status

Draft, pending gate G1 (see the gate message). One acceptance item is
unverified: the desk S3 did not enumerate for espflash board-info, so
the fps matrix has not been refreshed on silicon.

…ing frame cost

Frame cost on the desk S3 is flat ~8.4 ms per fixture+output chain; the way
to attribute that per-chain cost is to profile one chain against four. The
brief's 1-fixture workload was ad hoc, so the oracle could not be re-run.

Same shader, fixture, mapping and endpoint as quad-strips channel 1 — the
two must stay comparable, which the README says out loud.

Plan: 2026-07-31-2225-persist-dataflow-resolution
The resolver had one `clear_frame_cache()` doing two unrelated jobs: "a new
frame started, so last frame's values are stale" and "the graph changed
shape, so everything I know is suspect". Persisting resolution across frames
is a change to what the first one does — impossible to make safely while both
callers say the same thing.

So: `begin_frame()` and `invalidate_structure()`, classified at every call
site, plus counters that make "did this frame re-derive the graph?" a
testable question instead of a profiling exercise.

Two gaps surfaced while classifying:

- `Engine::add_binding` never invalidated at all. Harmless while every frame
  cleared anyway; a stale-value bug the moment it doesn't.
- `clear_bindings` ran on the tree, leaving the resolver's knowledge of a
  binding graph that no longer exists to be invalidated by whatever happened
  to run next. It now goes through the engine and invalidates on its own.

Behaviour is unchanged: both operations still clear everything. The tests
pin each way the graph can change — re-bind, bus priority, node reattach,
selector switch (the playlist's mechanism, without a playlist) — so that
P3's caching has an oracle that predates it.

One of those pins was wrong when first written: it assumed priority
arbitrates consumed-slot bindings the way it does bus providers. It does not
— consumed slots resolve by owner depth then registration order, and a
re-bind replaces the set rather than outranking it. The test now pins what
the engine actually does.

Plan: 2026-07-31-2225-persist-dataflow-resolution
Resolving a slot is two jobs: decide which binding answers it — owner depth,
priority, merge policy, expanding bus providers, all reads of the binding
graph and of authored defs — then get the value through that decision, which
may tick a producer. Only the second is per-frame, but the frame did both,
every tick, for every slot.

So the decision becomes a cached `ResolvedRoute`, alive until the graph
changes shape, and def-sourced values (binding literals, authored-def reads
and their deep slot-data copies) join it. Producer values stay strictly
per-frame — a cached route still runs the producer behind it.

Queries are interned to `QueryId`, which is what makes the rest cheap: cache
lookups index an array instead of comparing `SlotPath` strings, the
cycle-detection stack becomes `Copy` (it was cloning a heap-backed key on
every uncached resolve even with tracing off), and discarding a frame's
values is a counter bump rather than dropping and reallocating every entry.

1-fixture profile, steady render, esp32c6 cycle model:

  total cycles   2,468,427 -> 1,218,709   (-51%)
  alloc+memcpy        44.0% -> 34.2%
  [jit] render         1.1% ->  2.2%      (same cycles, half the frame)

and `QueryKey::eq`, `merge_policy_for_consumed_slot`,
`bindings_for_consumed_slot`, `slot_lookup` and `Vec<SlotPathSegment>::clone`
are all gone from the top twenty. What replaces them at the top —
`intern_query`, `hash_slot_path`, `SlotPath::parse` — is the cost of nodes
re-deriving keys they could hold onto, which is P4's subject.

The differential test is the one to keep: it runs a scene through binding
replacement, a producer switch and a node reattach twice, cached and
force-invalidated, and demands the two agree. A stale cache returns a
plausible answer, so it survives assertions written by whoever wrote the
cache; it does not survive being compared against not caching at all.

Plan: 2026-07-31-2225-persist-dataflow-resolution
Three sources of per-frame key churn, all downstream of P3's routes:

- `TickResolver::resolve` took its key by value, so every caller cloned a
  `SlotPath` into a fresh `QueryKey` per call and dropped it again. It now
  borrows.
- Routes stored a `BindingSource`, so following one rebuilt the target key
  and re-hashed it. They now store the target's `QueryId` — ids and routes
  die together on a structural change, so the id can simply be part of the
  decision.
- Nodes read authored slots by constant path (`power.some`,
  `diagnostic_mode`, `input`), parsing those strings every frame. The
  resolver memoizes them, since it already has exactly the right lifetime.

Interned keys move behind `Rc` so resolution can hold one while mutating the
resolver around it, which is what lets route following work from an id alone.

1-fixture profile, cumulative against the P1 baseline:

  total cycles   2,468,427 -> 1,146,190   (-54%)
  alloc+memcpy        44.0% -> 34.4%
  [jit] render         1.1% ->  2.4%      (same cycles, less than half the frame)

Two measured follow-ups, both left alone deliberately:

- `SlotPath::parse` (2.7%) is now almost entirely the shader node re-reading
  its consumed-slot *definitions* every frame through `format!`-built paths —
  the same re-derive-what-only-changes-structurally shape as this plan, but
  in shader config sync rather than in the resolver. Out of scope here, and
  it owns its own change-detection correctness.
- A cache hit still clones `ProductionSource`, which carries a `SlotPath`.
  Cheap per hit, not free.

Plan: 2026-07-31-2225-persist-dataflow-resolution
The ADR states the invalidation contract, because that is the part that does
not fail loudly: a mutation site that forgets to invalidate serves a stale
answer that looks entirely plausible. It names the current sites, including
the two that were missing before this work, and says why a playlist switch is
deliberately not one of them.

The debt entry gets the numbers rather than a claim: −54% on the 1-fixture
oracle, but only −1.9% on the four-fixture project, because endpoint status
is 46.7% of that profile and belongs to the other half of the exit criteria.
The desk-S3 rows stay unrefreshed and are marked so — the board did not
enumerate this session.

Plan: 2026-07-31-2225-persist-dataflow-resolution
The rule that keeps persisted resolution correct — any mutation that changes
what a query resolves *through* must call `invalidate_structure()` — was
stated only in an ADR, and breaking it is silent: the resolver keeps serving
an answer that is stale but entirely plausible. Prose does not survive contact
with the next mutation site.

So `tick_nodes` now compares a cheap fingerprint of the tree's shape (node
count, binding count, newest binding revision) against the previous frame's
and asserts that any change came with an epoch bump. Debug builds only —
release firmware pays nothing, and the guard's job is to fire during
development, long before a device is involved.

Verified by reintroducing the bug: dropping the `invalidate_structure()` from
`add_binding` trips it with the shape delta and a pointer to the ADR. Worth
noting that the differential test *passed* under that same sabotage — the
scenario reaches `add_binding` via `clear_bindings`, which does invalidate.
Two checks, catching different things.

Also records the desk-S3 numbers, measured today on d8:3b:da:47:29:70:

  4 fixtures   20 fps / 48 ms  ->  25 fps / 37.5 ms
  1 fixture    50 fps / 19 ms  ->  67 fps / 13.5 ms

Hardware beat the emulator's +2% prediction for four fixtures because the
profile runs the virtual WS281x driver, whose endpoint enumeration is dearer
than the real RMT one — the emulator attributes cost well but does not predict
fps. And the honest read on the filed shape: per-additional-chain cost only
fell ~9.7 -> ~8.0 ms. Most of this win is fixed per-frame overhead, not the
per-chain scaling the debt entry is named for. That still belongs to endpoint
status.

Plan: 2026-07-31-2225-persist-dataflow-resolution
An engine change that ships on a 3 MB partition should say what it spends.
Measured with `just fw-esp32c6-size-check` against origin/main:

  image     2,863,296 -> 2,873,504 B   (+10,208 B, +0.36%)
  headroom    282,432 ->   272,224 B   (CI gate 65,536)

Ten kilobytes for the intern table, the route and structural caches, and the
static-path memo — headroom stays four times the pre-merge gate.

The invalidation guard adds nothing to shipped firmware: it is
cfg(debug_assertions) and release-esp32 inherits release. Checked on the
linked ELF rather than assumed — the assertion's message string is absent
while other resolver strings are present.

Plan: 2026-07-31-2225-persist-dataflow-resolution
A reversible spend is only remembered at one moment — when someone is hunting
for flash — and that moment has a forced entry point: the size gate's error
message names the budget ADR, and AGENTS.md orders size work to read it first.
So that is where deliberate spends get registered, with amounts and levers,
rather than in PR threads where they go to die.

First entry is the resolver's +10,208 B, recorded honestly: the spend IS the
feature, the only cheap slice is the intern reverse-lookup's error-formatting
paths (unmeasured, likely single-digit KB — its real holding is heap, not
flash), and nobody should spend an afternoon there expecting ten.

Plan: 2026-07-31-2225-persist-dataflow-resolution
…-1fd675

# Conflicts:
#	docs/debt/s3-frame-cost-scales-per-fixture.md
With #243 and #244 on one image, measured together on d8:3b:da:47:29:70:
4 fixtures 25 fps / 37 ms, 1 fixture 68 fps / 13 ms. The emulator says 21.9×;
silicon says +25%, because the endpoint-status half was a virtual-board retry
storm the S3 never entered — it opens all four channels on frame one.

Decomposed, that is the uncomfortable part:

  per fixture+output chain   9.7 ms -> 8.0 ms   (-17%)
  fixed per-frame overhead   9.3 ms -> 5.0 ms   (-46%)

This entry is named for the per-chain term, and the per-chain term barely
moved. Ten fixtures goes from 9 fps to 12 — still linear, still unusable,
still not something an author can buy down with resolution or LED count.

So it stays open, now on the number rather than on either mechanism, and
without a fresh guess at the cause: the honest state is that ~8 ms per chain
is unattributed, with quad-strips-1fix vs quad-strips as the differential to
attribute it.

Plan: 2026-07-31-2225-persist-dataflow-resolution
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant