Mob.RenderStats (mob v0.7.39)

Copy Markdown View Source

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's render/1
  • expand_usMob.Composite, Mob.List and Mob.Component expansion
  • reconcile_us — the component reconcile pass
  • prepare_us — the renderer's tree walk: prop resolution, theme token lookup, and one register_tap per handler prop
  • register_tap_us — the register_tap calls alone. Nested inside prepare_us, not a sibling of it; adding the two double-counts.
  • encode_us:json.encode plus iodata_to_binary
  • set_root_us — the set_root NIF 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

accumulate(key, fun)

@spec accumulate(atom(), (-> result)) :: result when result: term()

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(key, value)

@spec add(atom(), number()) :: :ok

Add a measured value to the frame in progress.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

disable()

@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.

drop_frame(frame)

@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.

enable()

@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.

enabled?()

@spec enabled?() :: boolean()

Whether recording is on.

finish(tree, bytes)

@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.

frames()

@spec frames() :: [map()]

Recorded frames, newest first.

hand_off(ref)

@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.

native_disable(nif \\ :mob_nif)

@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.

native_enable(nif \\ :mob_nif)

@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_frames(nif \\ :mob_nif)

@spec native_frames(module()) :: {:ok, map()} | {:error, :unsupported | term()}

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.

native_summary(nif \\ :mob_nif)

@spec native_summary(module()) :: map() | {:error, :unsupported | term()}

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.

reset()

@spec reset() :: :ok

Discard every recorded frame.

resume_frame(frame)

@spec resume_frame(map() | nil) :: :ok

Install a frame taken from another process.

start_frame(screen, transition)

@spec start_frame(module(), term()) :: :ok

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.

summary()

@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.

take_frame()

@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.

time(stage, fun)

@spec time(atom(), (-> result)) :: result when result: term()

Record a stage's duration by timing fun. Runs fun either way.

verify_taps(on?)

@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.

verify_taps?()

@spec verify_taps?() :: boolean()

Whether the tap cross-check walk is on.