Skip to content

MOB-124 rounds 1-2: measure the render pipeline, then fix what it showed - #118

Merged
GenericJam merged 10 commits into
masterfrom
perf/mob-135-prop-dispatch
Sep 2, 2026
Merged

MOB-124 rounds 1-2: measure the render pipeline, then fix what it showed#118
GenericJam merged 10 commits into
masterfrom
perf/mob-135-prop-dispatch

Conversation

@GenericJam

@GenericJam GenericJam commented Sep 2, 2026

Copy link
Copy Markdown
Owner

MOB-124 rendering-performance epic: measurement, three native fixes, and the iOS half of lazy scroll. No version bump — nothing here releases.

Companion PR in mob_new: GenericJam/mob_new#46 (the Android half of MOB-128).

Contents

commits what
1648f27 d4b87d7 5cc18f2 c1b1990 90fadf6 MOB-125Mob.RenderStats, per-frame instrumentation readable over dist, plus nine defects two reviews found in it
f2552d4 d48df34 MOB-133register_tap no longer logs per exhausted call; ErlNifEnv leak fixed
d48df34 MOB-135 — one-pass prop dispatch in the iOS deserialiser
a28a508 a069a2d MOB-128 — iOS :scroll builds content lazily with lazy: true

Why the epic changed direction

On a Moto G Power, a 200-row screen costs ~45 ms of BEAM-plus-NIF and ~141 ms of main-thread work. The native rebuild dominates — the opposite of what the first (BEAM-side-only) measurements suggested, because set_root dispatches to the main thread asynchronously and no BEAM-side instrument can see past it.

Measured wins

  • register_tap: 13004 µs → 81 µs. The pool caps at 256; a 200-row screen registers 615, and each of the 359 overflows called NSLog synchronously — 13 ms of a 27 ms frame. Counted and reported once per frame instead.
  • iOS set_root: 7625 → 4040 µs, whole frame 13002 → 9403 µs. The deserialiser probed ~100 prop keys into every node to read the three to five it carries (104 probe sites, 99 keys, 11 type guards). Now one enumeration resolving keys to slots.

The bug I introduced, and fixed

Bounding clear_taps by a high-water mark that only set_root wrote leaked one ErlNifEnv per tap, per frame, whenever a render raised between clear_taps and set_root — a path Mob.Sender.commit/1 deliberately rescues, so it accumulated silently. A simulation showed 4900 live envs after 50 failed 100-tap frames. register_tap now maintains the mark.

Changed by adversarial review

The review proved the prop-dispatch rewrite behaviour-identical — applying the regex to the original body leaves a 17-line diff, all of it the new declaration, comment and loop — and proved the tap-table invariant sound on both platforms. It also found:

  • Lazy scroll silently degrades the harness. Rows below the fold are never built, so they never register a frame (element_frames / tap_id cannot address them), and a LazyVStack's contentSize reflects only built rows, so scroll_to(:bottom) under-scrolls and screenshot_tour truncates. Now opt-in via lazy: true, matching lazy_list, which already makes that trade explicitly.
  • Lazy on the wrong axis. lazyContainer was passed to children of a horizontal scroll, where a LazyVStack would be lazy on a bounded, non-scrolling axis — rows below the fold never built at all rather than on demand. Vertical only now.
  • Enum/table drift was unguarded. MobPropKey and names[] were two independently ordered 99-string lists joined by index; inserting mid-enum and appending to the table would silently shift every prop. names[] now uses designated initializers, so drift is unrepresentable, plus an NSCAssert for an unnamed slot. The two source-contract tests could not have caught this — they pass under total misalignment.
  • The exhaustion log held tap_mutex across a synchronous system-log write, blocking mob_send_* on the main thread — reintroducing once per frame the cost this PR removes from the per-call path. Snapshotted under the lock, logged after.
  • Mob.Sender crashed on a pre-reload pending entry. mob's dev loop reloads modules onto a running BEAM, so the sender can meet state a previous version wrote; a CaseClauseError there takes down the process every screen renders through.

Docs corrected against code: 8 → 11 type guards, a 141/134 ms contradiction between changelog and decision record, an overstated MOB-128 scope, and Mob.RenderStats added to a hexdocs group.

Release safety

mix.exs is touched (one line, the hexdocs group) and is the release workflow's trigger path. Verified harmless: tag 0.7.38 exists and 0.7.38 is already on Hex, so the tag, release and hex.publish steps each short-circuit. Version unchanged.

Testing

1380 tests pass, mix credo --strict clean, zig fmt / clang-format clean, zig test tap_handle_codec 5/5. Every fix in the review-response commits has a test that fails when that fix alone is reverted, checked one at a time.

Verified on the iOS simulator that a 200-row screen with lazy: true renders identically to the eager path.

Also filed

MOB-134 (throttle config never reaches native, both platforms), MOB-136 (Android text_field blows up row height under unbounded constraints — breaks the ordinary eager scroll > column > rows idiom), MOB-137 (Android set_root triple materialisation, ~39 ms of the remaining 45 ms BEAM frame), and MOB-133's remaining half (359 elements per frame still get handle -1).

GenericJam and others added 9 commits September 1, 2026 16:22
Every proposal in MOB-124 is a guess without this. The pipeline has never been
measured on a device, and the four candidate fixes attack four different stages
— identity and laziness attack the native rebuild, retained trees attack
navigation, wire patching attacks encode and decode. Picking between them on
intuition is how you spend weeks on the wrong one.

Mob.RenderStats records per frame: the user's render/1, tree expansion,
component reconcile, the renderer's prepare walk (which includes one
register_tap per interactive node), :json.encode, and set_root as seen from the
BEAM — plus node count, interactive-node count, and payload bytes. Readable
over dist with Mob.RenderStats.summary/0, which reports p50/p95/max rather than
a mean, because frame cost is not normally distributed and the tail is what a
user feels as stutter.

The switch is a :persistent_term read rather than a GenServer or an ETS lookup,
so the shipped path costs 49ns per call and 0.29us per frame — 0.08% of a
~378us frame. Measured, not assumed; that number matters because unlike the
recording path it runs on every frame of every app.

Two things the measuring found in the meter itself:

- total_us was being stamped after the node-counting walk, so it inflated the
  number it exists to describe. Stamped before now.
- interactive?/1 probed all eighteen handler names per node — ~14k map lookups
  on a 780-node tree. Walking the node's own props against a MapSet instead
  took recording overhead from 80% to 42%. Still not free, which is why the
  moduledoc says to read the stages rather than an enabled total_us.

No version bump: nothing releases until the epic works end to end.

Tests: 18, covering that it records nothing and still runs the pipeline when
disabled, that stage timings and the node/tap walk are correct, that the ring
buffer keeps the newest frames, and that a stage never recorded reads as nil
rather than zero — zero would claim the stage is free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The instrumentation recorded nothing on device. Mob.Screen.Server.paint/4 opens
the frame and times render/expand/reconcile in the SCREEN process, then hands
the tree to Mob.Sender as a cast — so prepare, :json.encode and set_root run in
the SENDER process, where the process-dictionary accumulator does not exist.
finish/2 returned :ok without storing, every frame.

I designed that cast in MOB-110 and still wrote "the whole pipeline runs in one
screen process" in the moduledoc. It does not, and the moduledoc now explains
why, since the split is the non-obvious thing about this module.

The screen times its stages, hand_off/1 sends the partial frame to the sender,
and the sender resumes it before committing. Sent as its own cast rather than
threaded through Mob.Sender.render/5,6: those are shipped render entry points
and one is already probed with function_exported?/3 for version skew, so
widening them to carry measurement scaffolding would be the wrong trade.

Frames the sender drops — superseded by a newer tree, or belonging to a screen
that is not active — are now recorded with committed: false rather than
discarded. A pipeline throwing away BEAM-side work is a finding, not a detail,
and this epic needs to know how often it happens.

Also fixed: the ETS table was created by whoever called enable/0 first. Over
:rpc.call that is a transient process, so the table died the instant enabling
returned and every later write went nowhere — which is why this was invisible
twice over. A process owns it now.

The first test I wrote for this passed with the fix reverted: it called
hand_off/1 directly, so it proved the mechanism worked without proving paint/4
used it. Added a test that drives a real router in render mode with a stub NIF,
and removing the hand_off call from paint/4 now fails it.

Found by the subagent building the benchmark app, by checking that the meter
actually recorded something before trusting it.

Tests: 22 in this file, 1362 total. No version bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
prepare dominates a dense frame, and it does two unrelated jobs: pure-Elixir
prop and theme resolution, and one register_tap NIF call per interactive node.
Which of the two it is decides what the fix looks like, so they are timed apart.

The split answered it immediately. On a 200-row screen (1627 nodes, 615
register_tap calls) register_tap is 13.0ms of a 27.4ms frame — 47%. Under the
256-handle cap it costs 0.76us per call; over it, 21us. A 28x cliff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every number MOB-124 will be decided on comes out of this module, so a meter
that is subtly wrong is worse than no meter — it produces confident, specific,
wrong conclusions. A review pass found five ways the first version was wrong,
four of which affect numbers already reported.

taps counted interactive nodes, not register_tap calls. A node carrying
on_tap + on_long_press + on_double_tap makes three NIF calls and contributed
one, and nine handler props the renderer registers (the swipe and scroll
families) were missing from the set entirely — so any scrolling screen, the
exact case MOB-128 is about, undercounted silently. A direct probe through
Mob.Renderer.render/4 showed 12 calls reported as 1. taps now counts
handle-valued props, which makes it the same quantity as register_tap_us_n and
therefore a real cross-check: the two disagreeing now means one has a bug.

at/2 used round/1 where nearest-rank wants ceil/1 - 1. Every reported p50 was
one rank high — the 60th percentile at n=10 — and every p95 was literally the
worst frame for any run under about 21 frames, which is the range a short
measurement run lands in. The old test used [1,1,1,1,1,1,1,1,1,500] and could
not fail: nine identical values hide a rank error, and p95 was not asserted.

Dropped frames contaminated the percentiles. drop_frame/1 set bytes: 0 rather
than nil, so uncommitted frames survived the nil filter: one committed frame at
5000 bytes among nine drops reported a byte p50 of 0. Their total_us is
screen-side work plus however long the frame waited in the sender, not a
render. Percentiles now come from committed frames only, with committed and
dropped counts alongside so `frames: 40` can never read as 40 rendered frames.

finish/2, accumulate/2 and drop_frame/1 had no enabled? guard — only time/2
checked — so recording continued after disable/0. And resume_frame(nil) was a
no-op rather than an erase. finish/2 is the only thing that clears the process
dictionary key and it is skipped whenever the render raises, a path
Mob.Sender.commit/1 exists specifically to rescue. A leftover frame was then
closed against the next unrelated tree, producing one record spanning two
frames whose total_us was mostly the gap between them, marked committed: true.

The structural fix is in the sender. A frame was cast separately from the
render it describes and looked up again at flush time, which let anything
landing in between mis-pair them — Mob.Sender.sync/1 is called from the router,
a different process, so a flush can arrive between a screen's two casts and
commit tree N-1 while holding frame N. The frame is now bound to its tree when
the render cast is dequeued and travels with it through coalescing, so a
superseded tree's frame is recorded as dropped rather than reattributed. Staged
frames deliberately survive a flush: the render that pairs them may still be in
the mailbox behind the :flush message. A test caught that when I first cleared
them.

register_tap_us also wrapped Mob.Listener.handler/1 — a whereis and a tuple
allocation, not the NIF. Negligible against 13 ms, but a meaningful share of the
0.76 us/call baseline that the exhaustion cliff is measured against.

Every fix has a test that fails when the fix is reverted; that was checked one
at a time rather than assumed. The moduledoc's disabled-cost claim is also
corrected: it said six calls per frame at 49 ns, but accumulate/2 runs once per
registered handler — 615 times on the benchmark, not six.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two native changes, both in the deserialiser's neighbourhood.

## The deserialiser probed ~100 keys per node to read three

mob_node_from_dict looked up every prop key it knows into every node's props
dictionary, regardless of node type: 104 probe sites over 99 distinct keys,
with only 8 guarded by a node-type check. A 207KB payload across 1627 nodes is
127 bytes per node, of which about 39 is the {"type","props","children"}
skeleton — so a typical node carries three to five props and paid ~100 hashed
lookups to find them. Each probe hashes the literal's bytes afresh (CFString
caches nothing), probes a bucket, and on a hit runs a character compare, because
the JSON-parsed key is a different object from the literal.

That is why converting an already-parsed NSDictionary cost more than four times
what parsing the JSON did: the parse touches each byte once, the conversion did
constant work per node with no relationship to node size.

Now the node's own props are enumerated once, each key resolved to a slot
through a dispatch_once table, and the deserialiser reads slots. Statement order
is untouched, which matters: prop precedence depends on it in three places
(text before value for a text field, generic width/height before canvas,
generic corner_radius before sheet). That is the reason for an indexed array
rather than a switch inside the enumeration — a switch would have reordered
those and broken them silently.

Measured on the iOS simulator, 200 rows / 1627 nodes / 207KB:

  set_root   7625us -> 4040us
  whole frame 13002us -> 9403us   (28% faster)

The two source-contract tests that asserted on the literal props[@"..."] text
now assert the key is in the slot table and that the node builder reads the
slot, which is the same contract in the new shape.

## clear_taps freed the wrong number of slots

Bounding nif_clear_taps by tap_table_used rather than MAX_TAP_HANDLES avoided
walking 256 slots for a frame that used four. But set_root was the only writer
of that high-water mark, and a frame can register taps and never reach set_root:
Mob.Renderer.render/4 runs clear_taps, prepare (N register_tap calls),
:json.encode, then set_root — and Mob.Sender.commit/1 rescues anything raising
in between, deliberately, so one screen's bad render cannot freeze every other
screen.

So the rescued path leaked one ErlNifEnv per tap, per failed frame, permanently,
on a path built to survive. A simulation of the verbatim logic showed 4900 live
envs after 50 failed 100-tap frames, and zero with the bound restored.
register_tap now maintains the mark — it is the thing that knows a slot was
written.

tap_exhausted_count had the same shape: reset only inside set_root's reporting
branch, so a frame that overflowed and then failed carried its count into the
next frame's report, which claims to describe "this frame". It resets in
clear_taps now, the one entry point every frame runs. The iOS increment also
moved inside the mutex to match Zig — Mob.Sender serialises callers today so it
was benign, but nothing else in that file relies on that.

The stale claim that register_tap costs 0.76us under the cap is removed rather
than corrected: it came from a measurement contaminated by the per-call NSLog
this same path removed, and it cannot be reconciled with the current numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first review fixed five defects. A second, run against those fixes, found
four more — two of them in numbers already published to MOB-124.

Both activation paths in Mob.Sender deleted a pending tree and threw its frame
away with it. The tree is correctly discarded: it predates the navigation
boundary and must not become the newly active screen's first frame. But the BEAM
work that built it was paid for, which is exactly what committed: false is for.
The meter was undercounting dropped frames precisely at navigation transitions,
the case this epic cares most about.

Staged frames are swept by age now. Keeping them across a flush was right — the
render that pairs a staged frame may still be in the mailbox behind the :flush
message — but the claim that the map was "bounded anyway, one entry per live
screen ref" was wrong. A screen killed between hand_off/1 and
Mob.Sender.render/5 leaves an entry nothing will ever claim.

The meter's own cost drops by roughly 90%. The review measured the finish/2 walk
at 120 ns per node and showed the per-node prop scan was 90% of it — all to
recompute a number register_tap_us_n already held exactly, for free, as the
calls happened. taps comes from that counter now. The walk survives behind
verify_taps/1 as an opt-in cross-check, since the two disagreeing is how a
counting bug announces itself, and the tests that exercise it use that flag. The
review also disproved my guess that the ETS insert and ring trim contributed:
they are 0.71us per frame, 0.3% of the overhead. Left alone.

Percentiles carry the n they were computed over. The stages do not share a
population — register_tap_us exists only on frames that registered a handler —
so a run mixing dense and tap-free screens computed it over a much smaller
sample than prepare_us and reported both as bare p50s. Forty committed frames of
which three are dense reads as "register_tap costs fifty times all of prepare".
register_tap_us_n also moved out of the stage map, where a count sat among
microsecond durations, and the docs now state that register_tap_us is nested
inside prepare_us rather than being a sibling of it.

The moduledoc's total_us section is rewritten. It spans two casts and the
sender's mailbox and includes the meter's own cost; on a physical device it was
observed exceeding an externally measured frame, which is only possible because
it covers time outside the frame. It must not be compared against a frame budget
or used to compare configurations — sum the stages, or measure from outside by
driving one render and blocking on Mob.Sender.sync/1.

Every fix has a test that fails when the fix is reverted, checked one at a time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three decision records and a filled-in Unreleased section, so the epic's
reasoning survives outside the commit log and nothing is missed at release.

The decisions worth keeping are the ones where the obvious choice was wrong:
a frame spans two processes so timing state cannot live in one process
dictionary; the meter's ETS table needs an owner or it dies with the rpc caller;
taps come from the call counter because recounting them was 90% of the meter's
cost; the deserialiser keeps statement order because prop precedence depends on
it in three places; and the function that writes a tap slot is the one that must
record it was written, because the stage that used to do it does not always run.

Also records what total_us is not, since two published sets of numbers had to be
retracted over it, and the external measurement method that replaced it.

No version bump — none of this has shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
case .scroll: wrapped its children in an eager VStack/HStack, so every child of
a scroll was built whether or not it was on screen. Only the dedicated lazyList
node type used LazyVStack.

A column that is the direct content of a scroll now builds with LazyVStack.
Everywhere else the stacks stay eager — laziness has setup cost and only pays
when most children are off screen.

Two choices worth recording.

The COLUMN is made lazy, not the scroll's own stack. Mob screens are written
scroll > column > rows, so the scroll's own stack has exactly one child; making
it lazy buys nothing because the column underneath is the stack with 200
children.

And the column is KEPT, not flattened away. Android flattens it and uses its
children as list items, guarded by a check that the column's props are
layout-neutral — cheap there because props are a map. On iOS MobNode exposes
typed properties (padding, background, alignment, borders, corner radius,
nativeViewId), so an exhaustive neutrality test would be a long list and missing
one entry would silently drop something visible. Passing a lazyContainer flag
down one level keeps every modifier where it was.

MobEitherStack exists because SwiftUI cannot pick between VStack and LazyVStack
inside one expression. Its `if` yields two view identities, which is safe here:
`lazy` is fixed for a node's position in the tree and never flips for a live view.

Verified correct: a 200-row screen renders identically to the eager path,
including multi-line wrapping labels, text fields, toggles and buttons.

The win is NOT measured on iOS. Android has dumpsys gfxinfo framestats, which
reports per-frame main-thread cost; iOS has no script-readable equivalent, and
set_root dispatches to the main thread asynchronously so no BEAM-side instrument
can see it. The simulator is a development Mac and not representative — assuming
otherwise is what made this epic's first numbers worthless. This is therefore
parity-by-construction on a mechanism proven on Android, and it is labelled that
way in the changelog and the decision record rather than claimed as a result.

No version bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uard, safer logging

A review of this PR proved the prop-dispatch rewrite behaviour-identical (a pure
regex substitution; the whole diff against the substituted original is 17 added
lines) and the tap-table invariant sound on both platforms. It also found real
problems, addressed here.

## Lazy scroll is opt-in, and vertical only

The review established that making :scroll lazy by default silently degrades the
test harness. Rows below the fold are never built, so they never register a
frame and Mob.Test.element_frames / tap_id cannot address them; and a
LazyVStack's contentSize reflects only built rows and grows as you scroll, so
nif_scroll_info's contentSize - bounds under-reports and scroll_to(:bottom)
under-scrolls while screenshot_tour truncates. lazy_list already makes that
trade explicitly. So a scroll now opts in with `lazy: true`, wired through
MobNode as a typed property.

It also passed lazyContainer to children of a HORIZONTAL scroll, where only the
column branch consumed it — producing a LazyVStack lazy on the wrong axis. The
vertical axis under a horizontal ScrollView is bounded and never scrolls, so
rows below the fold would never be built at all rather than on demand. Now
vertical only.

## The enum/table drift guard

MobPropKey and names[] were two independently ordered lists of 99 strings joined
by `m[names[i]] = @(i)`. Insert a key mid-enum and append it to names[] — the
natural mistake when the two are a hundred lines apart — and every slot after
the insertion point silently reads a different prop's value on every node. The
two updated source-contract tests cannot catch it: they assert the literal and
the read exist somewhere, and both pass under total misalignment.

names[] now uses designated initializers, so each entry names the slot it fills
and drift is unrepresentable. An NSCAssert catches a slot with no name, which
would otherwise resolve to nil and read as absent forever.

## The exhaustion log no longer holds tap_mutex

Both platforms logged the once-per-frame pool-exhaustion line while holding the
tap mutex, blocking concurrent mob_send_* on the main thread for a synchronous
system-log write — reintroducing once per frame exactly the cost this change
removed from the per-call path. The count is snapshotted under the lock and
logged after it is released.

## Sender survives a pre-reload pending entry

discard_pending/2 and flush/1 matched only a 5-tuple. mob's dev loop reloads
modules onto a running BEAM, so the sender can meet state a previous version
wrote; a CaseClauseError there takes down the one process every screen renders
through. Both now fall through.

## Docs corrected against the code

- disable/0 also clears verify_taps; the docstring now says so.
- The prop-dispatch record claimed 8 node-type guards in the old deserialiser.
  The real count is 11 (104 sites and 99 keys were exact).
- The changelog said 141 ms where the iOS decision record said 134 ms for the
  same measurement; both now say 141 ms p50.
- The changelog implied :scroll became lazy wholesale and quoted Android numbers
  in mob's changelog, where the Android work lives in mob_new. It now states the
  actual scope and attributes the measurement.
- Mob.RenderStats is a new public documented module and was ungrouped in
  hexdocs; added to "Testing & Debugging". This touches mix.exs, which is the
  release workflow's trigger path — verified harmless: tag 0.7.38 exists and
  0.7.38 is already on Hex, so every publish step short-circuits. Version
  unchanged.

Verified on the iOS simulator that a 200-row screen with `lazy: true` renders
identically to the eager path — wrapping multi-line labels, text fields, toggles
and buttons all at natural height.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@GenericJam
GenericJam merged commit bcb6f85 into master Sep 2, 2026
4 checks passed
@GenericJam
GenericJam deleted the perf/mob-135-prop-dispatch branch September 2, 2026 16:36
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