Time the native half of an Android frame (MOB-146) - #57
Merged
Conversation
`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>
This was referenced Sep 5, 2026
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>
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Mob.RenderStatscould 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, sonative_summary/1returned{: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.IdleHandleris the literal analogue of theCFRunLoopObserver(.beforeWaiting)iOS uses. Compose requests its frame throughChoreographer, 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.Runnablefails less visibly.ViewRootImpl.scheduleTraversalsinstalls a sync barrier holding non-async messages untildoTraversal. 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
popfrom 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
Choreographercaptured 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:
none(one field)pushpopNavigation costs 3.7x a re-render of the same tree.
Corroborated before it was believed — in the same window the platform logged
Davey! duration=1414msandChoreographer: Skipped 67 frames(~1120ms). The measured figures sit just below, which is right for a bracket closing before GPU swap.Review fixes
native_disable/0documents 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.transition. It arrives fromnif_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 fromNSJSONSerialization.System.nanoTimerather thanelapsedRealtimeNanos, which counts deep sleep — a run spanning a screen-off would report minutes.MobJson.parseNode.setRootJsonruns synchronously from the NIF, soset_root_usalready spans the parse; measuring it here double-counts.Handlerinstead of allocating per sample.Tests
Both halves exist and are
@JvmStatic; the emitted keys are the onesMob.RenderStatsparses; 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_DIRenv failures), ktlint and credo clean.Requires GenericJam/mob#136.
MobBridge.ktis generated once and never re-rendered, so existing apps must be regenerated.🤖 Generated with Claude Code