docs/PERFORMANCE.mdVer el original ↗
En esta página

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.

MetricTargetWhy
Frame budget (CPU)< 4 ms120 Hz ProMotion is an 8.3 ms frame
Parser throughput> 100 MB/s single-threadcat of a large file must not be the slow part
Keypress → pixel latency< 1 frame + input latencyThe metric a user actually feels
Scrollback memory100k 120-column lines within ~200 MBLog-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.

CategoryTargetHeld accountable by
InputKeypress → glass feels immediate; §1's latency targetRenderMetrics.keypressToPresent via MeasurementUITests.testKeypressToGlass (§5.7), corta-bench's benchmarkKeypressLatency
Sustained outputA flood (yes, a build log, a training run) does not fall behind or drop frames below §1's frame budgetcorta-bench's parse-throughput and write-backpressure benchmarks; MeasurementUITests' 1-, 2- and 4-pane floods
ScrollingScrolling a long buffer tracks the pointer/trackpad with no visible stutterCORTA_RENDER_METRICS ring buffer, by hand (TESTING.md, Measuring the app); InstanceUploadBenchmarkTests region/history/flood-while-scrolled cases, plus MeasurementUITests.testRegionAndHistoryScrolling (interactive)
StartupA warm launch reaches an interactive window fast enough that switching to Corta does not feel like waiting for an app to openMeasurementUITests.testLaunchToFirstWindow (XCTApplicationLaunchMetric, 5 warm launches)
Memory§1's scrollback figure holds, and closing panes/windows returns memory rather than leaking itcorta-bench's scrollback-footprint and peak-RSS benchmarks; MeasurementUITests.testOpenAndCloseWindowReturnsMemory
EnergyAn 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 requireAn 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
CompatibilityThe real-program and esctest pass rates CONFORMANCE.md already tracksCONFORMANCE.md §4.2 (esctest), §4.4.2 (real-program table), §4.6 (manual scenario pass) — cross-referenced here rather than duplicated
RecoveryA 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 restoredSessionRestore'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.

RuleReason
struct + ContiguousArray, never a class per cell or glyphA class per cell means an ARC retain/release storm per frame
UInt8 / UInt32 buffers for bytes and scalarsString carries grapheme-breaking semantics that are not free
Convert to String only at the Core Text boundaryShaping is the only place that needs it
No NSString / NSArray / ObjC bridgingBridging cost per element
No allocation per cell or per frameReuse buffers; size them at resize, not at draw
Reuse pipelines and the atlas texture when the font changesRebuilding 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 bufferAvoids a CPU/GPU stall waiting on the previous frame
Rebuild the instance buffer only on damageIdle 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.md D05).
  • 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.

BenchmarkMeasures
vtebenchThe standard cross-terminal comparison
cat of a ~100 MB text fileEnd-to-end throughput
find / 2>/dev/nullSustained realistic output
yesWorst-case flood; also verifies §2.1
Neovim scrolling a large file in tmuxInteractive full-screen redraw path
Parser-only harness over a byte corpusIsolates 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:

VariableFixed at
MachineThe recorded machine and chip
BuildRelease (-c release / the Release scheme), never Debug
DisplayBuilt-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 factorThe display's native backing scale (the atlas is rasterised per scale)
FontSystem monospaced at 12 pt, the default when these figures were measured (13 pt since 2026-10-20)
Window120×30, one pane, not full screen, no tab bar
PowerMains, not battery — the efficiency cores and a lowered display refresh rate are both on the table otherwise
Other loadNo other application in the foreground; the app activated and its window frontmost
Test programNamed 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):

VariableValue
Xcode26.6, build 17F113 (stable channel; xcodebuild -version)
Swift compiler6.3.3 (swiftlang-6.3.3.1.3, clang-2100.1.1.101; swift --version)
Swift language modeSwift 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 targetMACOSX_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 / buildSwift compilermacOS SDK / buildLanguage / deploymentCore / app result
27.0 / 27A266a6.4 (swiftlang-6.4.0.34.1, clang-2100.3.34.1)27.0 / 26A425Swift 6 / macOS 26.0615 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:

SignpostCovers
keyDownkey event → bytes written to the PTY
outputa parse batch has been applied to the grid (a point, not an interval — it is emitted on the reader thread)
wakethe MainActor hop that un-parks the display link — at most one a frame (§5.10)
framethe vsync callback: damage diff, instance build, nextDrawable
commitencode and submit
gpusubmission → 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:

  1. 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 unguarded activate that 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.
  2. InputLatencySignposts.keyDown only wrapped TerminalView .deliverBytes — the control-sequence bypass path (⌘/⌃, or an event the input context declines). Ordinary typing, composed or not, commits through TerminalView.insertText instead (Cocoa's input-context pipeline, not a Corta choice), and Return/Delete/Escape/the arrows through doCommand(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 zero keyDown signposts 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 in InputLatencySignposts.measure(.keyDown), matching deliverBytes.

With both fixed, a 12-second recording of ordinary typing (122 keystrokes) gives:

Stagenminp50p99max
keyDown → next output1220.07 ms0.13 ms0.19 ms0.43 ms
output → next frame begin1310.56 ms9.46 ms15.61 ms15.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:

Metricnavgp50p99max
drawableWait6000.00 ms0.00 ms0.00 ms0.00 ms
cpuFrame6000.38 ms0.34 ms0.78 ms3.79 ms
gpu6000.93 ms0.89 ms1.22 ms8.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:

RunMin, msMax, msAvg, msSD, ms
A — default (maximumDrawableCount = 3)45.499.470.113.9
B — CORTA_MAX_DRAWABLES=246.295.470.413.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:

Terminalp50p95p99max
Corta 1.1.960.479.681.282.3
iTerm2 3.6.1138.556.760.869.3
Ghostty 1.3.134.539.743.750.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):

CaseCortaGhosttyiTerm2
scrolling301721
scrolling_top_region79222,040
scrolling_fullscreen232536
dense_cells10780
unicode36950
cat 100 MiB, MiB/s887617

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.

Metric1.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 throughput143.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 + grid766.0 / 169.2 MiB/s751.7 / 155.4 MiB/s779.4 / 167.2 MiB/s766.9 / 161.4 MiB/s806.3 / 166.9 MiB/ssame run
Memory @ 100k × 120 lines185.0 MB185.0 MB185.0 MB184.4 MB185.0 MBcorta-bench
Keypress → grid (core side)p50 0.012 / p95 0.014 / p99 0.016 msp50 0.012 / p95 0.014 / p99 0.016 msp50 0.018 / p95 0.023 / p99 0.029 msp50 0.009 / p95 0.011 / p99 0.012 msp50 0.014 / p95 0.018 / p99 0.020 mscorta-bench, 2000 samples; excludes vsync and display
Frame CPU, 120×40 full rebuild, Debugnot re-measurednot re-measurednot comparable — now a Release figure, next row1.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, Release0.57 ms avg (0.56 / 0.59 / 0.57); full rebuild CPU-only p50 0.126–0.127 msnot re-measured0.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 floodednot re-measurednot re-measurednot re-measurednot re-measuredavg 0.60 / 0.14–0.49 ms; p99 2.59 / 0.38–0.45MeasurementUITests floods, CORTA_RENDER_METRICS
GPU, 2 / 4 panes floodednot re-measurednot re-measurednot re-measurednot re-measuredavg 0.49 / 0.47 mssame
Idle CPU, Release, 20 snot re-measurednot re-measurednot re-measurednot re-measured0.05%; occluded 0.0–0.1%same
Launch → first windownot re-measurednot re-measurednot re-measurednot re-measured208 ms (2-pane restore), 451 ms (4-pane)same
Spawn: zsh -l → first outputp50 42.1 msp50 40.3 msp50 41.2 msp50 46.8 msp50 44.5 mscorta-bench
Reflow, 100k lines, 120 → 80 columns53.3 ms58.9 ms51.2 ms97.9 ms94.1 mscorta-bench
Search, 100k lines, one query25.5 ms warm25.8 ms warm25.6 ms warm~400 ms warm~395 ms warmcorta-bench
Keypress → glass, scriptednot re-measurednot re-measurednot re-measurednot re-measured61.9 ms avg; p50 61.4 / p95 69.7 / p99 70.9§5.7
Keypress → glass, a person typingnot re-measurednot re-measurednot re-measurednot re-measured66.3 ms avg; p50 67.0 / p95 78.7 / p99 84.5§5.7, --manual
Energy, background flood (yes, occluded)not re-measurednot re-measurednot re-measurednot re-measured10.2 W machine-wide on mains; 2.6 W in Low Power Mode; idle, occluded and a static image within the machine's noise floor1.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 hand RenderMetrics the event's timestamp — 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:

KindIncludesComparable 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 sawthe 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:

preferredFrameLatencyp50p95p99max
Default (reads 2.0)63.7 / 64.3 ms72.2 / 70.9 ms80.1 / 72.3 ms82.8 / 72.7 ms
163.3 / 63.1 ms70.0 / 71.5 ms72.8 / 73.3 ms74.1 / 73.6 ms
264.0 / 64.0 ms71.2 / 72.1 ms72.8 / 74.0 ms74.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:

preferredFrameLatencyp50p95p99max
Default (reads 2.0)64.2 ms80.3 ms84.5 ms136.7 ms
165.1 ms80.5 ms134.5 ms145.3 ms
263.6 ms79.5 ms133.0 ms146.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:

BuildFrame CPU avg (runs)Full rebuild p50, CPU only
Debug, -Onone2.12 ms (2.14 / 2.10 / 2.13)1.18 ms
Release, -O0.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):

ShapeRelease, keptRelease, ordinary SwiftOutcome
Raw masks instead of OptionSet.contains0.127 ms0.130 msUndone — contains is elided under -O
nil instead of an empty overrides dictionary0.137 ms0.132 msUndone — no cost to avoid
The palette read once per row, not per cell0.130 ms0.137 msKept — 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 avg0.68 ms (0.677 / 0.653 / 0.692)0.55 ms (0.586 / 0.535 / 0.526)
Frame CPU p952.65–2.98 ms0.70–0.86 ms
Typing (1 row) p50, CPU only0.035 ms0.018 ms
Scroll (shift + 1 row) p50, CPU only0.012 ms0.016 ms
Full rebuild p50, CPU only0.123–0.129 ms0.140–0.151 ms
Backend construction, warm0.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:

CorpusNo snapshotterReleased at onceHeld to the next tick
yes lines (y\r\n, 32 MiB)53.1 MiB/s52.4 MiB/s52.1 MiB/s
200-column lines (64 MiB)207.6 MiB/s199.5 MiB/s199.8 MiB/s

The copy itself, timed on its own — one 200-column line fed into a full scrollback, 1,000 samples each:

p50p95p99max
Nothing sharing the scrollback0.001 ms0.001 ms0.002 ms0.005 ms
A snapshot alive (the copy-on-write)0.008 ms0.014 ms0.020 ms0.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.

MeasureBeforeAfter
corta-bench, yes at 200 columns, frames taken at 60 Hz42,499 batches/s, one hop each50 hops/s
The app, Debug, one yes pane, 5 s os_signpost trace: output / wake events22,014 / 43,904 (a hop per batch)22,005 / 167 (as many as frame, 167)
Release frame CPU (D17), three alternating runs0.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 runs716–768 / 158–161 / 140–144 MiB/s739–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:

ScenarioBeforeASCII tableInline piecesOne-pass placement
Full rebuild0.166 ms0.128 ms0.130 ms0.124 ms
Every row redrawn0.235 ms0.173 ms0.169 ms0.119 ms
Full rebuild, blocks0.336 ms0.408 ms0.129 ms0.138 ms
Typing (1 row)0.021 ms0.019 ms0.019 ms0.019 ms
Scroll (shift + 1 row)0.020 ms0.017 ms0.017 ms0.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 frameFull rebuild, ASCIIFull rebuild, blocks
Before161610,555
After161616

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.

ConfigurationFramesShift 0Shift 1Shift 2
Default8053
presentsWithTransaction8053
CORTA_FRAME_LATENCY=112057
Both12075

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:

presentsWithTransactionp50p95p99
Off117.3 / 109.8 ms134.4 / 133.5 ms135.6 / 136.0 ms
On118.6 / 111.5 ms134.7 / 133.8 ms135.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:

BuildFramesShift 0Shift 1Shift 2
Before (main, AppKit rules)16196
After (rules in the Metal pass)161600

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:

BuildFrame CPU avgFull rebuild p50, CPU onlyEvery row redrawn p50
Before0.825 ms (0.794–0.857)0.149 ms (0.141–0.167)0.126 ms (0.125–0.131)
After0.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 pathBeforeAfter
Parser + grid, MiB/s158.0355.9
Core feed, MiB/s137.2317.3
yes, no snapshotter, MiB/s50.185.4
yes, 60 Hz released snapshot, MiB/s49.483.9
yes, 60 Hz held snapshot, MiB/s49.383.7
200-column rows, no snapshotter, MiB/s203.8491.8
200-column rows, released snapshot, MiB/s195.3452.9
200-column rows, held snapshot, MiB/s194.8446.4
Warm-run memory increment, 100k × 120 rows, MB185.0185.9
Standalone memory increment, same rows, MB192.4192.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 sceneBefore p50 / p95, msAfter p50 / p95, ms
Region scroll by 1, with replacement text0.130 / 0.1460.026 / 0.030
History scroll by 10.103 / 0.1110.024 / 0.026
History scroll by 130.098 / 0.1040.047 / 0.048
Flood while anchored in history0.096 / 0.1030.010 / 0.010
Full rebuild, same harness0.153 / 0.1680.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:

Methodtoday p50 / p99by copy, direct p50 / p99
a person typing, in-app keypressToPresent (3 runs each)64.3–67.6 / 153.6–179.7 ms49.1–50.8 / 82.9–84.2 ms
Typometer, 600 samples each57.0 / 73.6 ms50.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:

Benchmarkmainchange
Reflow, 100k lines, 120 → 80 columns92.7 / 92.3 ms48.1 / 48.4 ms
Search, 100k lines, ASCII scrollback, warmed p5025.7 / 25.1 ms25.5 / 25.8 ms
Search, 100k lines, non-ASCII scrollback, ASCII query, p50450.9 / 449.1 ms27.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 codeCoverage false.
  • 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 and count and an addressor for every CellAttributes flag it tested — about ten calls a cell. Those members are @inlinable, the ASCII glyph hit is an inlined load from a table in a let buffer (no exclusivity check, no prologue), and the instance arrays are ContiguousArray.
  • 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:

ScenarioBeforeAfter
Full rebuild0.067–0.074 ms0.037–0.050 ms
Every row redrawn0.058–0.064 ms0.036–0.041 ms
Full rebuild, block elements0.063–0.065 ms0.058–0.059 ms
Typing (one row)0.011–0.016 ms0.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:

p95max
Decoded in the feed0.007–0.867 ms110.2–112.9 ms
Decoded outside the lock0.006–0.007 ms0.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 fillWorst frameTotal
1024 shapes (before)252.3–56.3 ms76.7–81.0 ms
4 ms per pane, every row rebuilt per deferred frame (first try)23–245.2–5.6 ms104.1–109.1 ms
8 ms per pane, every row rebuilt (second try)11–149.2 ms (one 18.0 ms)92.8–128.0 ms
8 ms per refresh, shared, deferred rows only (chosen)9–109.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).