# `Mob.RenderStats`
[🔗](https://github.com/genericjam/mob/blob/master/lib/mob/render_stats.ex#L1)

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_us` — `Mob.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.cast`s 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.

# `accumulate`

```elixir
@spec accumulate(atom(), (-&gt; 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`

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

Add a measured value to the frame in progress.

# `child_spec`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `disable`

```elixir
@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`

```elixir
@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`

```elixir
@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?`

```elixir
@spec enabled?() :: boolean()
```

Whether recording is on.

# `finish`

```elixir
@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`

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

Recorded frames, newest first.

# `hand_off`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@spec reset() :: :ok
```

Discard every recorded frame.

# `resume_frame`

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

Install a frame taken from another process.

# `start_frame`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@spec time(atom(), (-&gt; result)) :: result when result: term()
```

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

# `verify_taps`

```elixir
@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?`

```elixir
@spec verify_taps?() :: boolean()
```

Whether the tap cross-check walk is on.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
