Latency: what the daemon costs
Running a terminal through contour daemon must not feel different from running it in-process. This
page defines what is measured, what the budgets are, and how to run the gate. For where the time goes
inside one frame, see Profiling with Tracy; for the daemon itself, see
Multiplexer & daemon mode.
The budgets
Both are relative to in-process on the same machine and the same build, with the daemon already running, and inclusive:
| Metric | Budget |
|---|---|
| Launch to first prompt pixel | at most +10 ms (median over in-process) |
| Keystroke to pixel over the local socket | at most +1 ms at the median |
A cold launch (no daemon running, so the client spawns one) is measured and reported as
startup-cold, never gated. The first spawn after an upgrade stages a copy of the install; it is
reported apart as startup-cold-staging and is not part of startup-cold.
Bulk output keeps coalescing at 20 ms (FlushPolicy::coalesceDelay); the gate says nothing about
throughput, but bench-latency headless workload NAME reports MiB/s and Deltas per second of both
modes beside it.
What is timed
The application behind the terminal is always the echo child (bench-latency echo-child): it prints
the prompt >, then echoes every byte it reads, wrapping to a fresh prompt after 60 columns, and ends
on ^D. What a frame shows of that is the cursor column, not the glyph, so the probes detect
the column the echo of key k leaves the cursor at (vthost/latency/EchoProtocol.hpp).
Launch to first prompt.
bench-latency headless:t0is read just beforecreateTab()(in-process) or just before the connect (daemon);t1is when the terminal's, or the mirrored screen's, cursor reaches column 2.bench-latency e2e:t0is the benchmark'ssteady_clockjust before it spawnscontour;t1is thesteady_clockread in theframeSwappedhandler of the first frame that drew the cursor at column 2, reported by the GUI through--latency-probe.steady_clockis one system-wide clock on all three platforms, and the benchmark refuses a report whose process start is before its spawn or after its first prompt ("steady clocks disagree").
Keystroke to pixel.
bench-latency headless:t0is read by the probe immediately before the key is handed toSessionHost::writeInput(in-process) orNativeClient::sendInput(daemon);t1is when the cursor column reaches the key's expected column. Keys are typed 25 to 35 ms apart, so each finds the host idle.bench-latency e2e:t0is read on the GUI thread immediately before the key press event is delivered to the window;t1is read in theframeSwappedhandler of the first frame whoseprepareFrameRhidrew the cursor at the expected column. It includes key translation, the PTY write (in-process) or the Input PDU (daemon), the echo, parsing (in-process) or the Delta and mirror apply (daemon), and render plus swap. It excludes the OS input stack, which is identical in both modes.
Swaps are unpaced by default (--latency-probe selects swap interval 0), because a 60 Hz vsync
quantizes every sample to 16.7 ms, which hides a 1 ms difference. vsync on the e2e command turns
the pacing back on.
Running it
bench-latency is built with -DVTHOST_BUILD_BENCH_LATENCY=ON (on in every preset but
macos-package; it is never installed and never packaged).
# Without a GUI: both modes behind the same SessionHost / daemon code.
bench-latency headless # 10 runs of 50 keys per mode
bench-latency headless runs 20 keys 100 record latency-headless.tsv
bench-latency headless workload ascii seconds 3 # throughput instead of keystrokes
# With the real GUI: `contour terminal` against `contour client`. Needs a display.
bench-latency e2e contour PATH-TO-contour gate
bench-latency e2e contour PATH-TO-contour cold record latency-e2e.tsv
e2e writes a configuration with one profile (bench: the echo child as shell, 120x40, a steady
cursor), starts contour daemon on a socket of its own, and alternates contour terminal and
contour client launches, each with --latency-probe. Every client launch uses a unique --label, so
no registered service of yours answers for it. With cold, the benchmark stops its daemon itself
before each cold launch (the daemon's 10 s idle grace would otherwise make it warm).
On Linux the GUI needs a compositor that delivers frame callbacks. The CI-grade choice is a headless
weston: scripts/run-display-tests.sh --run COMMAND... runs a command under one (and under a private session
bus). Qt's
offscreen platform draws no frames, so a probe under it times out and the benchmark names the column
it waited for.
Reading the table
metric mode count p50 us p90 us p99 us max us
startup in-process 10 2620.1 3106.5 3384.2 3384.2
startup daemon 10 3065.9 3651.7 3944.2 3944.2
keystroke in-process 500 85.7 120.5 134.9 142.9
keystroke daemon 500 134.1 202.4 243.0 297.4
gate: startup +0.4 ms (budget +10.0 ms) PASS; keystroke p50 +0.05 ms (budget +1.00 ms) PASS
Times are microseconds. The gate line compares the medians; a verdict is INCONCLUSIVE, not
PASS, with fewer than 10 startup or 200 keystroke samples per mode (the defaults give 10 and 500).
headless marks its gate line (informational): the gate is e2e gate, whose exit status is 0 only
if both budgets pass.
A probe that never sees its cursor column ends the run with probe timed out waiting for cursor
column N and a non-zero exit; it never hangs. Typical causes are a blinking cursor, an unexposed
window, or a platform that schedules no frames.
The gate
The gate is a reproducible local run on a Windows, a Linux and a macOS machine before the default is flipped to daemon mode and before each release:
| Platform | Command |
|---|---|
| Linux | cmake --build --preset clang-release --target contour bench-latency then scripts/run-display-tests.sh --run out/clang-release/src/vthost/bench-latency e2e contour out/clang-release/src/contour/contour gate |
| Windows | cmake --build --preset clangcl-release --target contour bench-latency then out\build\clangcl-release\bin\bench-latency.exe e2e contour out\build\clangcl-release\bin\contour.exe gate (in the desktop session) |
| macOS | cmake --build --preset appleclang-release --target contour bench-latency then out/appleclang-release/src/vthost/bench-latency e2e contour out/appleclang-release/src/contour/contour.app/Contents/MacOS/contour gate (in the desktop session) |
CI runs the same benchmark on all three platforms in the latency_trend job and records the
numbers as a workflow artifact and a step summary. It never fails on them: shared runners are too
noisy to gate on, but a trend across commits shows a regression early.
Results
Each row is one run of the gate command above, gate cold, 10 runs of 50 keys per mode (500 keystroke
samples). A FAIL or INCONCLUSIVE is recorded as such: the budgets and the sample minimums are not
tuned to fit.
| Date | Commit | OS | CPU | GPU | Startup delta | Keystroke p50 delta | Cold startup (p50) | Verdicts |
|---|---|---|---|---|---|---|---|---|
| 2026-10-24 | d9878cde (before the resync fix) | Fedora 44, Linux 7.2.9, Xephyr (X11, no compositor), clang-release | AMD Ryzen 9 9950X3D | none: Mesa software GL (llvmpipe) | -0.9 ms | +0.10 ms | 296.2 ms (first staging spawn 295.0 ms) | startup PASS, keystroke PASS |
| 2026-10-24 | 257505a7 (resync snapshots coalescing) | Fedora 44, Linux 7.2.9, Xephyr (X11, no compositor), clang-release | AMD Ryzen 9 9950X3D | none: Mesa software GL (llvmpipe) | -0.2 ms | +0.09 ms | 293.4 ms (first staging spawn 297.8 ms) | startup PASS, keystroke PASS |
| pending | Windows (clangcl-release) | to be run on the owner's machine | ||||||
| pending | macOS (appleclang-release) | to be run on the owner's machine |
The Linux run used a nested Xephyr server in place of the headless weston run-display-tests.sh
starts when it finds weston (not installed on that machine). It renders in software, so absolute times
(6.5 ms keystroke-to-pixel, 255 ms to the first prompt) are those of a software renderer; the deltas
between the two modes are what the gate judges.
Bulk output after the flush change
bench-latency headless runs 5 workload ascii seconds 2, this tree against the same tree with the
commit that flushes an echo at once (b7edc5ce) reverted, both clang-release, two alternating runs:
| MiB/s in-process | MiB/s daemon | Deltas/s | |
|---|---|---|---|
| without the flush change | 317 / 324 | 313 / 316 | 47.9 / 48.0 |
| with it, first version (d9878cde) | 309 / 330 | 296 / 296 | 217.1 / 217.4 |
| with it, resync snapshots coalescing (257505a7) | 320 / 322 | 312 / 313 | 48.4 / 48.4 |
The first version flushed 4.5 times as often. The flood scrolls, so nearly every flush is a resync snapshot (412 snapshots, 411 of them resyncs, in a 2 s run); a snapshot cleared the follow's increment history, and the next screen update counted as the first one after an attach and flushed at once (422 such flushes beside 1505 coalesced). Only the attach snapshot now leaves the next increment to flush at once; a resync keeps the history, and Deltas per second are back at the coalescing rate.
Callgrind of the daemon (valgrind --tool=callgrind --trace-children=yes bench-latency headless runs 1
workload ascii seconds 2, the serve process) shows the same profile with and without the flush
change: 829 M instructions against 846 M, with streamSnapshots at 37 % and buildPush at 15 % in both
and no new hot spot.
The keystroke gate was rerun with the fix; that run is the 257505a7 row of the results table above.