Corta — Performance
Documentation index · Project overview
Performance is the first priority (DESIGN.md §1). This document states
the targets, the rules that protect them, and how they are measured.
The language is not the bottleneck. Every modern GPU terminal —
Ghostty, Alacritty, kitty — shares the same architecture: a glyph atlas
and instanced quads. What separates fast from slow is the two decisions
in §2, not the implementation language. Swift with ContiguousArray and
raw integer buffers reaches the same ceiling, provided the hot path
avoids the constructs in §3.
1. Targets
Set before the first release and defended from then on. A change that regresses one of these is a bug regardless of what it improves.
| Metric | Target | Why |
|---|---|---|
| Frame budget (CPU) | < 4 ms | 120 Hz ProMotion is an 8.3 ms frame |
| Parser throughput | > 100 MB/s single-thread | cat of a large file must not be the slow part |
| Keypress → pixel latency | < 1 frame + input latency | The metric a user actually feels |
| Scrollback memory | 100k 120-column lines within ~200 MB | Log-heavy ML workloads are a target use case; the column count is stated because without it the target is unfalsifiable |
| Idle CPU | ~0% | No redraw when nothing changed |
Numbers are re-measured at every release (§5.6). "It feels fast" is not a measurement.
1.1 User-visible targets
§1's table states engineering targets (frame budget, parser throughput) — the numbers a change is checked against. This table states what those numbers are for: the categories a person actually notices, each with a target stated in terms a user would recognize and the harness that is supposed to hold it accountable. Where no repeatable number exists yet, that is stated plainly rather than filled in with a guess.
| Category | Target | Held accountable by |
|---|---|---|
| Input | Keypress → glass feels immediate; §1's latency target | RenderMetrics.keypressToPresent via MeasurementUITests.testKeypressToGlass (§5.7), corta-bench's benchmarkKeypressLatency |
| Sustained output | A flood (yes, a build log, a training run) does not fall behind or drop frames below §1's frame budget | corta-bench's parse-throughput and write-backpressure benchmarks; MeasurementUITests' 1-, 2- and 4-pane floods |
| Scrolling | Scrolling a long buffer tracks the pointer/trackpad with no visible stutter | CORTA_RENDER_METRICS ring buffer, by hand (TESTING.md, Measuring the app); InstanceUploadBenchmarkTests region/history/flood-while-scrolled cases, plus MeasurementUITests.testRegionAndHistoryScrolling (interactive) |
| Startup | A warm launch reaches an interactive window fast enough that switching to Corta does not feel like waiting for an app to open | MeasurementUITests.testLaunchToFirstWindow (XCTApplicationLaunchMetric, 5 warm launches) |
| Memory | §1's scrollback figure holds, and closing panes/windows returns memory rather than leaking it | corta-bench's scrollback-footprint and peak-RSS benchmarks; MeasurementUITests.testOpenAndCloseWindowReturnsMemory |
| Energy | An idle pane draws no more power than idle CPU (§1) implies; a flooding pane does not keep the GPU busier than the frames it is actually producing require | An Activity Monitor xctrace recording (per-process CPU, idle wakeups, App Nap) across MeasurementUITests' idle, occluded and flood scenarios; machine-wide watts need sudo powermetrics, a person's call (TESTING.md, Measuring the app) — §5.6 has the figures. Thermal pressure is not forced and stays not judged |
| Compatibility | The real-program and esctest pass rates CONFORMANCE.md already tracks | CONFORMANCE.md §4.2 (esctest), §4.4.2 (real-program table), §4.6 (manual scenario pass) — cross-referenced here rather than duplicated |
| Recovery | A crashed or force-quit Corta restores its window/split/directory metadata on next launch without asking the user to rebuild the arrangement by hand; terminal text and child processes are not restored | SessionRestore's crash marker and its tests; the manual scenario in CONFORMANCE.md §4.6 |
Startup, memory and energy inherit their machine dependency from §5.2 below — a number recorded here is only comparable to another run that held the same fixed environment.
2. The Two Decisions That Matter
Everything else in this document is a detail by comparison.
2.1 Decouple PTY drain rate from frame rate
PTY read (blocking read / DispatchIO, batched — never byte-at-a-time)
│
▼
Parser → Grid runs as fast as bytes arrive
│
▼ CAMetalDisplayLink (vsync) snapshot taken here, at most 1× per frame
Metal renderer
Three properties follow, and all three are mandatory:
Never render per chunk of input. A flooding process can produce tens of MB/s. Rendering is driven by vsync and reads the latest grid state; intermediate states are simply never drawn.
Never stop draining the PTY. If reading stops, the pipe fills, the
child blocks in write, and the terminal becomes the reason a training
job runs slowly. This is the classic cause of "my terminal slows down my
program" — draining must not be coupled to rendering, to the main thread,
or to any lock the renderer can hold for long.
Cap the work in one parse batch. Give a batch a byte budget (roughly 1 MB) and yield afterwards. Without a cap, a single enormous burst starves the UI and the window appears frozen.
2.2 An ASCII fast path that bypasses shaping
Calling Core Text per line per frame costs milliseconds and destroys the frame budget immediately.
- Pure-ASCII runs skip shaping entirely — look glyphs up directly via
CTFontGetGlyphsForCharacters. This is the single largest text rendering win available, and it covers the overwhelming majority of terminal content. - Everything else goes through a shaping cache, keyed by (run, font, attributes). Shape once, reuse across frames.
3. Hot-Path Rules
The hot path is: PTY read → parse → grid write → instance buffer build. These rules apply there, and only there. Elsewhere, write ordinary idiomatic Swift.
| Rule | Reason |
|---|---|
struct + ContiguousArray, never a class per cell or glyph | A class per cell means an ARC retain/release storm per frame |
UInt8 / UInt32 buffers for bytes and scalars | String carries grapheme-breaking semantics that are not free |
Convert to String only at the Core Text boundary | Shaping is the only place that needs it |
No NSString / NSArray / ObjC bridging | Bridging cost per element |
| No allocation per cell or per frame | Reuse buffers; size them at resize, not at draw |
| Reuse pipelines and the atlas texture when the font changes | Rebuilding a TerminalRenderer per keystroke recompiled pipeline states and allocated a new atlas texture, which is what made key-repeat font sizing stutter |
| Triple-buffer the Metal instance buffer | Avoids a CPU/GPU stall waiting on the previous frame |
| Rebuild the instance buffer only on damage | Idle CPU must be ~0%; a static screen rebuilds nothing |
Instances occupy 24 bytes: two Float2 geometry fields, RGBA8 and a
rectangle index. The 8-byte-per-glyph atlas rectangle table has a buffer of its
own, uploaded when the atlas changes rather than with every draw: a frame over
glyphs already in the atlas uploads only its instances.
Glyph UVs normalize integer pixel rectangles using the actual texture size;
Kitty draws retain fractional source UVs in a per-draw uniform. Cell stays
16 bytes. Negative bearings and fractional geometry are retained.
On damage tracking
With instanced quads, redrawing a full screen on the GPU is already cheap. The win from damage tracking is not rebuilding the instance buffer on the CPU when nothing changed. Track damage at line granularity; per-cell damage tracking is complexity that does not pay for itself.
On the live screen the line-granularity check is not a comparison of
each row's full Line value against the cache but a UInt64 stamp (Grid.lineRevision(_:), bumped centrally by
ScreenLines on every row it touches: ScreenLines.swift). The
granularity is unchanged, still one row, not one cell; only the cost of
asking "did this row change" dropped, from an O(row length) comparison
to one integer compare. Scrolled into history the rows come from
immutable scrollback storage with no such stamp, so that path still
compares Line values directly (TerminalRenderer.rebuildDamagedRows).
The shell takes one session.snapshot() + diff per frame, in
PaneFrameLoop.prepareFrame, and releases it there: render draws the
renderer's cached instances and reads no grid. A second snapshot would
diff a grid the first had just diffed in the same vsync callback, and one
held until the next frame would cost the reader a scrollback copy (§5.9).
Because a ScreenLines swap (an alternate-screen enter/exit, a column
resize replacing lines outright) restarts row revisions from small
numbers a moment-ago screen's cache could coincidentally already hold,
ScreenLines.generation — a process-wide unique value set once per
instance — is checked alongside lineRevision so that coincidence can
never be mistaken for "unchanged" (Grid.linesGeneration).
On scrolling specifically: ScreenLines rotates storage and revisions
together. A 64-event journal records full-screen and margin-region rotations
in order; TerminalRenderer.applyShift moves retained instances in Y and
invalidates only exposed rows. If the journal overflows, the ordinary revision
check rebuilds changed rows instead. A generation change still rebuilds all
rows. The randomized instance/count oracle covers mixed region/full/history
scrolls, edits, cursor movement and journal overflow, including deliberate
mutations that omit Y adjustment or journal replay.
History caches track scrollback.totalPushed - offset, the top document row.
A viewport movement shifts retained rows in either direction; output with an
unchanged document top rebuilds no history rows. Value comparisons still catch
live rows at the bottom of a partly scrolled viewport. Retained history rows
check only their mutable mark, without copying cells out of the arena. Eviction of the cached top forces a full rebuild. Kitty placements
entirely inside a scroll region move and permanently clip their source pixels
at its margins; placements straddling a margin stay fixed, following the
Kitty page-area rule.
Considered and not done: shifting the CPU-side cache is the win here,
not shrinking what gets copied into the GPU instance buffer afterward.
Metal4Backend's ring buffers already copy the whole array in one
memcpy per draw (steady-state zero allocation, PERFORMANCE.md §3),
and true byte-range partial updates into a triple-buffered ring would
need each of the three slots to independently track which generation of
the array it holds and replay every dirty range accumulated since — and
because a row's rebuilt instance count can change (an edit that adds or
removes a glyph), a row's byte offset in the array is not stable across
rebuilds the way a fixed-size slot's would be, so a naive partial copy
risks splicing the wrong bytes into a shifted position. Solving that
properly means fixed-size per-row instance slots, a larger rewrite not
justified here: a full array copy is a sub-millisecond memcpy
(hundreds of KB at a typical window size), not the measured cost.
4. Memory
- Rows are variable length, stored up to the last non-blank cell.
Fixed 200-cell rows over 100k lines is ~320 MB (
DECISIONS.mdD05). - Scrollback is a ring buffer with a configured line cap; eviction is O(1) and never a reallocation of the whole history.
- The glyph atlas is bounded and lazy: grayscale starts at 2048×128, color at 1×1, growing height to the 2048 ceiling as needed. Pixel rectangles remain stable across growth; old textures retire after queued frames. Exhausted pages evict independently. Apple silicon uses shared storage.
- Cold history is compressed in memory in 256-row arenas. Four newest sealed batches and the tail stay plain; LZ4 encoding and ID scans run outside the session lock. Decoded arenas are bounded to four batches / 8 MiB, shared ASCII planes to 32 MiB. Clear invalidates both caches. Reflow and dumps stream batches rather than materializing the whole history.
- Every unbounded input has a cap — OSC/DCS string length, CSI
parameter count and magnitude. See
SECURITY.md§3; these are simultaneously a memory-safety and a denial-of-service concern.
5. Benchmarks
Because the terminal core is a separate SwiftPM package (DECISIONS.md
D04), the core benchmarks run without launching the app.
| Benchmark | Measures |
|---|---|
vtebench | The standard cross-terminal comparison |
cat of a ~100 MB text file | End-to-end throughput |
find / 2>/dev/null | Sustained realistic output |
yes | Worst-case flood; also verifies §2.1 |
| Neovim scrolling a large file in tmux | Interactive full-screen redraw path |
| Parser-only harness over a byte corpus | Isolates parse cost from rendering |
Latency (keypress → glass) is measured separately — since 1.0.0 from inside the app (§5.7); before that with an external screen-capture tool. It is invisible to throughput benchmarks and is the number users actually perceive.
The measurements taken before 1.0.0 with that tool, and why they cannot be read against each other, are a record of their own.
The percentile-shaped view of the render stage. Between
corta-bench (headless, core-only) and the end-to-end §5.7 number sits
CORTA_RENDER_METRICS
(RenderMetrics.swift's 600-sample ring buffer): it dumps real p50/p99 for
cpuFrame, drawableWait and gpu from a live, on-screen app, without
Instruments. MeasurementUITests fills the rings with a sustained yes
in 1, 2 and 4 panes and reports each summary line; a scroll or a real
typing session is still a person's, with the rings streamed from the
unified log (TESTING.md, Measuring the app).
Every target has a baseline. Without one, "performance is the first priority" is a slogan rather than a constraint.
5.1 Report distributions, never averages
Every latency number in this document must carry p50, p95, p99 and the
maximum. corta-bench reports all four (LatencyDistribution), and so does
§5.7's in-app measure; the screen-capture figures from before 1.0.0
(earlier records) predate the rule and are averages, which is exactly the
shape of the problem.
An average is the one statistic a latency measurement should not be reduced to. Keypress latency is not normally distributed — a tight body with a tail of vsync misses and scheduling hiccups — and it is the tail that is felt: 45 ms average with a 90 ms p99 is a terminal that visibly stutters once a second while averaging "fine". A change that trades 2 ms off the mean for 20 ms on the p99 is a regression that an average reports as an improvement.
The sample count has to support the percentile it claims. The p99 of 200
samples is the second-largest value in the set, which is one scheduling
hiccup away from being noise; corta-bench takes 2,000.
5.2 The fixed benchmark environment
Every new latency record must state power source, Low Power Mode state and
physical panel refresh rate, alongside the effective requested frame-rate
range and measured frameInterval. The range alone does not prove delivered
120 Hz. These details were missing from some historical battery runs.
Numbers recorded in this document or in the 0.1 roadmap are only comparable against numbers taken the same way. Any run that is quoted must state:
| Variable | Fixed at |
|---|---|
| Machine | The recorded machine and chip |
| Build | Release (-c release / the Release scheme), never Debug |
| Display | Built-in panel, and its refresh rate — a 120 Hz panel halves the vsync quantum a 60 Hz one imposes, which moves every latency number in this table |
| Scale factor | The display's native backing scale (the atlas is rasterised per scale) |
| Font | System monospaced at 12 pt, the default when these figures were measured (13 pt since 2026-10-20) |
| Window | 120×30, one pane, not full screen, no tab bar |
| Power | Mains, not battery — the efficiency cores and a lowered display refresh rate are both on the table otherwise |
| Other load | No other application in the foreground; the app activated and its window frontmost |
| Test program | Named explicitly (cat, yes, nvim in tmux, vtebench case) |
Two runs that differ in any row of that table are two different measurements. In particular a Debug build is not a slow Release build: the parse path's bounds checks and non-inlined generics change its shape, not only its speed.
Toolchain. Compiler, SDK, language mode and deployment target are four different things — conflating them makes a "same environment" claim unfalsifiable. Recorded on the machine this run was measured on (2026-09-10):
| Variable | Value |
|---|---|
| Xcode | 26.6, build 17F113 (stable channel; xcodebuild -version) |
| Swift compiler | 6.3.3 (swiftlang-6.3.3.1.3, clang-2100.1.1.101; swift --version) |
| Swift language mode | Swift 6, per the Xcode project's SWIFT_VERSION and CortaTerminal/Package.swift's swift-tools-version: 6.2 |
| macOS (build machine) | 26.6.2, build 25G83 (sw_vers) — the OS actually running the benchmark, not a promise every contributor matches it |
| Deployment target | MACOSX_DEPLOYMENT_TARGET = 26.0 (Corta.xcodeproj/project.pbxproj) — the oldest OS the shipped binary claims to run on, independent of the two rows above |
Current stable toolchain (2026-09-19). The benchmark identity above is historical; the installed toolchain has since changed. The following compatibility results are not new benchmark measurements.
| Xcode / build | Swift compiler | macOS SDK / build | Language / deployment | Core / app result |
|---|---|---|---|---|
| 27.0 / 27A266a | 6.4 (swiftlang-6.4.0.34.1, clang-2100.3.34.1) | 27.0 / 26A425 | Swift 6 / macOS 26.0 | 615 core / 653 app tests passed; 4 expected known issues |
The host runs macOS 27.0 (26A428). Results and diagnostics are recorded in the dated verification record.
The pin and the measurement are deliberately two different toolchains.
.xcode-version — 26.6.0, which every workflow selects — is what compiles
the binary users run.
The numbers in this document come from the machine above and whatever
toolchain it has, currently Xcode 27.0. There is no standard GitHub-hosted
runner with Xcode 27 (macos-27 is not a label; the xcode-27 image is a
preview that requires a paid larger runner), and CI was never going to be
where a performance number came from anyway: its runner is a virtual
machine whose GPU reports only MTLGPUFamily.apple5 and cannot construct
a Metal 4 renderer at all (issue #107).
So the rule that keeps both usable is: a number and the number it is
compared against must come from the same machine and the same toolchain,
and every quoted figure says which one. That is enough for everything this
document is for — D17's "re-measure after touching the render loop", a
before/after on a batch, a regression guard — because all of them are
differential. What it is not enough for is an absolute claim about the
shipped binary: that binary is compiled by the pinned toolchain, and each
release records which one in its own notes (release.yml). Moving a
number to a new toolchain means re-recording its baseline on both sides
once, as a bridge, exactly as a Debug-to-Release move would.
A run quoted without this table predates the distinction — it is not a claim that it used a different toolchain.
5.3 Attributing latency: os_signpost
An end-to-end number says whether the last change helped. It cannot say
where the time goes, and each stage has a different fix — so the whole
chain emits os_signpost intervals (Corta/TerminalView/InputLatencySignposts.swift),
subsystem dev.noahqin.Corta, category input-latency:
| Signpost | Covers |
|---|---|
keyDown | key event → bytes written to the PTY |
output | a parse batch has been applied to the grid (a point, not an interval — it is emitted on the reader thread) |
wake | the MainActor hop that un-parks the display link — at most one a frame (§5.10) |
frame | the vsync callback: damage diff, instance build, nextDrawable |
commit | encode and submit |
gpu | submission → the command buffer's completion handler |
xcrun xctrace record --instrument os_signpost --all-processes \
--time-limit 90s --output .build/traces/signposts.trace
--instrument, not --template: os_signpost is not one of
xctrace list templates' entries on current Xcode (it is one of
xctrace list instruments' entries instead), so --template 'os_signpost' — this
document's own earlier wording — fails outright with "Cannot find
template matching name". Never --launch, for the reason the paragraph
below this one explains: launching through xctrace/Instruments does not
hand the new process window focus, so typing right after fails silently.
Record every process and let something else bring Corta forward — run
MeasurementUITests/testKeypressToGlass during the recording, or launch
Corta yourself and type — then filter on the subsystem. The trace goes
under .build/traces/, not ~/Desktop, which needs a one-time
Files-and-Folders permission grant Terminal does not have by default and
xctrace fails on outright.
Everything is behind OSSignposter.isEnabled, which is false unless a
trace is recording, so the render path pays one atomic load per stage.
That is what makes it safe in a Release build — and the point is that the
2.40 ms → 4.19 ms frame-CPU regression this document warns about was
invisible to every passing test and would have been one interval wide in
a trace.
Open question this instrumentation exists to answer: whether an
input-triggered partial redraw occasionally misses the current display
frame and waits a whole refresh period. In a trace this is visible
directly — an output event landing after that frame's frame interval
has begun, with the next frame an interval later.
First two attempts: no valid data. Two Instruments recordings against
the Release build both showed zero keyDown and output events — only
wake/frame/commit/gpu from idle cursor-blink activity — because
the typing done immediately after launching Corta from Instruments'
Record button never reached TerminalView (launching a target this way
does not appear to hand it window focus).
One incidental result from those two recordings, unrelated to the
missed-frame question but relevant to §5.4: Instruments' own
CAMetalLayer.Stalls track recorded real stalls from idle cursor-blink
redraws alone — 146 stalls averaging 30.6 ms in the first recording, 305
stalls averaging 30.5 ms in the second. Drawable stalling is happening on
this machine at rest, not only under load.
Answered. Two more things had to be fixed before a valid recording was possible, both real gaps rather than test-environment noise:
- Launching through
xctrace/Instruments never hands the new process focus, confirmed again — the fix, at the time a script, was to launch Corta normally (it gets focus the way it always does), block until System Events itself confirms it is frontmost, then attach a trace to the already-running process. An unguardedactivatethat silently swallowed its own failure (2>/dev/null || true) was the actual reason two further attempts still showed a mistyped-into-the- wrong-window session — the script now surfaces that failure instead of hiding it. InputLatencySignposts.keyDownonly wrappedTerminalView .deliverBytes— the control-sequence bypass path (⌘/⌃, or an event the input context declines). Ordinary typing, composed or not, commits throughTerminalView.insertTextinstead (Cocoa's input-context pipeline, not a Corta choice), and Return/Delete/Escape/the arrows throughdoCommand(by:)— neither had ever been instrumented. A trace of a completely normal typing session showed real keystrokes reaching the child (bytes on the PTY, grid updates, frames) with zerokeyDownsignposts the whole time, which is what exposed this: the chain's first link was silently only covering the path a small minority of real keystrokes take. Both paths are now wrapped inInputLatencySignposts.measure(.keyDown), matchingdeliverBytes.
With both fixed, a 12-second recording of ordinary typing (122 keystrokes) gives:
| Stage | n | min | p50 | p99 | max |
|---|---|---|---|---|---|
keyDown → next output | 122 | 0.07 ms | 0.13 ms | 0.19 ms | 0.43 ms |
output → next frame begin | 131 | 0.56 ms | 9.46 ms | 15.61 ms | 15.73 ms |
The core-side chain (keyDown → output) stays sub-millisecond, in line
with corta-bench's separately-measured 0.005 ms average for the same
span — the end-to-end latency is not spent in the core.
output → frame spreads roughly uniformly across the whole 0–16 ms
window this machine's frame-begin-to-frame-begin gaps cluster around
(§5.2's fixed-environment table was not held for this run — power and
foreground load were not controlled — so the exact frame period is a
observation of this run, not a citable number). That shape is what
ordinary vsync alignment looks like — a keystroke lands at a random
phase of the refresh clock, and the closer to the start of the current
period it lands, the nearer to a full period it waits for the next one.
No output → frame gap in this recording exceeded roughly one frame
period — the specific pattern a genuinely missed frame would show
(waiting for the vsync after the one that should have caught it) did
not appear. One 12-second sample is not exhaustive — a longer or busier
session could still catch a rarer double-miss — but this is a real answer
where none existed before, not another inconclusive attempt.
5.4 maximumDrawableCount
CAMetalLayer.maximumDrawableCount = 2 is often cited as removing a
frame of latency; it can equally add one, because nextDrawable() then
blocks the main thread more often waiting for a drawable to be recycled.
Which one happens depends on how long a frame takes on the machine in
question, so it is a measurement, not a choice.
Corta ships the default (3). CORTA_MAX_DRAWABLES=2 sets it for one launch, so the
comparison is two launches of the same binary rather than a code change.
Pair it with a signpost trace: if double buffering is costing rather than
saving, it appears as the frame interval growing at its front.
Preliminary signal, not the A/B itself. RenderMetrics
(Corta/TerminalView/RenderMetrics.swift, CORTA_RENDER_METRICS=1) reports
drawableWait — how long
nextDrawable() blocks — directly, without Instruments. One
informal run at the default drawable count (3), auto-repeat plus yes
for a few seconds, held nothing else about the machine fixed the way
§5.2 requires:
| Metric | n | avg | p50 | p99 | max |
|---|---|---|---|---|---|
drawableWait | 600 | 0.00 ms | 0.00 ms | 0.00 ms | 0.00 ms |
cpuFrame | 600 | 0.38 ms | 0.34 ms | 0.78 ms | 3.79 ms |
gpu | 600 | 0.93 ms | 0.89 ms | 1.22 ms | 8.22 ms |
drawableWait never left zero — at the default count, nextDrawable()
is not blocking on this machine under this load, which is the condition
under which dropping to 2 has nothing to buy back and can plausibly only
cost (more frequent blocking, not less). Not a substitute for the actual
end-to-end A/B — that is the only way to turn "probably not worth it"
into a number.
The end-to-end A/B itself. The same Release build measured twice,
back to back, so the only variable between the two runs is
CORTA_MAX_DRAWABLES: MeasurementUITests/testKeypressToGlass (§5.7)
once as is and once with TEST_RUNNER_CORTA_MAX_DRAWABLES=2 in front of
the command, which the test passes to the app it launches. The pair below was taken with the external screen-capture
tool of the day (200 chars / 150 ms delay / 50 ms period / 1,000 ms
length, synchronous, no pauses), mains power, Corta frontmost with
nothing else running:
| Run | Min, ms | Max, ms | Avg, ms | SD, ms |
|---|---|---|---|---|
A — default (maximumDrawableCount = 3) | 45.4 | 99.4 | 70.1 | 13.9 |
B — CORTA_MAX_DRAWABLES=2 | 46.2 | 95.4 | 70.4 | 13.5 |
Run A and Run B are within noise of each other on every column — the
gap in the mean (0.3 ms) is far smaller than either run's own standard
deviation (13.5–13.9 ms). This matches the drawableWait finding above:
nextDrawable() is not blocking at the default count on this machine, so
there is nothing for a smaller count to buy back, and it does not cost
anything either. Conclusion: leave the default (3). Forcing 2 has no
measured benefit and is not worth the added blocking risk on a slower
frame or a busier machine.
(These two runs' Avg/SD are higher across the board than the pre-1.0 cross-terminal run's single-run numbers for Corta (its table is in the 2026-10-09 cross-terminal run) — 70 ms vs. 45 ms — despite identical capture settings; §5.2's environment table was not fully controlled for this pair either, e.g. other background load on the machine varied between sessions. The A vs. B comparison is still valid, since both runs shared whatever that day's uncontrolled conditions were — only the absolute numbers should not be cross-cited against that table.)
5.5 Cross-terminal comparison
Corta, Ghostty and iTerm2, current releases, one machine, one day
(2026-10-09, #279): keypress → glass from Typometer, the external
screen-capture tool, and throughput from vtebench and a 100 MiB cat.
The environment, every run, the calibration and what was not measured are
in history/2026-10-09-CROSS-TERMINAL-RUN.md,
which also keeps the pre-1.0 table this replaces. TESTING.md →
Against other terminals repeats it.
Keypress → glass, MacBook Air M5, 60 Hz, 3 × 200 samples each, ms:
| Terminal | p50 | p95 | p99 | max |
|---|---|---|---|---|
| Corta 1.1.9 | 60.4 | 79.6 | 81.2 | 82.3 |
| iTerm2 3.6.11 | 38.5 | 56.7 | 60.8 | 69.3 |
| Ghostty 1.3.1 | 34.5 | 39.7 | 43.7 | 50.1 |
Corta trails Ghostty by 26 ms at p50, about one and a half frames; before
1.0 the gap was about one. In-app keypressToPresent (§5.7) reads about
14 ms above this external figure at p50.
Throughput (vtebench ms per 1 MiB sample, lower is faster; cat in MiB/s):
| Case | Corta | Ghostty | iTerm2 |
|---|---|---|---|
scrolling | 30 | 17 | 21 |
scrolling_top_region | 79 | 22 | 2,040 |
scrolling_fullscreen | 23 | 25 | 36 |
dense_cells | 10 | 7 | 80 |
unicode | 36 | 9 | 50 |
cat 100 MiB, MiB/s | 88 | 76 | 17 |
5.6 Numbers by release
One column per release, on the machine §5.2 records. A cell says not re-measured rather than carrying an older figure forward. Each release's full run — machine state, every scenario, the energy tables and the reading of them — is its record, preserved at a commit: 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.1.5.
| Metric | 1.1.5 (2026-10-07) | 1.1.1 (2026-10-03) | 1.1.0 (2026-10-03) | 1.0.1 (2026-09-21) | 1.0.0 (2026-09-18) | How |
|---|---|---|---|---|---|---|
| Core feed throughput | 143.8 MiB/s (one run) | 137.8 MiB/s (one run) | 144.1 MiB/s (one run) | 138.5 MiB/s (one run) | 144.2 MiB/s (5-run mean; 141.2–146.6) | corta-bench, -c release |
| Parser-only / parser + grid | 766.0 / 169.2 MiB/s | 751.7 / 155.4 MiB/s | 779.4 / 167.2 MiB/s | 766.9 / 161.4 MiB/s | 806.3 / 166.9 MiB/s | same run |
| Memory @ 100k × 120 lines | 185.0 MB | 185.0 MB | 185.0 MB | 184.4 MB | 185.0 MB | corta-bench |
| Keypress → grid (core side) | p50 0.012 / p95 0.014 / p99 0.016 ms | p50 0.012 / p95 0.014 / p99 0.016 ms | p50 0.018 / p95 0.023 / p99 0.029 ms | p50 0.009 / p95 0.011 / p99 0.012 ms | p50 0.014 / p95 0.018 / p99 0.020 ms | corta-bench, 2000 samples; excludes vsync and display |
| Frame CPU, 120×40 full rebuild, Debug | not re-measured | not re-measured | not comparable — now a Release figure, next row | 1.79 ms avg (1.76 / 1.75 / 1.87) | 2.26 ms avg (2.31 / 2.26 / 2.20) | FrameCPUBaselineTests (D17), Debug test action; from 1.1.0 a Release figure (§5.8) |
| Frame CPU, 120×40 full rebuild, Release | 0.57 ms avg (0.56 / 0.59 / 0.57); full rebuild CPU-only p50 0.126–0.127 ms | not re-measured | 0.87 ms avg (0.86 / 0.88 / 0.88); full rebuild CPU-only p50 0.13–0.15 ms | — | — | FrameCPUBaselineTests (D17), §5.8 command |
| Live frame CPU, Release, 2 / 4 panes flooded | not re-measured | not re-measured | not re-measured | not re-measured | avg 0.60 / 0.14–0.49 ms; p99 2.59 / 0.38–0.45 | MeasurementUITests floods, CORTA_RENDER_METRICS |
| GPU, 2 / 4 panes flooded | not re-measured | not re-measured | not re-measured | not re-measured | avg 0.49 / 0.47 ms | same |
| Idle CPU, Release, 20 s | not re-measured | not re-measured | not re-measured | not re-measured | 0.05%; occluded 0.0–0.1% | same |
| Launch → first window | not re-measured | not re-measured | not re-measured | not re-measured | 208 ms (2-pane restore), 451 ms (4-pane) | same |
Spawn: zsh -l → first output | p50 42.1 ms | p50 40.3 ms | p50 41.2 ms | p50 46.8 ms | p50 44.5 ms | corta-bench |
| Reflow, 100k lines, 120 → 80 columns | 53.3 ms | 58.9 ms | 51.2 ms | 97.9 ms | 94.1 ms | corta-bench |
| Search, 100k lines, one query | 25.5 ms warm | 25.8 ms warm | 25.6 ms warm | ~400 ms warm | ~395 ms warm | corta-bench |
| Keypress → glass, scripted | not re-measured | not re-measured | not re-measured | not re-measured | 61.9 ms avg; p50 61.4 / p95 69.7 / p99 70.9 | §5.7 |
| Keypress → glass, a person typing | not re-measured | not re-measured | not re-measured | not re-measured | 66.3 ms avg; p50 67.0 / p95 78.7 / p99 84.5 | §5.7, --manual |
Energy, background flood (yes, occluded) | not re-measured | not re-measured | not re-measured | not re-measured | 10.2 W machine-wide on mains; 2.6 W in Low Power Mode; idle, occluded and a static image within the machine's noise floor | 1.0.0: a powermetrics script, since removed; now an Activity Monitor trace (§5.6 note) |
The 1.0.0 and 1.0.1 runs were on battery for the core benchmarks, not the mains power §5.2 asks for; the 1.0.0 keypress and energy runs were on AC. Every 1.0.1 figure is inside the run-to-run spread the 1.0.0 run recorded, and the frame-CPU difference is the machine's state on the day, not the change: the claim for 1.0.1 is "no regression".
1.1.0's run was on battery too. Its frame CPU is the first Release figure in this table (§5.8), so it has no 1.0.x neighbour to compare with; it sits inside the spread §5.8 and Follow-up performance checks recorded during the milestone. Reflow and search are the release's measured gains (#137 and the 2026-10-02 follow-up); the rest is inside the 1.0.0 spread.
1.1.5's run was on battery as well. Between 1.1.1 and 1.1.5 the render loop changed (#226's theme-derived overlay colours, #232's opaque canvas, #240's command-status rules in the Metal pass), so D17 was re-measured: the Release frame CPU and its CPU-only rebuild are inside §5.8's post-cleanup spread (0.58 ms; p50 0.128–0.135 ms). The average is lower than 1.1.0's 0.87 ms, but it includes the GPU's round trip (§5.8); the CPU-only rebuild, 0.13–0.15 ms at 1.1.0, is the comparison, and it did not move. Every scripted core figure is inside the 1.0.0–1.1.1 range or better than it.
Since 1.1.5 no release has had a whole-table run. 1.2.0's changes were measured one batch at a time, each before/after on the same M5 and toolchain: scrolling and throughput in §5.14, memory and the core in §5.15, full screen in §5.17, the instance build in §5.19. The 2026-10-11 memory and core run, for instance, measures the 100k-line history's resident increment at 185 → 11.4 MiB (maintained ingestion), the CJK feed median at 70.8 → 87.4 MiB/s and the AI ANSI feed at 123.3 → 133.7 MiB/s, each five samples after one warmup. The ASCII feed, 345.2 → 340.3 MiB/s, has overlapping ranges and is not a speedup. Warm ASCII history search is about 25.6 → 20.8 ms, while first-query setup is slower (27.2 → 44.7 ms). The dated record has the corpora, distributions, stage gates and verification limits.
5.7 Keypress → glass, measured from inside the app
A third-party screen-capture tool — one that grabs the window in a loop until the pixels change — can measure keypress to glass, but Corta measures the same interval from the inside, with nothing installed and no permission asked:
TerminalView's three key-delivery sites handRenderMetricsthe event'stimestamp— the HID timestamp for a real key, the posting time for a synthetic one;- the reader thread marks the first parse batch after that keystroke as its echo (the assumption a screen-capture tool makes too: "the pixels changed after the key");
- the next drawable to be presented closes the sample in its
presented handler, using
MTLDrawable.presentedTime— the moment the frame reached the glass, not the moment it was scheduled. A drawable the compositor replaced before showing (presentedTime == 0, about half the frames of a burst) hands the keystroke back so the frame that did show the echo closes it; dropping those would keep only the lucky frames.
CORTA_RENDER_METRICS=1 turns it on; 200 samples print one line on the
unified log (keypressToPresent: n=200 avg=… p50=… p95=… p99=… max=…) —
and, when the variable names a file, to that file.
MeasurementUITests/testKeypressToGlass launches the Benchmark build,
types 320 digits about 150 ms apart and reports the line; a person typing
is the --manual kind, the launch-and-stream command in TESTING.md
(Measuring the app). Two kinds of number come out of it, and a quoted figure
says which:
| Kind | Includes | Comparable to |
|---|---|---|
Scripted (typeKey from XCTest; key code via System Events before 1.1.0) | Corta's whole path plus the compositor and scanout; not the keyboard's HID stage (1–8 ms on USB/Bluetooth) | a lower bound on what a finger sees |
--manual (a person typing) | everything a screen-capture tool saw | the pre-1.0 screen-capture figures |
Under XCTest (1.1.0 on), more of the echo's frames are replaced before
they reach the glass than under System Events, so a sample is closed by a
later frame — the one that actually showed the echo, which the pane is
asked for as soon as the first one reports presentedTime == 0. The
median agrees with the System Events runs (68 ms against 61–64 ms); the
p95–p99 tail is longer (about 147–153 ms against about 71 ms) and is not
to be compared across the two drivers. Before that request existed the
test closed only about 60 of 320 keystrokes and never filled the ring.
The two kinds differ by the keyboard's HID stage plus the wider spread of
human keystrokes: a synthetic key code arrives at a fixed cadence, a
typist's do not, and the p95–p99 tail is where that shows. §5.6 has both
numbers for each release.
Both kinds say the same thing the target row in §1 says: above one
frame plus input latency, on a 60 Hz panel a good three to four frames.
Where those frames go is the os_signpost chain's job (§5.3); the
in-app number is what says whether a change moved it.
preferredFrameLatency (#114, 2026-09-28). CAMetalDisplayLink
asks for a latency in frames; it reads 2.0 by default and clamps
anything below 1 to 1.0. The question was whether 1 buys back a frame
of the three to four above. Scripted runs, 200 samples each, two rounds
alternating, CORTA_FRAME_LATENCY passed through the launch; the
Benchmark configuration's CortaDev.app at main after #166–#168; the
built-in 60 Hz panel:
preferredFrameLatency | p50 | p95 | p99 | max |
|---|---|---|---|---|
| Default (reads 2.0) | 63.7 / 64.3 ms | 72.2 / 70.9 ms | 80.1 / 72.3 ms | 82.8 / 72.7 ms |
| 1 | 63.3 / 63.1 ms | 70.0 / 71.5 ms | 72.8 / 73.3 ms | 74.1 / 73.6 ms |
| 2 | 64.0 / 64.0 ms | 71.2 / 72.1 ms | 72.8 / 74.0 ms | 74.4 / 74.6 ms |
Apple M5, macOS 27.0 (26A428), Xcode 27.0 (27A266a), on battery.
A frame is 16.7 ms here, and 1 moved the median by half a millisecond
and the tail not at all beyond the run-to-run spread — Apple's note that
the final latency "may be larger in windowed modes on macOS" is what
this measures. The default stays: FrameScheduler.attach sets
nothing, and CORTA_FRAME_LATENCY remains the seam to measure it again
(full-screen, another panel, a later macOS).
The --manual kind, a person typing digits on the built-in keyboard,
one run of 200 samples per setting, same build, machine and power:
preferredFrameLatency | p50 | p95 | p99 | max |
|---|---|---|---|---|
| Default (reads 2.0) | 64.2 ms | 80.3 ms | 84.5 ms | 136.7 ms |
| 1 | 65.1 ms | 80.5 ms | 134.5 ms | 145.3 ms |
| 2 | 63.6 ms | 79.5 ms | 133.0 ms | 146.0 ms |
It says the same: 1 is no faster at the median or at p95. The tail is a person's — the default and 2 are one setting, yet their p99 differ by 48 ms, since at 200 samples p99 is the second-slowest keystroke — and not something to choose a value by.
October 9 follow-up (#280, #282). Correlated diagnostics now record
keypress→output, echo→main, main→frame and frame→actual glass, alongside
wakeHop, callback target lead, first-after-resume lead, resume→callback,
actual-target presentation slip and delivered frameInterval. Timing is
conditional on metrics being enabled. Requested ranges are recorded as
requests, not evidence of delivered refresh.
Three alternating same-binary rounds on the built-in 60 Hz panel, AC and Low Power Mode off, give DisplayLink p50 83.75–84.12 ms and opt-in on-demand p50 33.94–34.21 ms, with lower p95/p99. Three Typometer rounds confirm the direction: 54.81–56.37 ms → 22.26–24.12 ms. Internal and external gains differ by about one frame; the old calibration offset is not reused. Human rounds were subsequently recorded on reported battery power; these are supplemental, not verified mains acceptance. #279's external baseline is now closed.
The first resumed callback has approximately the same 49.9 ms target lead as steady callbacks; resume→callback is approximately 16.4 ms. No additional first-tick penalty was observed, so no hold-awake was added. The mixed CAMetalDisplayLink/nextDrawable prototype trapped twice and was removed; the opt-in experiment uses separate CADisplayLink pacing and only bypasses pacing for paused-link echoes within 50 ms of input, rate-limited to one refresh. DisplayLink stays the default: three alternating flood rounds show higher experimental tails, so the promotion gate fails. Verified-mains human driver pairs later give median p50 68.85 → 39.39 ms with every paired p95/p99 lower; full visual/tearing acceptance is still pending; scripted power-mode pairs are recorded below.
Full environment, decomposition, raw rounds and acceptance limits also record final full Unit, D17, idle/occluded Activity Monitor counters and valid Allocations evidence. No 120 Hz panel was available. Idle process CPU was approximately 0.03%. A later explicit PTY close wakeup removes the 250 ms reader poll: input-then-idle interrupt wakeups fall from 4.7974/s to about 0.4–0.55/s, with zero render commits/frame callbacks over 20 seconds. Total process wakeups are still not strictly zero; raw controls are linked above.
Typing, committed IME text, selector commands, accepted paste and reported mouse input now request an interactive rate for a re-armed one-second grace. The pure policy overrides serious thermal pressure, Low Power Mode and window inactivity during interaction. Critical pressure still caps at 20 Hz, including scroll gestures (a change from their former precedence). Above 60 Hz the interactive range explicitly prefers the screen maximum; non-interactive output prefers 60 Hz with the screen maximum as its ceiling. Screen-change notifications reapply the policy. The scheduler still parks at idle; the grace does not wake it or re-present unchanged frames.
Three scripted power-mode pairs (October 9–10, shipping DisplayLink driver) measure low-power p50 medians 186.64 → 64.08 ms and normal-mode medians 108.48 → 107.23 ms. Low-power target-interval p50/p99 is 16.67/16.67 ms. XCTest input cadence differs by power mode. Later three human mains pairs per power mode (current PTY-wakeup build) give normal p50 medians 68.32 → 69.02 ms, low-power 184.54 → 68.33 ms. Current normal first-pair p99 175.47 ms exceeds old max 169.47 ms; mixed key content and prelaunched inactive processes limit the result. Energy/visual acceptance and the full raw distributions remain in the linked record. No 120 Hz result or energy equivalence is inferred from policy tests.
The later authorized foreground retry fixes directory-preview overlap with real Pinyin preedit. Three further default-driver four-pane pairs still fail GPU tails: old p99 0.80/0.50/1.42 ms versus current 1.05/2.80/1.73 ms. CPU/wakeup energy proxies do not increase in the recorded stable windows, but are not watts. System Trace confirms a 27.127-second blocked reader; process wakeups remain about 0.45/s, and disabling diagnostics still gives about 0.60/s. The user did not observe the continuous animation, so physical temporal tearing remains unconfirmed. No experimental default or acceptance completion is claimed.
5.8 The frame-CPU baseline, under Release (D17)
D17's number is a Release number. One command produces it:
xcodebuild test -project Corta.xcodeproj -scheme Corta \
-testPlan Release -configuration Benchmark -destination 'platform=macOS' \
-only-testing:CortaPerformanceTests
cat /tmp/corta-frame-cpu-baseline.txt
TestPlans/Release holds FrameCPUBaselineTests,
InstanceUploadBenchmarkTests and RendererConstructionCostTests, in the
CortaPerformanceTests bundle; -configuration Benchmark builds it and
the app with Release's compiler settings (-O, whole-module, no
-enable-testing) and the development identity (D22), so the test host is
CortaDev.app and never the installed Corta. The bundle imports the app
without @testable — testability inhibits exactly the optimisation this
number exists to see — and so reaches only what the renderer declares
public. Every report names the configuration it was built in: the same
plan under the scheme's default configuration is a Debug run, and says
Debug, -Onone on its first line.
Until 2026-10-14 none of the three test plans turned coverage off, and
Xcode reads a missing codeCoverage as on: this plan's build compiled the
app and both libraries with -profile-generate, so every frame-CPU figure
up to §5.17 includes coverage counters. They compare with each other — both
sides of each A/B carried them — but not with figures from §5.19 on.
CORTA_BASELINE_OUTPUT, CORTA_UPLOAD_OUTPUT and
CORTA_CONSTRUCTION_OUTPUT move the three reports, which default to
/tmp/corta-frame-cpu-baseline.txt, /tmp/corta-instance-upload.txt and
/tmp/corta-renderer-construction.txt.
What the figure includes. The timed window runs from the render
call to waitUntilCompleted, so it holds the instance rebuild, the
upload, the encode and the GPU's round trip. In Release the CPU side is
the small part: InstanceUploadBenchmarkTests' full-rebuild row, which
stops the clock before the commit, reads ~0.13 ms p50 where this one
averages ~0.6 ms, and its p95 is the GPU's scheduling. A change to the
CPU path is best read on that row; the averaged figure stays the D17
baseline so the history below remains comparable.
Mac audit closeout (2026-10-08–09). The dated record
rebuilds the exact 1.1.8 tree and the audit follow-up on the same Apple M5,
macOS 27.0.1 (26A434), Xcode 27.0 (27A266a), AC power, Benchmark -O.
Three initial runs averaged 0.630 ms on 1.1.8 and 0.627 ms after #277/#278.
After the atlas-overflow correction, a further batch averaged 0.678 ms, with
larger GPU-completion tails; CPU-only full rebuild p50 was 0.135 ms versus
0.133 ms in the preceding batch. Three subsequent alternating runs average 0.831 ms on 1.1.8 / 0.823 ms
on the final fix; CPU-only p50 ranges overlap (0.145–0.154 /
0.133–0.158 ms). The paired sample does not show a stable frame-time regression.
The record includes the per-run numbers and A1 stress verification: the fixed
first 600 frames average 0.78 ms CPU, subsequent batches 0.60–0.65 ms, with a
4.7% main-thread sample versus 36.6% before the overflow correction. The overflow correction
prevents deferred work from forcing a whole-screen rebuild after eviction;
it retains follow-up frames for a static CJK screen that fits the atlas.
The bridge (2026-09-27). Until 1.1.0 this figure was taken under the
Debug test action (-Onone), which is the column §5.6 still shows for
1.0.x. It was re-recorded both ways once, on the same commit, machine and
toolchain, three alternating runs each:
| Build | Frame CPU avg (runs) | Full rebuild p50, CPU only |
|---|---|---|
Debug, -Onone | 2.12 ms (2.14 / 2.10 / 2.13) | 1.18 ms |
Release, -O | 0.74 ms (0.82 / 0.68 / 0.74) | 0.13 ms |
Apple M5, macOS 27.0 (26A428), Xcode 27.0 (27A266a), on mains power. The Debug build spends nine times the CPU on the rebuild; that ratio, not the GPU wait both share, is what the Debug figure was mostly measuring.
Shapes that were only there for Debug. Three render-loop idioms were written against the Debug figure and were re-measured under Release before being kept or undone (five alternating runs each, full-rebuild p50 median, same machine):
| Shape | Release, kept | Release, ordinary Swift | Outcome |
|---|---|---|---|
Raw masks instead of OptionSet.contains | 0.127 ms | 0.130 ms | Undone — contains is elided under -O |
nil instead of an empty overrides dictionary | 0.137 ms | 0.132 ms | Undone — no cost to avoid |
| The palette read once per row, not per cell | 0.130 ms | 0.137 ms | Kept — the per-cell global read costs ~5% in Release too |
A fourth shape turned out to be doing real work: removing the
single test that rejects cells with no underline, strikethrough, conceal
or link cost ~8% (0.128 → 0.139 ms), so it stays — spelled as an
OptionSet test rather than a mask. After the cleanup the Release figure
is 0.58 ms avg (0.50 / 0.62 / 0.63; full rebuild p50 0.128–0.135 ms),
which is inside the bridge's spread.
Metal 4 as the only backend (#109, 2026-09-27). Same machine,
toolchain and power as the bridge, three runs each side; before is
main with the classic path as the default, after is the branch:
| Before (classic path) | After (Metal 4 only) | |
|---|---|---|
| Frame CPU avg | 0.68 ms (0.677 / 0.653 / 0.692) | 0.55 ms (0.586 / 0.535 / 0.526) |
| Frame CPU p95 | 2.65–2.98 ms | 0.70–0.86 ms |
| Typing (1 row) p50, CPU only | 0.035 ms | 0.018 ms |
| Scroll (shift + 1 row) p50, CPU only | 0.012 ms | 0.016 ms |
| Full rebuild p50, CPU only | 0.123–0.129 ms | 0.140–0.151 ms |
| Backend construction, warm | 0.000 ms (QuadRenderer) | 0.057 ms p50 (Metal4Backend) |
The averaged figure and its tail both fall: a frame is now one render
pass, where the classic path ran up to three, each a tile load and store
of its own. The CPU-only rows are not like for like and are recorded as
such: render now commits inside its window (the backend owns the
command buffer), and the upload benchmark waits for each frame's GPU
completion outside it, because a frame slot is released only when its
previous frame completes. A warm backend costs 0.06 ms per pane where
QuadRenderer cost nothing: each pane's backend owns its own queue,
command buffers, allocators and residency set.
5.9 A snapshot's lifetime (#111)
A Grid snapshot shares the scrollback's arenas with the reader. While it
lives, the reader's next Scrollback.push copies the batches array and
the tail arena (up to 256 rows × width × 16 bytes) — once, since the copy
is the reader's own from then on. Until 1.1.0 the frame's snapshot was
held from one prepareFrame to the next, so the reader paid that copy
once per frame under any flood; since #109 it is released as soon as it
is diffed.
corta-bench's feed throughput under a 60 Hz snapshotter measures the
difference: the reader feeds in the session's 16 KiB lock slices while a
.userInteractive thread takes a snapshot under the same lock at 60 Hz,
and drops it at once or holds it to the next tick. Median of three
alternating runs, -c release:
| Corpus | No snapshotter | Released at once | Held to the next tick |
|---|---|---|---|
yes lines (y\r\n, 32 MiB) | 53.1 MiB/s | 52.4 MiB/s | 52.1 MiB/s |
| 200-column lines (64 MiB) | 207.6 MiB/s | 199.5 MiB/s | 199.8 MiB/s |
The copy itself, timed on its own — one 200-column line fed into a full scrollback, 1,000 samples each:
| p50 | p95 | p99 | max | |
|---|---|---|---|---|
| Nothing sharing the scrollback | 0.001 ms | 0.001 ms | 0.002 ms | 0.005 ms |
| A snapshot alive (the copy-on-write) | 0.008 ms | 0.014 ms | 0.020 ms | 0.089 ms |
Apple M5, macOS 27.0 (26A428), Xcode 27.0 (27A266a), on battery. Held and released are inside each other's run-to-run spread, and the second table says why: the copy is ~8 µs, so a snapshot held across every frame cost the reader ~0.5 ms a second at 60 Hz — well under the noise of a throughput run (a one-off run at 2 kHz could not separate the two either). What a snapshot does cost is the lock hand-off, the ~4% between no snapshotter and either mode on wide lines, which is the frame's to pay. The benchmark stays so that a snapshot that starts outliving its frame again shows up as a number.
Two holders outlive a frame on purpose: a search sweep (Task.detached,
for the sweep's duration) and an export (until the save panel's row walk
finishes). Each pays the copy once. A selection drag re-snapshots per
mouse event, so it holds one for at most an event's interval.
5.10 Main-actor wakes under a flood (#112)
The reader calls onOutput once per parse batch, and a yes flood is
tens of thousands of batches a second. Until 1.1.0 each one enqueued a
Task on the main actor to resume the display link and restart the
long-task notifier's idle Timer. Now each session has an
OutputWakeGate: a batch hops only when the gate was idle, and the frame
that takes the flag in prepareFrame re-arms it, so a flood wakes the
main actor at most once a frame. The notifier no longer hears about
output at all; its idle timer reads the gate's last-output time when it
fires and re-arms itself for the rest of the grace, which also keeps it
right while a hidden pane's frames are paused. The reader's batch is fed
to the parser as slices of its buffer (Terminal.feed(_: ArraySlice)),
not copied into an Array per 16 KiB lock slice.
| Measure | Before | After |
|---|---|---|
corta-bench, yes at 200 columns, frames taken at 60 Hz | 42,499 batches/s, one hop each | 50 hops/s |
The app, Debug, one yes pane, 5 s os_signpost trace: output / wake events | 22,014 / 43,904 (a hop per batch) | 22,005 / 167 (as many as frame, 167) |
| Release frame CPU (D17), three alternating runs | 0.689 ms avg (0.684 / 0.712 / 0.670) | 0.691 ms avg (0.707 / 0.617 / 0.749) |
| Parser-only / parser + grid / core feed, three alternating runs | 716–768 / 158–161 / 140–144 MiB/s | 739–748 / 164–165 / 143–146 MiB/s |
Apple M5, macOS 27.0 (26A428), Xcode 27.0 (27A266a), on battery. The
signpost counts are intervals' begin and end events, so a hop or a frame
is two. The frame-CPU figure does not move, as it should not: the render
loop is unchanged, and D17 is recorded because the frame's entry point
is. corta-bench's main-actor wakes under flood holds the number.
5.11 The per-cell loop without hashing or allocation (#113)
Three changes to TerminalRenderer's instance build, each measured on
its own. GlyphAtlas.glyph(forASCII:style:) reads a 512-entry table
indexed by (style << 7) | scalar instead of hashing a key into a
dictionary; the table lives on the ASCII page, so an eviction or a font
reset clears it. BlockElements.pieces(for:) returns an inline
three-slot value instead of a fresh [Piece] per cell. And
rebuildDamagedRows builds every damaged row into scratch first, then
places them per instance array the cheaper way: row by row in place when
the instances that would move come to less than one array (typing moves
none; a scroll's new bottom row moves only the overlay), otherwise one
pass that copies unchanged rows and takes rebuilt ones from scratch —
where it had spliced per row, O(rows × instances) when a redraw changes
every row's count.
InstanceUploadBenchmarkTests gained two rows for it: every row
redrawn (the whole screen rewritten in place at new lengths) and full
rebuild, blocks (a screen of U+2580–U+259F). CPU-only p50, median of
three alternating runs, each step against the one before:
| Scenario | Before | ASCII table | Inline pieces | One-pass placement |
|---|---|---|---|---|
| Full rebuild | 0.166 ms | 0.128 ms | 0.130 ms | 0.124 ms |
| Every row redrawn | 0.235 ms | 0.173 ms | 0.169 ms | 0.119 ms |
| Full rebuild, blocks | 0.336 ms | 0.408 ms | 0.129 ms | 0.138 ms |
| Typing (1 row) | 0.021 ms | 0.019 ms | 0.019 ms | 0.019 ms |
| Scroll (shift + 1 row) | 0.020 ms | 0.017 ms | 0.017 ms | 0.017 ms |
Heap allocations on the rendering thread per frame, counted through
libmalloc's malloc_logger hook around render (a verification probe,
not a checked-in test), warm atlas:
| No-damage frame | Full rebuild, ASCII | Full rebuild, blocks | |
|---|---|---|---|
| Before | 16 | 16 | 10,555 |
| After | 16 | 16 | 16 |
The 16 are the frame's own (encoder and command buffer), with or without a rebuild; the ASCII path allocated nothing before either — the dictionary cost a hash per cell, not an allocation — and a block element cost one array per cell.
The averaged D17 figure did not move with them. Nine runs each over three sessions, the three orders mixed: before 0.705 ms avg (0.586–0.875), after 0.740 ms (0.587–0.885). The first session alone put the after figure 0.09 ms higher; the third put it 0.08 ms lower, and a control that spun the saved 40 µs back into every frame landed inside the spread of both. The window runs to the GPU's completion (§5.8) and its spread between runs is several times the CPU change, so, as §5.8 says, the CPU-only rows are where this change is read.
Apple M5, macOS 27.0 (26A428), Xcode 27.0 (27A266a), on battery.
5.12 Overlays against the Metal frame, and presentsWithTransaction (#124)
The question #124 left: do AppKit overlays drawn over the Metal layer fall out of step with the text by a frame, and does presenting inside the Core Animation transaction fix it? A screen recording is not available to an automated check, so the measurement uses what a screenshot is — one composited frame — and makes a one-frame gap visible in a single still.
Method. A scratch ZDOTDIR whose .zshrc prints, every 16 ms (about
one frame), OSC 133 A, $ step N, OSC 133 C, output line N and
OSC 133 D;s, with s = 1 when N % 3 == 0. Every command is two rows,
so the status rule of a prompt that is one frame stale lands exactly on
the previous command's prompt: the positions stay right and the colour
pattern moves. A throwaway XCUITest launches the staged development build
and takes window.screenshot() repeatedly while the output scrolls; a
Swift script reads each prompt's number with Vision and the rule's colour
beside it, and reports the shift s (0, 1 or 2) for which every rule's
colour is the status of command N + s. presentsWithTransaction was a
launch-time switch in the spike, not in the tree.
| Configuration | Frames | Shift 0 | Shift 1 | Shift 2 |
|---|---|---|---|---|
| Default | 8 | 0 | 5 | 3 |
presentsWithTransaction | 8 | 0 | 5 | 3 |
CORTA_FRAME_LATENCY=1 | 12 | 0 | 5 | 7 |
| Both | 12 | 0 | 7 | 5 |
The core's mapping of records to marked rows is correct, checked against
a grid snapshot older than the records. The rules lead because
ShellOverlayView commits with this run-loop pass while the drawable for
the same snapshot lands one to two frames later; neither property changes
that. The fix is to draw what must stay locked to the text in the Metal
pass (#238, §5.13).
Cost. MeasurementUITests/testKeypressToGlass, Benchmark
configuration, 200 samples, two rounds alternating:
presentsWithTransaction | p50 | p95 | p99 |
|---|---|---|---|
| Off | 117.3 / 109.8 ms | 134.4 / 133.5 ms | 135.6 / 136.0 ms |
| On | 118.6 / 111.5 ms | 134.7 / 133.8 ms | 135.8 / 135.2 ms |
These absolute figures are not comparable with §5.7's (another session, another load on the machine); the A/B within the session is. On costs a millisecond and a half at the median and buys nothing, so it stays off (D25).
The titlebar. The same session tried NSBackgroundExtensionView
under the titlebar; that is a visual result, recorded in D24.
Apple M5, macOS 27.0.1 (26A434), Xcode 27.0 (27A266a), on battery.
5.13 The command-status rules in the Metal pass (#238)
§5.12 found the rules one to two frames ahead of the text. They are now
instances of their own, rebuilt from the frame's rows whenever the frame
changes and drawn in the same render pass, scissored to the 2-point column
6 points left of the grid; ShellOverlayView keeps only their tooltips.
Acceptance. §5.12's method unchanged — a prompt-and-output pair every
16 ms, every third failing, window.screenshot() while it scrolls, Vision
reading each prompt's number and the rule's colour beside it — on the same
machine, the development build, before and after:
| Build | Frames | Shift 0 | Shift 1 | Shift 2 |
|---|---|---|---|---|
Before (main, AppKit rules) | 16 | 1 | 9 | 6 |
| After (rules in the Metal pass) | 16 | 16 | 0 | 0 |
Each frame showed 8 to 14 prompts, every one with a rule. A hover over a rule still shows its tooltip.
D17. Five runs each of -testPlan Release -configuration Benchmark,
alternating, the first three before-first and the last two after-first;
medians, with the range:
| Build | Frame CPU avg | Full rebuild p50, CPU only | Every row redrawn p50 |
|---|---|---|---|
| Before | 0.825 ms (0.794–0.857) | 0.149 ms (0.141–0.167) | 0.126 ms (0.125–0.131) |
| After | 0.769 ms (0.684–0.874) | 0.154 ms (0.149–0.161) | 0.134 ms (0.125–0.138) |
The averaged figure is inside its own spread (§5.8). The CPU-only rows moved by 5 and 8 µs, within the before build's own range for the full rebuild; the rule pass is one walk of the frame's rows on a changed frame, and the benchmark's screen carries no marks.
Apple M5, macOS 27.0.1 (26A434), Xcode 27.0 (27A266a), on mains power.
5.14 Scrolling without per-line screen allocation (#281, #283)
October 9, 2026: Apple M5 MacBook Air (Mac17,3), 32 GB; macOS 27.0.1
(26A434), Xcode 27.0 (27A266a), Swift 6.4 (swiftlang-6.4.0.34.1), AC,
Low Power Mode off. Baseline b0917dd; same toolchain, Release optimization,
three alternating before/after launches with no concurrent builds or fuzzing.
The original corta-bench corpus, slices and snapshotter modes were retained.
Each feed-throughput launch already alternates its three snapshot modes three
times; the table reports the median of the three launch medians.
| Core path | Before | After |
|---|---|---|
| Parser + grid, MiB/s | 158.0 | 355.9 |
| Core feed, MiB/s | 137.2 | 317.3 |
yes, no snapshotter, MiB/s | 50.1 | 85.4 |
yes, 60 Hz released snapshot, MiB/s | 49.4 | 83.9 |
yes, 60 Hz held snapshot, MiB/s | 49.3 | 83.7 |
| 200-column rows, no snapshotter, MiB/s | 203.8 | 491.8 |
| 200-column rows, released snapshot, MiB/s | 195.3 | 452.9 |
| 200-column rows, held snapshot, MiB/s | 194.8 | 446.4 |
| Warm-run memory increment, 100k × 120 rows, MB | 185.0 | 185.9 |
| Standalone memory increment, same rows, MB | 192.4 | 192.4 |
The warm-run resident increment depends on allocations left by the earlier
parse phase: resident memory after filling was 326.6 MB before and 326.5 MB
after (medians), while the starting resident value differed. To isolate that
confound, corta-bench --memory-only runs only the existing memory workload
in a fresh process. Standalone before range 192.4–192.9 MB, after 192.4 MB;
this shows no footprint increase and retains the approximately 200 MB target.
The harness's MB unit divides bytes by 1,048,576, as earlier records did.
The isolated figure must not replace the historical 185.0 MB without naming
the changed measurement sequence.
The render table uses InstanceUploadBenchmarkTests, Menlo 14 @1x,
120 × 40, Release -O. Region scroll retains a 39-row region and status
line and writes a full-width replacement row each frame. History is filled
with SGR-varied full-width rows and alternates by one or 13 rows; flood output
reanchors a history viewport. Each run has 5 warmups and 60 samples and waits
for GPU completion outside the CPU timing window. The same new harness was
compiled against the baseline core/renderer and the changed core/renderer.
Medians of three p50 and p95 readings:
| CPU-only scene | Before p50 / p95, ms | After p50 / p95, ms |
|---|---|---|
| Region scroll by 1, with replacement text | 0.130 / 0.146 | 0.026 / 0.030 |
| History scroll by 1 | 0.103 / 0.111 | 0.024 / 0.026 |
| History scroll by 13 | 0.098 / 0.104 | 0.047 / 0.048 |
| Flood while anchored in history | 0.096 / 0.103 | 0.010 / 0.010 |
| Full rebuild, same harness | 0.153 / 0.168 | 0.132 / 0.159 |
D17's separate full-rebuild CPU benchmark averaged 0.769 ms before (0.806 / 0.786 / 0.716), 0.753 ms after (0.779 / 0.755 / 0.726). Its p95 ranges overlap: 1.121–3.220 ms before, 1.329–2.929 ms after. No full-rebuild regression was observed in this run. These measurements do not establish displayed flood GPU tails, compositor latency or idle energy. The anchored-history regression test asserts 0 rebuilt rows after new output.
Raw measurements preserve all rounds. Verification and outstanding gates record the original background tests and limits. The subsequent authorized desktop follow-up includes a valid steady-state Allocations call tree: no per-line Line allocation, with the two reserved history arrays still allocated once per 256-row batch. It also records final full Unit and D17, live flood tails and outstanding gates.
October 11 merge follow-up: the final validation corrects flood sampling before teardown, records three passing sustained default-driver pairs and a final-source check, matches scripted input cadence across power modes, withdraws no-sync, and attributes the three-second idle timer to Apple AGX deferred GPU setup. Historical negative results above are retained with their method limits; absolute zero process wakeups and an unperformed human panel observation are not claimed.
5.15 Memory and core optimization (#286–#289, 2026-10-11)
The same-machine before/after record, codec experiment, Unicode corpus,
parser profile, stage gates and verification limits are in
test-results/2026-10-11-memory-core-optimization.md.
Run corta-bench --unicode, --compression, --memory-only,
--search-only --compressed, --reflow-only --compressed or --cold-history
for the new drivers. --unicode defaults to five measured samples after
one warmup per mode; --unicode-samples N selects 1–20 samples. Maintenance runs during ingestion in the memory driver;
--cold-history --deferred demonstrates allocator high-water retention when
compression is postponed until the entire uncompressed history exists.
The SIMD parser differential test has a scalar advance reference and
random feed boundaries. CORTA_DIFFERENTIAL_CASES=1000000 swift test --package-path CortaTerminal --filter ParserSIMDTests runs the acceptance load.
The four-pane 400×120 CPU gate has one render target per pane. GPU cell
expansion depended on #284's render thread, which was removed (D04), so
that stage was never taken; these figures do not approve it. Row-relative
instance positions and shared ASCII atlases failed their benefit gates and
were not adopted.
5.16 Shared images, render ownership and idle energy (#291/#284/#290, 2026-10-11)
Implementation, measurements and acceptance limits record three large-image decode/texture rounds, full-UV sampling, reader QoS throughput/backpressure, real cursor handoffs and a stalled rendering A/B. The opt-in render thread's final validation improved the steady and stalled A/B p50/p95/p99 and held 1/2/4-pane flood interval p99 at 16.67 ms on the 60 Hz display, but never showed a power saving; it was removed on 2026-10-14 (DECISIONS D04). The unchanged helper sandbox now receives an inherited anonymous mapping and emits only an eight-byte header. The follow-up measurements record actual Kitty payload-to-glass latency/footprint and real Swift-build/find CPU/wakeup proxies. Those proxies do not establish a power saving.
5.17 Full screen without the compositor (#285, 2026-10-13)
A drawable a Metal 4 command buffer wrote is never presented directly to the
display, on macOS 27, whichever queue presents it: the window server
composites it, a frame later. Copying the Metal 4 frame into the drawable on
a classic MTLCommandQueue lifts that. So a full-screen window with one pane
renders into a staging texture and presents by copy
(Metal4Backend.presentsByCopy), and drops its canvas corner mask; nothing
visible changes. Windowed and split panes keep the plain Metal 4 path.
On macOS 26 every window presents by copy: that system's WindowServer was seen to abort while compositing a drawable presented from the Metal 4 queue (#380). macOS 27 and later keep the full-screen-only behaviour measured below.
What the copy costs on macOS 26, measured on the affected Mac (M4 Pro, Xcode 26.2) as an alternating A/B in one optimized build: 60 full rebuild frames per path into a 2940×1846 texture, to GPU completion. Direct averaged 0.358 ms (p50 0.345, p95 0.398); by copy 0.555 ms (p50 0.558, p95 0.616), about 0.20 ms more per rendered frame, plus one staging texture per pane (about 21.7 MB at that size). Composition, keypress-to-glass latency and energy are not included and have not been measured on macOS 26. An idle pane submits no frames, so it pays nothing.
Measured on the M5 Air's built-in 60 Hz panel, Benchmark -O, full screen,
one pane:
| Method | today p50 / p99 | by copy, direct p50 / p99 |
|---|---|---|
a person typing, in-app keypressToPresent (3 runs each) | 64.3–67.6 / 153.6–179.7 ms | 49.1–50.8 / 82.9–84.2 ms |
| Typometer, 600 samples each | 57.0 / 73.6 ms | 50.5 / 67.2 ms |
A person typing gets a frame back at the median and half the tail: the
display link's lead drops from three frames to two once the surface is
presented directly. While Typometer captures the screen the lead stays at
three frames, so its gain, ≈ 6.5 ms at every percentile, is the compositor
pass alone. Under XCUITest the frames are rejected for direct presentation,
so an XCUI run cannot measure this. The copy costs ≈ 0.8 ms of GPU round
trip a presented full-screen frame (measurePresentByCopyCost); D17's
windowed figure did not move (0.37–0.39 ms against main's 0.40–0.57 ms,
battery, alternated). The
record has the method (Metal
System Trace's Direct to Display column), the probe that isolated the
Metal 4 blocker, every run and what was not verified.
5.18 Search and reflow without per-cell work (2026-10-02)
An ASCII query keeps a cell-based path for scalars whose case folding
cannot match ASCII; combining and format scalars, and ASCII folds, keep the
full Unicode match. Reflow reserves row capacity and reuses its scratch
cell and row-start arrays across logical lines. corta-bench, release, two
alternating rounds each against main (0af9fa9), no build beside them:
| Benchmark | main | change |
|---|---|---|
| Reflow, 100k lines, 120 → 80 columns | 92.7 / 92.3 ms | 48.1 / 48.4 ms |
| Search, 100k lines, ASCII scrollback, warmed p50 | 25.7 / 25.1 ms | 25.5 / 25.8 ms |
| Search, 100k lines, non-ASCII scrollback, ASCII query, p50 | 450.9 / 449.1 ms | 27.4 / 27.6 ms |
The frame path did not move (D17 averages 0.840–0.913 ms on both sides). Apple silicon MacBook Air, macOS 27.0.1, Xcode 27.0 (27A266a).
5.19 Coverage off, inlined cell reads, allocation-free clusters (2026-10-14)
Three changes, measured together against the tree before them with coverage already off on both sides (§5.8):
- No coverage counters. The test plans set
codeCoveragefalse. - The renderer's cell reads inline. The app builds the core as a
module of its own without cross-module optimisation, so the per-cell loop
called
Line's subscript andcountand an addressor for everyCellAttributesflag it tested — about ten calls a cell. Those members are@inlinable, the ASCII glyph hit is an inlined load from a table in aletbuffer (no exclusivity check, no prologue), and the instance arrays areContiguousArray. - A grown grapheme cluster is found without building its array
(
GraphemeTable.intern(extending:base:with:)): steady emoji, flag and accent output allocated about twice per combining scalar.
InstanceUploadBenchmarkTests, CPU-only p50, three alternating Benchmark
rounds on the M5 Air (macOS 27.0.1, Xcode 27.0), the machine otherwise in
use:
| Scenario | Before | After |
|---|---|---|
| Full rebuild | 0.067–0.074 ms | 0.037–0.050 ms |
| Every row redrawn | 0.058–0.064 ms | 0.036–0.041 ms |
| Full rebuild, block elements | 0.063–0.065 ms | 0.058–0.059 ms |
| Typing (one row) | 0.011–0.016 ms | 0.011–0.017 ms |
D17's average, which waits on the GPU, stayed inside its spread (0.327–0.396 ms before, 0.273–0.351 ms after). Splitting the renderer's long methods afterwards measured within noise of this (full rebuild p50 0.036–0.041 ms on both sides).
Two tests now hold the allocation figures the M6 work found by hand:
HotPathAllocationTests (core, -c release in CI) requires zero heap
allocations from parse to grid for ASCII, SGR, cursor and erase sequences,
wide text and grapheme clusters, and only arena growth when scrolling into
history; RenderAllocationTests (Release plan) requires a frame's
allocation count not to grow with damage. Metal's own bookkeeping makes
that count drift between 17 and 20 a frame, so the check allows 16 over the
idle frame — one allocation per row would add 40.
5.20 A large image decoded outside the session lock (2026-10-21)
The reader feeds under the session lock, and every frame takes that lock for
its bell, synchronized-output and snapshot reads. A Kitty transmission's
last chunk used to decode inside the feed — base64, then zlib, over up to
64 MiB — so the frame waited out the whole decode, on the main thread every
window shares. Now Terminal.feedDeferringImageDecode stops the feed right
after a transmission of 64 KiB of base64 or more, the session decodes it with
the lock released, and the next acquisition stores, places and answers it
before feeding on: no byte after the image is applied before it, so the
placement, the cursor and the reply are the ones an inline decode gives
(KittyGraphicsDeferredDecodeTests). A clear, resize or reset the user makes
in the pause applies first, as if it came a moment earlier; after a RIS the
image is answered but not stored. The decode stays charged to the image
budget while it runs.
corta-bench --image-stall: a 3840×2160 RGBA image, o=z, in 128 KiB
chunks (32 MiB of stream) through /bin/cat and a real PTY, a
.userInteractive thread reading the bell and a snapshot at 240 Hz.
Release, alternating builds:
| p95 | max | |
|---|---|---|
| Decoded in the feed | 0.007–0.867 ms | 110.2–112.9 ms |
| Decoded outside the lock | 0.006–0.007 ms | 0.008–1.256 ms |
--unicode feed throughput did not move (ASCII 323–329 MiB/s, CJK
85.9–86.7 MiB/s on both sides). Apple M5, macOS 27.0.1 (26A434),
Xcode 27.1 (27A9275), on AC power.
5.21 First draws of CJK text bounded by time (2026-10-21)
A glyph not yet in the atlas is shaped and rasterised on the main thread.
The per-frame cap was 1024 such shapes, taken to be about 20 ms; a Han
character's first draw costs Core Text 40–60 µs at Retina 14 pt, most of it
loading the outline (a second draw of the same glyph costs ~2 µs), so a page
of new CJK text held every window for 50–90 ms — on a new pane, a zoom step,
a first cat of a document.
Now the time spent shaping is counted, around each shape alone, in a ledger
every atlas shares, and a frame begins no new shape once 8 ms of it is spent
(TerminalRenderer.shapingTimePerFrame). The ledger restarts with each
display refresh — FrameScheduler notes the display link's target timestamp
— so the panes drawing in one refresh share one budget and each refresh gets
a fresh one. A frame always gets one shape. The rows that lost a glyph are
marked and rebuilt alone on the frames onGlyphsDeferred asks for. A fill-in
that evicts the atlas once (a page that was nearly full) keeps going; a
second eviction in the same fill-in means the content overflows the atlas,
and it stops until output asks again.
A 30×100 screen of 1,500 Han characters the atlas has not seen,
updateInstances per refresh until nothing is deferred, three blocks of
characters, Debug test host:
| Frames to fill | Worst frame | Total | |
|---|---|---|---|
| 1024 shapes (before) | 2 | 52.3–56.3 ms | 76.7–81.0 ms |
| 4 ms per pane, every row rebuilt per deferred frame (first try) | 23–24 | 5.2–5.6 ms | 104.1–109.1 ms |
| 8 ms per pane, every row rebuilt (second try) | 11–14 | 9.2 ms (one 18.0 ms) | 92.8–128.0 ms |
| 8 ms per refresh, shared, deferred rows only (chosen) | 9–10 | 9.3–10.3 ms (one 15.5 ms) | 79.1–83.5 ms |
Four panes each showing such a page in the same refreshes fill in over 37 refreshes, the worst 13.8–14.3 ms; with a budget per pane that refresh would have cost four.
In the launched Debug app (CORTA_RENDER_METRICS, a scratch ZDOTDIR whose
shell prints 30 rows of new Han text at launch), the slowest cpuFrames were
86.8 / 90.1 ms before and 14.9–15.6 ms after, every other frame ≤ 13.1 ms.
D17 stayed inside its spread (0.646 / 0.721 ms against 0.657 / 0.697 ms).
A page now appears over about 80 ms rather than after one long frame; a 4 ms budget doubled that for a stall a dropped frame already hides. Holding ⌘= over CJK text resets the atlas each step, so each step fills in rather than stalling. Apple M5, macOS 27.0.1 (26A434), Xcode 27.1 (27A9275).