Skip to content

Time the native half of an Android frame (MOB-146) - #57

Merged
GenericJam merged 2 commits into
masterfrom
perf/mob-146-android-frame-timing
Sep 6, 2026
Merged

Time the native half of an Android frame (MOB-146)#57
GenericJam merged 2 commits into
masterfrom
perf/mob-146-android-frame-timing

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

Mob.RenderStats could time the BEAM half of a render but not the native half, and on a dense screen the native half is most of the cost. On Android it could see none of it — there was no native implementation at all, so native_summary/1 returned {:error, :unsupported}, which is indistinguishable from "you forgot to enable it."

MOB-146 argues Android navigation disposes and recreates the composition, the same defect MOB-129 fixed on iOS. Without a number, that fix lands with no before and no after. This is the prerequisite the ticket asks for.

The bracket is the whole problem

Both obvious closing brackets are wrong, and neither fails — they return a plausible number. Documented in place so nobody re-derives them:

  • MessageQueue.IdleHandler is the literal analogue of the CFRunLoopObserver(.beforeWaiting) iOS uses. Compose requests its frame through Choreographer, and a vsync callback arrives asynchronously rather than sitting in the queue — so between request and vsync the queue is genuinely empty. The handler fires there, before any measured work, and reports the cost of a field write.
  • Registering from inside a posted Runnable fails less visibly. ViewRootImpl.scheduleTraversals installs a sync barrier holding non-async messages until doTraversal. With a traversal already pending, the post is held while Compose recomposes at frame V, so it registers for V+1.

That second one was caught in review as a suspicion and confirmed by measurement: removing the outer post dropped a one-field re-render from 266ms → 221ms and a pop from 916ms → 788ms. The bracket had been absorbing roughly an extra frame, biased upward exactly under the load being characterised.

So the callback is registered straight from the calling thread against a Choreographer captured on main at init, then a post from inside it closes the bracket after the synchronous traversal.

Baseline

Physical moto g power, 1600-node screen:

transitionnp50
none (one field)10221 ms
push4818 ms
pop4788 ms

Navigation costs 3.7x a re-render of the same tree.

Corroborated before it was believed — in the same window the platform logged Davey! duration=1414ms and Choreographer: Skipped 67 frames (~1120ms). The measured figures sit just below, which is right for a bracket closing before GPU swap.

Review fixes

  • Clear on enable only. Clearing on disable destroyed the window the caller was about to read — native_disable/0 documents that samples stay readable, and the natural shape is enable, drive, disable, read. It failed by returning an empty summary, which looks like the feature being off.
  • Escape transition. It arrives from nif_set_transition, which takes any atom up to 15 chars verbatim; one containing a quote costs the reader the whole window, not one sample. iOS gets this free from NSJSONSerialization.
  • System.nanoTime rather than elapsedRealtimeNanos, which counts deep sleep — a run spanning a screen-off would report minutes.
  • Start after MobJson.parseNode.setRootJson runs synchronously from the NIF, so set_root_us already spans the parse; measuring it here double-counts.
  • Cache the main Handler instead of allocating per sample.

Tests

Both halves exist and are @JvmStatic; the emitted keys are the ones Mob.RenderStats parses; the bracket rides a frame callback registered without an intervening post; the clock ignores deep sleep; enabling clears and disabling does not; the transition is escaped.

Comments are stripped before matching, per the convention in native_frame_stats_test.exs — this file names its rejected alternatives, so a plain substring search would find them in the prose explaining why they aren't used.

Suite 413/415 (2 pre-existing MOB_DIR env failures), ktlint and credo clean.

Requires GenericJam/mob#136. MobBridge.kt is generated once and never re-rendered, so existing apps must be regenerated.

🤖 Generated with Claude Code

`Mob.RenderStats` could time the BEAM half of a render but not the native
half, and on a dense screen the native half is most of the cost. On Android it
could not see any of it: there was no native implementation at all, so
`native_summary/1` returned `{:error, :unsupported}` — indistinguishable from
"you forgot to enable it".
MOB-146 argues Android navigation disposes and recreates the composition, the
same defect MOB-129 fixed on iOS. Without a number that fix lands with no
before and no after, so this is the prerequisite the ticket asks for.
The ring buffer lives here rather than in the NIF, unlike iOS where it sits in
C beside it. The measurement can only be taken on the main thread, so keeping
the buffer next to the writer avoids a JNI hop per sample. Mirrors
elementFrames, which is built in Kotlin for the same reason.
The closing bracket is the part that fails silently with a plausible number,
so both rejected options are documented in place:
- A MessageQueue.IdleHandler is the literal analogue of the
CFRunLoopObserver(.beforeWaiting) iOS uses, and it is wrong here. Compose
requests its frame through Choreographer, and a vsync callback arrives
asynchronously rather than sitting in the queue, so between request and
vsync the queue is genuinely empty. The handler fires there — before any of
the measured work — and reports the cost of a field write.
- Registering the frame callback from inside a posted Runnable fails less
visibly. ViewRootImpl.scheduleTraversals installs a sync barrier that holds
non-async messages until doTraversal runs, so with a traversal already
pending the post is held while Compose recomposes at frame V, and it
registers for V+1. Measured: removing that outer post dropped a one-field
re-render from 266ms to 221ms and a pop from 916ms to 788ms — the bracket
had been absorbing roughly an extra frame, biased upward exactly under the
load being characterised.
So the callback is registered straight from the calling thread against a
Choreographer captured on the main thread at init, then a post from inside it
closes the bracket after the synchronous traversal.
Baseline, physical moto g power, 1600-node screen: none 221ms p50, push 818ms,
pop 788ms. Navigation costs 3.7x a re-render of the same tree. Corroborated
before it was believed — the platform logged "Davey! duration=1414ms" and
"Choreographer: Skipped 67 frames" in the same window, and the measured
figures sit just below, which is right for a bracket that closes before GPU
swap.
Review fixes:
- Clear the buffer when ENABLING only. Clearing on disable too destroyed the
window the caller was about to read — native_disable/0 documents that
samples stay readable, and the natural shape is enable, drive, disable,
read. It failed by returning an empty summary, which looks like the feature
being off.
- Escape `transition` into the JSON. It reaches the buffer from
nif_set_transition, which takes any atom up to 15 chars verbatim; one
containing a quote costs the reader the whole window, not the one sample.
iOS gets this free from NSJSONSerialization.
- System.nanoTime rather than elapsedRealtimeNanos, which counts deep sleep —
a run spanning a screen-off would report minutes.
- Start the clock after MobJson.parseNode. setRootJson runs synchronously from
the NIF, so set_root_us already spans the parse; measuring it here too
double-counts against anyone summing the two windows.
- Cache the main Handler instead of allocating one per sample.
Tests assert both halves exist and are @JvmStatic, that the emitted keys are
the ones Mob.RenderStats parses, that the bracket rides a frame callback
registered without an intervening post, that the clock ignores deep sleep,
that enabling clears and disabling does not, and that the transition is
escaped. Comments are stripped before matching, per the convention in
native_frame_stats_test.exs — this file names its rejected alternatives, so a
plain substring search would find them in the prose explaining why they are
not used.
Requires the matching mob NIFs. MobBridge.kt is generated once and never
re-rendered, so existing apps must be regenerated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both add to the generated Android bridge and to the Unreleased changelog
section; the bridge auto-merged, the changelog kept both entries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@GenericJam
GenericJam merged commit 544a429 into masterSep 6, 2026
1 check passed
Sign up for freeto 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

@GenericJam