Skip to content

core: the profiler says why each frame happened - #364

Merged
foxnne merged 2 commits into
mainfrom
core/frame-causes
Oct 10, 2026
Merged

foxnne merged 2 commits into
mainfrom
core/frame-causes

Conversation

@foxnne

@foxnne foxnne commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator

Part of plans/DASHBOARD_PLAN.md, "What performance work lacked": frame causes. Coordinated with the session doing the profiler; it takes spans.

What changes

The profiler says why each frame happened. fizzy draws only when something asks for a frame, so the frame-loop bugs are a frame that never comes (work waiting for the next unrelated event) and frames that never stop (something asking every frame). A profile showed where a frame's time went, but not who asked for the frame. Two of those bugs came up this week, the crash checkpoint (#361) and the restart handover (#356), and each cost a round of guessing.

While the profiler records, each frame is put down to one of three causes:

  • its events, less the pointer position event dvui adds to every frame;
  • else the dvui.refresh calls before it, by file and line;
  • else something else: a timer or an animation coming due.

core.profile.report gains a .causes section with the counts and the places that asked for frames, most first. Idle under the agent's profiler, for example:

.causes = .{
    .frames = 252, .by_events = 0, .by_refresh = 251, .by_other = 1,
    .refreshed_from = .{
        .{ .place = "Host.refresh (a plugin):0", .count = 252 },
    },
},

That first reading found a bug of ours: the agent's own fizzy_profile keeps fizzy awake while it measures. The fix is in the agent plugin, next.

Where the places come from:

  • fizzy and the built-in plugins: dvui already records each refresh's file and line as a debug log line while dvui.debug.logRefresh is on, which is what FIZZY_LOG_REFRESH turns on. fizzy turns it on while the profiler records. The app's log function hands those lines to core.profile.interceptLog, matched by format at compile time, so no other log call costs anything, and they're counted instead of printed.
    • For release builds, dvui's debug level is compiled in (log_scope_levels), and its other debug lines are dropped in logFn as before. A ReleaseFast run logged none of them.
  • A plugin dylib: its refresh lines reach the host as text through Host.logLine and are read the same way (interceptPluginLine). Only its Debug builds produce them.
  • Host.refresh, how every plugin asks for a frame: it wakes the backend directly, so it's recorded at that point as Host.refresh (a plugin). Which plugin asked, it doesn't say.

No layout change: FrameCauses is its own allocation, made the first frame the profiler records and published under its own key, as Lookback is (#353). The profiler's abi stays 4. It starts over each time the profiler starts recording, so it describes what happened while someone was looking.

SDK impact

  • Core-only or additive: reaches plugins at the next SDK release (core.profile: FrameCauses, frameCauses, interceptLog, ReportOptions.causes). No fingerprint move (test-sdk-version passes).

Verified

  • macOS, Debug and ReleaseFast, a sandbox profiled through the agent plugin:
    • Idle: all frames put down to Host.refresh, as above. The same in ReleaseFast, with no dvui debug lines in its log.
    • With FIZZY_LOG_REFRESH=1: the intercept fires for every refresh record dvui prints (234 in a run), and the lines still print.
  • fizzy-profile-causes-tests (new, std-only): the three causes; the busiest place first; a long path keeping its tail; places past the 32 kept counted together; reset.
  • zig build, zig build -Doptimize=ReleaseFast, zig build test (533/534, 1 skipped), test-integration (375/375), check-web, test-sdk-version.
  • Windows, Linux: CI.
  • Not covered: a test that holds dvui to the format strings it logs. The integration tests take their log function from Zig's test runner, so the intercept can't run there. If dvui changes the format, refresh places stop being recorded, and frames show up as "other".

Follow-ups

  • Which plugin called Host.refresh: a source location through it, which moves the SDK boundary, so it waits for a release that moves it anyway.
  • A dylib plugin's own dvui.refresh calls are counted only in its Debug builds, and only once its dvui copy has refresh records on. Syncing that flag through dvui_context is the seam.
  • Agent: fizzy_profile should measure without driving frames, and a step to compare before and after.

🤖 Generated with Claude Code

fizzy draws only when something asks, so the frame-loop bugs are a frame that never comes and
frames that never stop. While the profiler records, each frame is now put down to its events,
else to the dvui.refresh calls before it by file and line, else to something else (a timer, an
animation), and the report gains a .causes section: the counts, and the places that asked for
frames, most first. Kept beside the profiler under its own key, so its abi stays 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/DASHBOARD_PLAN.md`, "What performance work lacked":
startup and handover phases. Stacked on #364 (it shares the report
code); retarget to `main` once that merges.

## What changes

**fizzy times its own startup.** `core.profile.phase(io, name)` stamps a
moment of the app's start. fizzy stamps:
- **at launch:** `main`, `lock` (the single-instance lock), `window`
(the backend up: GPU, fonts), `editor`, `plugins`, `shown`, and `first
frame`;
- **in an instance a restart's handover started (#356):** `lock deferred
(handover)`, `handover ready` and `handover lock`.

Each run's first frame logs one line:

```
info: startup: up 1288 ms after main (window shown at 1198 ms)
```

**The phases in the report:** `core.profile.report` includes them with
`.startup = true`, and they're published like `Lookback` and
`FrameCauses`: their own data key, no change to the profiler's layout or
`abi`. The new instance of a handover, read that way:

```zig
.startup = .{
    .{ .phase = "main", .ms = 0.0 },
    .{ .phase = "lock deferred (handover)", .ms = 0.4 },
    .{ .phase = "window", .ms = 151.6 },
    .{ .phase = "editor", .ms = 175.6 },
    .{ .phase = "plugins", .ms = 208.4 },
    .{ .phase = "handover ready", .ms = 211.7 },
    .{ .phase = "shown", .ms = 219.5 },
    .{ .phase = "handover lock", .ms = 232.1 },
    .{ .phase = "first frame", .ms = 301.9 },
},
```

It already shows something to act on: **the window is shown about 80 ms
before its first frame is drawn.** That's true of every launch, and in a
handover the new window covers the old one for those 80 ms before it has
drawn anything. A follow-up can show it after the first frame.

Measuring a restart used to take a window sampler and a pid poller
running outside fizzy, on macOS only.

## SDK impact

- [x] Core-only or additive: `core.profile.phase`, `phases`, `Phases`,
`ReportOptions.startup`. No fingerprint move.

## Verified

- [x] **macOS:**
  - **A cold launch:** the startup line above.
- **A handover:** the table above, read from the new instance through
the agent plugin. That used a local build of it against this tree; the
agent's own tool for this waits for the SDK release, since it's pinned
to 0.2.22.
- **The first version of this crashed at `main`:** the clock went
through `dvui.io`, which the backend hadn't set yet. So `phase` takes
the `io`.
- **Gates:** `zig build`, `zig build test` (533/534, 1 skipped),
`test-integration` (375/375), `check-web`, `test-sdk-version`.
- [ ] Windows, Linux: CI.

## Follow-ups

- **Show a window after its first frame is drawn,** not before, starting
with a handover's.
- **The real process start** (macOS `proc_pidinfo`, Windows
`GetProcessTimes`, Linux `/proc/self/stat`), so `main` isn't time zero
and dyld and static initialisation are counted.
- **The agent's `fizzy_build`** on fizzy's repo reports the new
instance's phases, once the SDK is released and the agent repinned.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne enabled auto-merge (squash) October 10, 2026 22:20
@foxnne
foxnne merged commit debea1c into main Oct 10, 2026
9 of 10 checks passed
@foxnne
foxnne deleted the core/frame-causes branch October 10, 2026 22:29
@foxnne foxnne mentioned this pull request Oct 10, 2026
2 of 8 tasks
foxnne added a commit that referenced this pull request Oct 11, 2026
## What changes

`sdk_version` goes from 0.2.22 to 0.2.23. Merging tags `sdk-v0.2.23`,
publishes its tarball, and asks the store plugins to repin.

**Why now.** The plugin boundary's fingerprint moved since `sdk-v0.2.22`
(`0x1bf417f6…` → `0x65096e94…`, by #360), so store plugins built against
0.2.22 don't load in a fizzy built from `main`, and fizzyed.it/app is
built from `main`. Also, #355 moved the `plugins` service to version 2,
and the agent plugin's `fizzy_build` asks for version 1 until it repins.

## SDK impact

- [ ] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [x] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

**The `sdk` PRs since `sdk-v0.2.22`:**
- #360: a plugin's panic message reaches its crash report. This moves
the fingerprint.

**Additive, reaching plugins with this release:**
- #355: the `plugins` service is version 2, with `list` (each plugin's
identity, link, version, registration counts and status). A caller built
for version 1 is refused until it rebuilds, which this release's repin
does.
- #351: a crash report says whose code crashed (`sdk/plugin_sdk.zig`).

**Also reaching plugins (`core/`):**
- `core.viz`, graphics for live numbers: `line`, `bar`, `stat`, `Table`
(#345); `Pie`, `keyColor`, `palette` (#347); the table's rounded header
and shaded rows (#359).
- `core.profile`:
- `lookback()`: 30 s of costs and the frames over budget, plus
`ReportOptions.over_ms` / `.hitches` and `selfTimes` (#353).
- `frameCauses()` and `ReportOptions.causes`: why each frame happened
(#364).
- `phase` / `Phases` / `ReportOptions.startup`: startup timing (#365,
which landed inside #364's squash commit).
  - The profiler's `abi` stays 4.

## Verified

- [x] macOS:
  - `zig build` and `test-sdk-version` on this head.
  - `scripts/pack-sdk.sh` packs `fizzy-sdk-v0.2.23.tar.gz`.
- Every store plugin (fresh pulls of pixi, zig, drive, atlas and ghostty
`main`) builds against this tree's `sdk/` with `zig build --fork=<sdk>`
into a throwaway profile (31/31, and 28/28 for each of the other four).
The fingerprint moved, so each repin PR is a real rebuild, but none
should open as a draft for a broken build. The agent and chat plugins
build too (22/22, 19/19).
- [ ] Windows:
- [ ] Linux: CI.
- [ ] Web:

## Follow-ups

- Each repin PR merged and its plugin tagged (the plugin repos' tags
stay the maintainer's).
- `fizzyedit/agent` and `fizzyedit/chat` repin to the `sdk-v0.2.23`
tarball.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to 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