Per-frame timing for the render pipeline, readable from a connected node.
Exists because every proposal in the rendering-performance epic (MOB-124) is a
guess without it. The pipeline has never been measured on a device: nobody
knows whether a dense screen spends its time in the user's render/1, in tree
expansion, in JSON encoding, or inside set_root — and the four candidate
fixes attack four different ones of those.
A frame spans two processes
This is the thing that makes the implementation less obvious than it looks.
the screen's paint path runs the user's render/1, the expansion passes
and the component reconcile in the screen's process — then hands the tree
to Mob.Sender as a cast, so prepare, :json.encode and set_root run
in the sender's process. A process-dictionary accumulator started by the
screen is simply not there when the renderer looks for it, and the first cut
of this module recorded nothing at all on device for exactly that reason.
So the screen times its stages, hand_off/1 sends the partial frame to the
sender, and the sender resumes it before committing. Frames the sender drops
— superseded by a newer tree, or belonging to a screen that is not active —
are recorded with committed: false rather than discarded, because BEAM-side
work that gets thrown away is worth knowing about.
Cost when disabled
time/2 reads a :persistent_term and returns; accumulate/2 reads the
process dictionary and returns. Neither allocates a record.
The honest cost is dominated by accumulate/2, not by the six time/2 sites:
it wraps every register_tap call, so it runs once per registered handler —
615 times on the 200-row benchmark, not six. It also allocates a closure the
direct call did not. Measured on a development Mac, 29.2 ns per call before
and 35.8 ns after, so ~4 us per dense frame here and plausibly 20-40 us on a
phone. Against a 27 ms frame that is under 0.2%, but it is not free, and it
ships on every frame of every app. Note also that pdict lookup cost grows with
dictionary size (14.6 ns at ~10 entries, 30.3 ns at 200).
Using it
From a connected node (mix mob.connect --no-iex, then a script):
:rpc.call(node, Mob.RenderStats, :enable, [])
# ... drive the app ...
:rpc.call(node, Mob.RenderStats, :summary, [])summary/0 returns percentiles per stage. frames/0 returns the raw records,
newest first, for when a percentile hides the thing you are looking for.
What the stages mean
render_us— the user'srender/1expand_us—Mob.Composite,Mob.ListandMob.Componentexpansionreconcile_us— the component reconcile passprepare_us— the renderer's tree walk: prop resolution, theme token lookup, and oneregister_tapper handler propregister_tap_us— theregister_tapcalls alone. Nested insideprepare_us, not a sibling of it; adding the two double-counts.encode_us—:json.encodeplusiodata_to_binaryset_root_us— theset_rootNIF as seen from the BEAM, so it includes the dirty-scheduler hop, which is the honest number from the caller's side
Each percentile carries the n it was computed over, because 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 computes it
over a much smaller sample than prepare_us. Comparing their p50s without
looking at n compares two different sets of frames.
taps is the number of register_tap calls, taken from the counter
accumulate/2 maintains — not from a walk. nodes still needs a walk of the
prepared tree, which runs after every timed stage and after total_us is
stamped, so it cannot inflate any of them. verify_taps/1 adds a second walk
that recounts handle-valued props into taps_walked, as a cross-check.
What total_us is not
It is stamped in the screen process before render/1 and closed in the sender
after set_root, so it spans two GenServer.casts and however long the frame
waited in the sender's mailbox — and it includes the meter's own cost. On a
physical device it has been observed exceeding an externally measured frame by
several milliseconds, which is only possible because it covers time outside
the frame.
Use it within a single run, never against a frame budget and never to compare
configurations. For that, sum the stages, or measure from outside: drive one
render and block on Mob.Sender.sync/1. The per-stage numbers are honest
because each is timed in isolation.
Summary
Functions
Time fun and add it to a running total for this frame.
Add a measured value to the frame in progress.
Returns a specification to start this module under a supervisor.
Stop recording. Frames already collected are kept.
Record a frame whose tree was never committed.
Start recording. Idempotent.
Whether recording is on.
Close the frame, counting the prepared tree and storing the record.
Recorded frames, newest first.
Hand the frame in progress to Mob.Sender, which finishes it.
Turn native frame timing off. Recorded samples stay readable.
Turn native frame timing on, and clear the sample window.
Native frame samples, newest first.
Percentiles of native apply time, split by transition.
Discard every recorded frame.
Install a frame taken from another process.
Begin a frame. Returns a token to thread through, or nil when disabled.
Percentiles per stage across the recorded frames.
Take the frame in progress out of this process, for handing to another.
Record a stage's duration by timing fun. Runs fun either way.
Also walk each finished tree and record taps_walked, an independent count of
the handle-valued props in it.
Whether the tap cross-check walk is on.
Functions
Time fun and add it to a running total for this frame.
For work that happens many times per frame — one register_tap per
interactive node — where the sum is what matters, not each call.
Add a measured value to the frame in progress.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec disable() :: :ok
Stop recording. Frames already collected are kept.
Also clears verify_taps/1, so a later enable/0 starts with the cross-check
off. Both are switches this module owns, and leaving a diagnostic armed across
an enable/disable cycle is the more surprising of the two behaviours.
@spec drop_frame(map() | nil) :: :ok
Record a frame whose tree was never committed.
A superseded or inactive tree still cost the BEAM everything up to the hand-off, and a render pipeline that throws away half its work is a finding rather than a detail.
@spec enable() :: :ok | {:error, term()}
Start recording. Idempotent.
Starts a process to own the ETS table. Without one the table belongs to
whoever called enable/0 first — over :rpc.call/4 that is a transient
process, so the table dies the instant enabling returns and every later write
goes nowhere.
@spec enabled?() :: boolean()
Whether recording is on.
@spec finish(term(), non_neg_integer()) :: :ok
Close the frame, counting the prepared tree and storing the record.
tree is the prepared tree and bytes the encoded payload. The node and tap
walk happens here, after every timed stage, so it cannot inflate them.
@spec frames() :: [map()]
Recorded frames, newest first.
@spec hand_off(term()) :: :ok
Hand the frame in progress to Mob.Sender, which finishes it.
Sent as its own cast rather than threaded through Mob.Sender.render/5,6:
those are the shipped render entry points and one of them is already probed
with function_exported?/3 for version skew, so widening them to carry
measurement scaffolding would be the wrong trade. Ordering holds because both
messages come from the same process to the same mailbox.
@spec native_disable(module()) :: :ok | {:error, :unsupported}
Turn native frame timing off. Recorded samples stay readable.
A sample can still land one apply-window after this returns: an observer already armed when the flag flipped records when it fires.
@spec native_enable(module()) :: :ok | {:error, :unsupported}
Turn native frame timing on, and clear the sample window.
Off by default. When off, the native side pays one atomic load per
set_root; the timestamps and the run loop observer are downstream of that
check.
Returns {:error, :unsupported} in three cases, all of which look identical
to a caller: on the host, where there is no native side at all; on a platform
whose native half has not implemented it, which today means Android; and in
an iOS release build, because the reading NIFs sit inside the same
MOB_RELEASE guard as the rest of the test harness. Profile a debug build.
Native frame samples, newest first.
Each sample is %{apply_us: float(), transition: String.t(), seq: integer()}.
apply_us is main-thread busy time from the tree being applied to the run
loop going idle, so read it as an upper bound on that frame's native cost
rather than as an attribution: anything else queued on the main thread in the
same window is inside it.
Percentiles of native apply time, split by transition.
Split because the two populations answer different questions and pooling them
hides both: a "none" sample is a steady-state re-render into an existing
view tree, while "push", "pop" and "reset" each rebuild the whole tree
because the root carries a new identity. The size of that difference is
exactly what MOB-126 and MOB-129 are arguing about.
dropped is how many samples scrolled out of the native ring buffer. When it
is above zero the percentiles describe the tail of the run, not all of it.
@spec reset() :: :ok
Discard every recorded frame.
@spec resume_frame(map() | nil) :: :ok
Install a frame taken from another process.
Begin a frame. Returns a token to thread through, or nil when disabled.
The accumulator lives in the process dictionary because the whole pipeline —
the screen's paint/4 and the renderer it calls — runs in one screen process,
and threading a struct through Mob.Renderer's public API to carry timings
would put measurement scaffolding in a shipped signature.
@spec summary() :: map()
Percentiles per stage across the recorded frames.
Reports p50, p95 and max rather than a mean: frame cost is not normally distributed, and the tail is what a user experiences as stutter.
@spec take_frame() :: map() | nil
Take the frame in progress out of this process, for handing to another.
Returns nil when disabled or when no frame is open.
Record a stage's duration by timing fun. Runs fun either way.
@spec verify_taps(boolean()) :: :ok
Also walk each finished tree and record taps_walked, an independent count of
the handle-valued props in it.
Off by default, and deliberately so: the walk costs about 120 ns per node —
90% of the meter's whole overhead on a dense screen — to recompute a number
register_tap_us_n already has. Turn it on when the question is whether the
counting itself is right, not when the question is where the time goes. A
taps_walked that disagrees with taps means one of the two is buggy.
@spec verify_taps?() :: boolean()
Whether the tap cross-check walk is on.