Timing tests¶
This document does two things.
Part 1 shows you how to characterise the timing behaviour of your machine
with the bundled Timing-Tests program — both the checks that need no equipment
and the photodiode/TTL measurement that is the real answer — and how to read
each of them.
Part 2 summarises what we have measured on our own machines, so you have reference points to compare against. Every figure there names the machine, the configuration, the date and the sample size. Nothing is quoted that we cannot point at a capture for.
The one-sentence version of Part 2: on Linux the display stack dominates everything else by more than an order of magnitude — a compositor costs about sixteen times the onset jitter of a bare KMS/DRM console, and no amount of care in the experiment code recovers it.
Companion documents. tests/Timing-Tests/README.md is the flag-by-flag
reference — invocations, defaults, equipment, wiring. This page explains what
the tests mean. For the EEG/MEG question specifically, see
Minimising trigger-to-stimulus jitter, and for
the comparison against published packages,
the mega-study comparison.
Part 1 — Measuring your own machine¶
Read this before you trust any number¶
This suite produces two kinds of output, and they are not equally trustworthy. Keeping them apart is the single most important thing on this page.
- What the program prints to the console — frame intervals, bright-phase durations, drain times, SOAs — comes entirely from software timestamps, in all six sub-tests without exception. It records when goxpyriment believes a flip happened, not what reached the panel.
- What an instrument records while
avruns — a photodiode on the screen, the TTL line on a second channel, a microphone if you are checking audio — is a measurement of the physical event. This is ground truth.
So the suite does measure your hardware properly: that is what av is for,
and run-timing-tests.sh can wrap those steps in a bbtk-capture recording so
the stimulus always falls inside the capture window. What it cannot do is tell
you any of it from the console. Never read the printed statistics as if they
were the instrument's.
This is not a theoretical caveat. A presentation bug on a GNOME/Wayland machine
once left the display showing stale frames for seconds at a time while the
program's own numbers stayed textbook-perfect: bright-phase duration
199.915 ± 0.16 ms against a 200 ms target. Nothing in the console output hinted
at a problem. It was visible only with a photodiode. (The cause and fix are in
apparatus/CLAUDE.md; the regression test is tests/test_clear_only_frames.)
None of which makes the console output useless — it is the right tool for a
different job. Software statistics genuinely detect dropped frames, scheduler
jitter and audio-buffer effects, and check, display and latency need no
equipment at all, so run them first. They just cannot confirm that light changed
when you said it would.
If you are validating a rig for publication, take the hardware path: av plus a
photodiode and a trigger box, then timing-drift
to check the two against each other.
What the display actually does¶
Most monitors refresh at a fixed rate — 60 Hz (a new frame every 16.67 ms), 120 Hz, or 144 Hz. The driver synchronises presentation to that cycle (VSYNC), with two consequences:
- Durations are quantised to multiples of the frame period. At 60 Hz you can
show a stimulus for 16.67, 33.33 or 50 ms — but not 25 ms. Plan accordingly,
or see the
vrrtest. - Onset is not when you call the flip; it is the next VSYNC, anywhere from 0
to 16.67 ms later. goxpyriment holds that offset constant once the pipeline is
warm, which is why the first frames must be excluded —
-warmupdoes this.
The screen does not change all at once¶
An LCD paints top to bottom, so a square at the bottom of the screen lights measurably later than one at the top — on a 60 Hz panel, approaching a full frame period. A stimulus at screen centre appears roughly half a frame after one at the top.
This matters more than it sounds: if your photodiode sits in a corner but your stimulus is central, your measured onset carries a systematic bias larger than most of the jitter people worry about.
The gradient is a property of your panel, and you have to measure it. The
av test draws five squares — four corners plus centre — precisely so you can.
Put a photodiode on a top square and another on a bottom one; the difference is
your scan-out gradient. Two squares on the same row are only microseconds apart
and serve as a sanity check. Each display needs its own figure.
The six tests¶
No hardware needed
check Go/no-go: can this machine display and make noise at all?
display What is my true refresh rate, and how stable are frame intervals?
latency How long does SDL hold audio before it sounds?
Photodiode and/or trigger box
av THE stimulus timing test — visual + audio + TTL, every cycle
vrr Can I present arbitrary durations, not just multiples of a frame?
rt How precise is my reaction-time measurement?
av is the default: running with no -test flag runs it.
| Test | What it measures | Reads its answer from |
|---|---|---|
check |
Nothing. Flashes white, plays a buzzer and a ping. | Your eyes and ears |
display |
Frame-interval distribution over -duration-s; estimated true refresh rate; the frame-pacing verdict |
Console |
latency |
How long SDL holds PCM beyond a tone's nominal duration — the software audio pipeline delay | Console |
av |
Bright-phase duration, cycle period, software-side audio-vs-visual SOA — and drives the photodiode/TTL capture that is the real measurement | A recorder |
vrr |
Achieved-vs-target duration across a 1 ms-step sweep with VSync off | Console, then a photodiode |
rt |
SDL3 hardware event timestamp against the flip timestamp | Console |
Note that -test latency labels its own output drain: and writes
test=drain into the info file — the drain time is the measurement.
Two related tests live in their own directories: tests/test_gv_sync (.gv
playback synchronisation) and tests/test_dlpio8 (DLP-IO8-G square-wave
characterisation).
Build it once:
Run every test fullscreen — that is the default. -w exists for debugging
the tests themselves; a windowed run measures your compositor, not goxpyriment.
Recording the machine (-sysinfo)¶
Before running anything, capture a snapshot. -sysinfo prints it and exits — it
opens no window, though it initialises SDL briefly to enumerate displays and
audio devices:
A real capture (tests/Timing-Tests/report-dell-precision-5490-laptop-kmsdrm/sysinfo.txt):
Machine: product: Precision 5490 System: Dell Inc. Type: laptop
System: Host: is158520 Kernel: 7.0.0-29-generic x86_64 Uptime: 32m
OS: Ubuntu 26.04 LTS Shell: bash 5.3.9
CPU: Model: Intel(R) Core(TM) Ultra 7 165H Info: 16 cores / 22 threads Speed: 1076 MHz (min: 400 / max: 4700)
Memory: RAM: total: 30.04 GiB used: 2.12 GiB (7.0%) Swap: total: 8.00 GiB used: 0 KiB (0.0%)
Graphics: Card: Intel Corporation Meteor Lake-P [Intel Arc Graphics] Driver: i915
Card: NVIDIA Corporation AD107GLM [RTX 2000 Ada Generation Laptop GPU] Driver: nvidia
Audio: Card: sof-soundwire Driver: snd_soc_sof_sdw
Server: PipeWire v: 1.6.2 ALSA: k7.0.0-29-generic
Sched: policy: SCHED_OTHER nice: 0 (real-time available up to 50, not used)
Displays: [0] eDP-1 2560x1600 60.040 Hz bounds 0,0 2560x1600 [primary]
[1] DP-5 2560x1440 59.950 Hz bounds 2560,0 2560x1440
video driver: kmsdrm (the [N] above is the -d N value)
Audio out: [0] Dummy Output
driver: pulseaudio
Reviewers routinely ask for CPU model, OS version, graphics driver and audio server. Two lines repay a second look:
Sched:records the policy the run actually got, not the one it asked for. See Setting priority under Linux.Displays:— on a multi-monitor machine the heads differ in resolution, refresh rate and pixel density, and every calibration is per-display. Each data file's-info.txtrecords which display the window actually opened on; read it from there rather than assuming.
-sysinfo also prints a Vblank: line. By default, onsets are timestamped when
SDL_RenderPresent returns; GOXPY_VBLANK=on instead anchors every frame on
the kernel's DRM vblank stamps. It is off by default and there is currently no
reason to turn it on — measured against a photodiode the two are
indistinguishable, both flat to well under a ppm. The case it exists for is a
host whose nominal refresh rate is badly wrong. If you do enable it, read
sys vblank_resolution: in the run's -info.txt: if it ever says
WRONG DISPLAY, the onsets in that file are on another monitor's frame grid and
must not be used. The mechanism lives in the vblank/ package.
The Frame pacing block¶
-test display and -test av both end with a block like this:
── Frame pacing ───────────────────────────────
presents: 588 (frame = 16.656 ms)
blocked : 566 (96.3 %) — present carried the frame; its return is the onset
of which 281 came back inside the nominal boundary by mean
0.285 ms, max 1.861 ms — the phase offset between the nominal
frame grid and the panel's, not a wait.
paced : 22 (3.7 %) — returned early; Update held to the schedule
wait mean 2.496 ms max 2.696 ms
verdict : the driver blocks. Flip timestamps carry the display's own
instant, and cannot drift against the panel.
It does not report dropped or missed frames. Every present is counted here; a frame that never reached the panel shows up in the interval statistics above it, not in this block.
It answers a different question: what were this run's flip timestamps anchored
to? SDL_RenderPresent is supposed to block until the retrace, but under
triple or mailbox buffering it queues the frame and returns immediately. When it
does, Screen.Update holds the frame to the boundary itself — and the onset it
reports is then a scheduled time rather than a hardware one.
| line | meaning | the onset FlipTS reports |
|---|---|---|
blocked |
present covered the frame and returned at, or just inside, the boundary | the present's own return — a hardware instant |
early |
the part of blocked that returned just inside the nominal boundary |
unchanged: still the present's return |
paced |
present came back with most of the frame left, so Update held it |
the scheduled boundary — synthesised |
vblank |
a kernel vblank timestamp was available (GOXPY_VBLANK=on) |
the vblank — measured |
A large early count is not a warning. A blocking present returns one
panel period after the last one, while the boundary it is compared against is
one nominal period after — and the two grids are never in exact phase. It is a
constant offset, re-established by the hardware every frame, so it cannot
accumulate, and it cancels out of any duration or reaction time (both are
differences between timestamps).
Read the verdict, not the percentages: it is computed from the time held per present across the whole run, precisely because a driver that blocks but sits a fraction of a millisecond off the nominal grid can take the held branch on nearly every frame while blocking for 96 % of each one.
the driver blocks— nothing to do. Onsets are hardware-anchored.onsets come from the kernel's vblank timestamp— nothing to do; the most accurate configuration available.the driver does NOT block— your frames are still correctly paced and your durations are fine, but the onsets are stamped with a schedule running at the nominal refresh rate. If that rate is wrong, absolute onsets slide against the panel over long blocks. Compare the estimated refresh rate printed above against the nominal one — the gap between them is the drift rate — and runtiming-drifton a photodiode capture before quoting absolute onsets.MIXED— some frames took each path. Worth a photodiode check before quoting absolute onsets.
A mostly-paced machine is not broken, and that is exactly the problem: pacing makes a non-blocking driver produce software statistics indistinguishable from a blocking one. What it cannot do is keep the schedule and the panel in step forever.
Only the third verdict asks anything of you, and even then it bounds a slow slide over minutes, not per-trial error.
The same counts are written to every run's -info.txt, so you can tell after
the fact which branch a capture took:
# sys pacing: presents=30000 blocked=30000 early=29610 paced=0 vblank_held=0 (0.0 % paced) … class=blocking
# sys pacing: presents=30000 blocked=0 early=0 paced=30000 (100.0 % paced) wait_mean=16.141 ms … class=not-blocking
A drift-free result means two different things in those two cases.
class=blocking means every frame re-anchored to the hardware, so the run says
little about how accurate the pacing schedule is. class=not-blocking means the
schedule was what advanced the clock, and a flat result there is evidence
about the schedule itself. Without this line the two are indistinguishable
afterwards, and a set of captures can end up unable to answer the question it
was run for.
av — the stimulus timing test¶
This is the one that matters. Every cycle presents all three modalities at once:
- Visual — five squares (four corners + centre) bright for
-frames-onframes, then dark for-frames-offframes - Audio — a tone lasting
frames-on × frame-period, synchronised to the visual onset or offset from it by-soa-ms - TTL — a
-trigger-mspulse at the visual onset, on the device chosen by-trigger-device
Timing-Tests -test av -cycles 100 # 200 ms on, 300 ms off at 60 Hz
Timing-Tests -test av -no-sound -no-ttl # visual only, no hardware at all
Timing-Tests -test av -soa-ms -50 # audio leading the flash by 50 ms
The frame period is read from the display, not passed in; -frames-on 12 is
200 ms only if the panel really runs at 60 Hz, which is what -test display
tells you.
-no-sound and -no-ttl drop a modality, which is how one test covers what
used to be three.
What to record. A photodiode on one square, the TTL line on a second channel, a microphone if you are checking audio. The quantity of interest is the offset between the TTL edge and the luminance step, and how stable it is across cycles. A constant offset is a calibration you subtract; a varying one is jitter you cannot.
What the console tells you. Bright-phase duration, cycle period, and (with sound) audio-queued minus visual onset. All software-side — cross-checks, not ground truth.
Expect a warm-up transient. The first several cycles run long and variable,
and they dominate the summary statistics if left in. -warmup (default 10)
excludes them from the test's own statistics, but offline analysis tools do not
know about it — quote steady-state numbers, and say which cycles you used.
Audio onsets are quantised by the buffer. With -audio-frames 256 at
44100 Hz, tone onsets land on 5.8 ms steps. This appears as a lattice in the
inter-onset intervals and is not jitter — it is the buffer. See
Audio: the buffer size is the whole story
before choosing a value; smaller is emphatically not better.
Choosing a TTL output device. -trigger-device selects among dlpio8
(USB serial, 5 V), parallel (ppdev ioctl, 5 V), gpio (GPIO chip ioctl,
3.3 V), ft232h (USB bulk, 3.3 V) and labjackt4 (Modbus TCP, 3.3 V). They are
not interchangeable — the trigger fires immediately after the flip returns, so
whatever the write costs lands in the trial-to-trial spread of the
flip-to-edge interval. The full table, the per-device meaning of -trigger-pin,
and the Linux prerequisites are in
tests/Timing-Tests/README.md, under "Choosing a TTL output device";
what the differences actually cost is in
How much the trigger device costs.
A device that fails to open no longer silently continues.
avrefuses to start if the chosen trigger device could not be opened, unless you passed-no-ttl. Still watch the start-up lines — they echo the device, the resolved pin and the pulse width.
Running a whole session. tests/Timing-Tests/run-timing-tests.sh drives the
standard sequence (sysinfo check display display-gc av av-gc av-visual
latency) and can wrap the photodiode steps in a bbtk-capture recording so the
stimulus always falls inside the capture window. Set BBTK_CAPTURE=1 and
BBTK_CAPTURE_BIN; BBTK_MARGIN_S (default 8) sets how much is recorded either
side. Its environment variables are documented in its own header and in
tests/Timing-Tests/README.md.
If you are recording audio with a BBTK microphone, max the output volume and
place the microphone within a few millimetres of the speaker membrane. Anything
less and events are silently dropped. Two checks on the resulting -events.csv:
N(Mic1) should equal N(TTLin1), and the Mic duration should be close to
frames-on × frame period. A 197-cycle run yielding 6 Mic events is a placement
problem, not a timing one.
vrr: arbitrary stimulus durations¶
On a 60 Hz monitor every duration is a multiple of 16.67 ms. At 120 Hz the quantum shrinks to 8.33 ms — better, but still coarse for subliminal work where you may want 10, 12 or 15 ms.
Variable Refresh Rate — AMD FreeSync, NVIDIA G-Sync, or the underlying VESA Adaptive-Sync standard — removes the quantisation: the display holds the current frame for exactly as long as you ask. Durations become controlled by your software timer rather than the display clock.
The test switches the renderer to vsync=0 for its duration (restored on exit),
sweeps target durations from 1 ms to -vrr-max-ms (default 20) in 1 ms steps,
-vrr-reps times each, holding with a busy-wait and recording what was
achieved. It prints one line per target plus an aggregate over the sweep.
- On a working VRR display: duration error stays small and roughly constant across the whole sweep.
- On a fixed-refresh display: the error is periodic — near zero at multiples of the frame period, rising in between. A sawtooth means you do not have VRR, whatever the monitor's box claimed.
On Linux, check the connector first (sudo cat
/sys/kernel/debug/dri/0/*/vrr_capable, looking for vrr_capable: 1) — then
confirm with the test rather than trusting the setting.
We have no photodiode capture of a VRR run. The console numbers describe what the busy-wait achieved, not what the panel did.
rt — reaction-time timestamp precision¶
Flashes the screen at a jittered interval and waits for a key press, recording the SDL3 hardware event timestamp against the flip timestamp. This characterises the input path: how much delay and jitter your keyboard, USB stack and event queue add.
Press as fast as you can, or better, use a solenoid — you are characterising the machine, and human variability swamps it. The useful number is the SD, not the mean.
timing-drift: is the flip timestamp tracking the panel?¶
The two av output files each look fine on their own; a drift failure exists
only between them. This tool joins them and reports the slope first:
Options: -ttl (channel name, default TTLin1), -light (default Opto1),
-hz (0 = read the run's sibling -info.txt), -warmup (10), -out.
It prints, for TTL→light and then for flip-timestamp→light:
- the slope, in µs/cycle and ppm — the number that matters;
- the de-trended SD, the real trial-to-trial scatter once the ramp is gone;
- how much of the variance the ramp accounts for;
- one-frame jumps, and the panel's true frame period fitted from the photon train, scored against both rates the framework recorded.
The host and the instrument run off different crystals — tens of ppm apart before anything interesting has happened — so the tool fits that clock ratio out of the trigger-versus-flip relation rather than assuming it away. Events are paired by nearest neighbour within half a cycle, not by index, because a single spurious detection shifts every later pair by a whole cycle.
Every slope is fitted over the longest stretch containing no dropped frame,
and the fitted over line says which cycles those were. This is not tidying: a
frame-sized step at index k of n points biases a least-squares slope by
6·h·m·k/n³ (with m = n−k), which at the midpoint is 1.5·h/n. Over a
1000-cycle run at 60 Hz that is 25 µs/cycle — 50 ppm from a single dropped
frame in eight minutes, ten times the effect these captures exist to resolve,
and it does not average away with more cycles because the lever arm grows with
n too. On a short capture the bias is larger still: 20 cycles with one drop
puts it above 2000 ppm.
The one-frame jumps count is printed separately, and a run with more than a
few of them is not a usable capture however clean the surviving stretch looks.
Read the verdict line. DRIFT DOMINATES means the flip timestamps and the panel
are on different clocks and no absolute onset from that run can be trusted;
NO DRIFT, but … scatter means the timestamps track the panel and the spread is
genuine.
Rules of thumb for your own numbers¶
These thresholds are judgement, not measurement. No capture in this repository established them; they are a starting point for a reader with no reference at all. For real reference points — machines, dates, sample sizes — use Part 2.
| Metric | Excellent | Acceptable | Problematic |
|---|---|---|---|
Frame-interval SD (display) |
< 0.1 ms | < 0.5 ms | > 1 ms |
Frames > 0.5 ms late (display) |
< 1 % | < 5 % | > 10 % |
Audio pipeline latency (latency) |
< 15 ms | < 30 ms | > 50 ms |
Bright-phase duration SD (av) |
< 0.2 ms | < 1 ms | > 2 ms |
TTL→photodiode SD (av, hardware) |
< 0.5 ms | < 2 ms | > 5 ms |
Dropped frames per 100 cycles (av) |
0 | < 5 | > 10 |
VRR duration error SD (vrr) |
< 0.1 ms | < 0.5 ms | > 1 ms (or periodic → no VRR) |
RT SD (rt) |
< 3 ms | < 10 ms | > 20 ms |
Pacing verdict (display) |
vblank, or blocks | MIXED | does NOT block |
The hardware rows are the ones worth quoting in a methods section.
The pacing row grades what the onsets are anchored to, not frame quality, and
it is the one row where "problematic" still leaves durations and reaction times
intact — read The Frame pacing block before acting
on it.
An SD alone cannot grade a TTL→photodiode series, and that row is the one
most likely to mislead. A delay that slides steadily across a run has an SD like
any other, and a large one — yet nothing about it is jitter, it cannot be
averaged away, and every within-channel statistic in a BBTK report looks perfect
while it happens. Measured on a Raspberry Pi 4: SD 4.07 ms, of which 99.8 %
was a monotonic 13.9 ms ramp and 0.18 ms was real scatter. Run timing-drift
before reading this row.
Loading the data in Python¶
Each run writes two files to ~/goxpy_data/, or to -outdir if given:
Timing-Tests_sub-000_date-<YYYYMMDD>-<HHMMSS>.csv— data rows, no comments...-info.txt— session metadata: times, host, OS, and the display and audio configuration actually in use
import pandas as pd
df = pd.read_csv("~/goxpy_data/Timing-Tests_sub-000_date-20260817-091541.csv")
# av: drop the warm-up cycles before quoting anything
steady = df[df.cycle >= 10]
print(steady.bright_duration_ms.describe())
print(steady.period_ms.std())
Every CSV starts with subject_id. After that:
| Test | Columns |
|---|---|
av |
cycle, t_visual_before_ms, t_visual_after_ms, bright_duration_ms, period_ms, t_audio_queued_ms, soa_intended_ms, soa_actual_ms, onset_source |
display |
frame, t_before_ms, t_after_ms, interval_ms |
rt |
trial, onset_ns, event_ts_ns, rt_ns, rt_ms |
vrr |
target_ms, rep, actual_ms, duration_error_ms, onset_ns, offset_ns, trigger |
latency |
duration_ms, rep, drain_ms, overhead_ms |
check |
none — the CSV is empty; only the -info.txt is meaningful |
onset_source records how each onset was anchored: hardware-verified,
present-return, pacing-schedule, vsync-estimated or unknown. Captures
made before this column existed do not have it.
Always read display parameters from the run's own -info.txt rather than
assuming — on a multi-monitor machine the heads differ in every relevant
parameter, and the header records which one the window opened on.
Improving timing on your system¶
Linux
- Get off the compositor. By far the largest single improvement, and it is
not close — see Part 2. Start from a virtual terminal (
Ctrl+Alt+F2) with the window system stopped (systemctl stop gdm) andSDL_VIDEODRIVER=kmsdrm. Failing that, a plain non-compositing window manager (openbox, i3) with exclusive fullscreen is the other measured-good configuration. See Linux virtual console for the SDL driver selection details. - Run at real-time scheduling. Worth a measured factor of 1.8 in onset
jitter. Grant your user the privilege once; after that goxpyriment programs —
Timing-Testsincluded — request priority 50 at startup on their own:TheTiming-Tests -test display -duration-s 30 # asks for real-time itself Timing-Tests -realtime-priority 0 -test display # opt outSched:line in the system report records which policy it actually got. The full procedure — and the traps, including that the limit is inherited from your login session and a new terminal will not pick it up — is in Setting priority under Linux. - Disable CPU frequency scaling:
cpupower frequency-set -g performance. - Consider a real-time kernel — see https://ubuntu.com/blog/enable-real-time-ubuntu.
- Send triggers off the USB bus if you can.
-trigger-device parallelon a machine with an LPT port, or-trigger-device gpioon a single-board computer; both write via oneioctl. Before assuming/dev/gpiochip0owns the header, ask the system (gpiodetect,gpioinfo) — which chip carries the 40-pin header varies by board and kernel, and another chip's lines may drive real board hardware. Verify the wiring withtests/test_linuxgpioortests/test_parallel_portbefore spending a long capture. - Lower the FTDI latency timer if you read responses over a USB serial device. It does nothing for sending triggers — see the DLP-IO8 appendix.
Windows
- Disable "Hardware-Accelerated GPU Scheduling" in Display Settings if you see high frame jitter. See Setting priority under Windows.
Part 2 — What we found¶
Every figure below names the machine, the stack, the date and n. The full workings live in Minimising trigger-to-stimulus jitter and the mega-study comparison; this is the summary.
The display stack is the dominant term¶
Three runs differing only in the display stack — same binary, same machine, same
panel, same night. Dell Precision 5490, Intel Arc (Meteor Lake), 60.04 Hz,
SCHED_FIFO 50, synchronous trigger, DLP-IO8 and photodiode captured in one
Analog Discovery 3 acquisition at 200 kS/s so the instrument's clock cancels.
Onset is the photodiode's 10 % crossing. 2026-08-08, ~590 trials per arm:
| stack | mean | sd | full range | flips > 1 ms off the frame grid |
|---|---|---|---|---|
| Wayland session | 21.75 ms | 1.344 ms | 18.83–36.74 | 7.1 % |
| KMS/DRM, no display server | 18.91 ms | 0.113 ms | 18.58–19.13 | 0 of 590 |
| Bare Xorg + openbox, exclusive fullscreen | 35.74 ms | 0.083 ms | 35.52–35.95 | 0 of 580 |
Sixteen times better, and the mechanism is gone rather than reduced. Off a compositor, not one flip in ~590 lands more than 1 ms off the frame grid; under Wayland one trial in fifteen does. An entire five-minute Xorg run fits inside a 0.43 ms band. By comparison, real-time priority on the same rig is worth 1.8× (sd 2.342 → 1.320 ms).
The result is three-way, not "kmsdrm beats Wayland". Bare Xorg is the steadiest and the latest, which is not a contradiction — they are different quantities. The gap is exactly one frame:
Xorg − KMS/DRM = 16.826 ms = 1.010 frames
One more buffer in the pipeline, dead constant. For EEG and MEG the distinction is the whole point: a constant 19 or 36 ms is subtracted in analysis and costs nothing; 1.3 ms of scatter around it cannot be. Measure your offset once per rig, record it, subtract it.
Set against the fourteen lab configurations of Bridges et al. (2020) Table 2, the bare-Xorg run has the best onset precision in the table by a factor of two, and the Wayland run is worse than thirteen of the fourteen — the same binary on the same machine. See the mega-study comparison.
The display stack is worth two frames of latency and five times the jitter¶
A second, independent campaign found the same effect on different hardware. 2026-08-17, BBTK v3, a Radeon Pro W5700 (radeonsi) driving a DLP-IO8, one variable changed:
| W5700, everything else identical | onset latency | scatter | n |
|---|---|---|---|
| X11, exclusive fullscreen | 54.88 ms | 0.296 ms | 481 |
| kmsdrm, bare console | 22.55 ms | 0.057 ms | 286 |
32.4 ms is almost exactly two frames — an X11 Present path plus a driver swapchain queueing ahead, where on kmsdrm the application page-flips straight to the CRTC.
This is a different machine and a different campaign from the table above, which is why its X11 scatter (0.296 ms) is not the 0.083 ms measured on the laptop's bare Xorg. That configuration was openbox with no compositor at all; this one was a full X session. The two are not the same condition, and the comparison to draw from each is the within-campaign one.
The panel is not involved: its 10–90 % transition measured 16.29 ms on a Pi 4 and 17.28 ms here, with the same asymmetry between halves, so the same monitor behaves the same way on both machines and the latency is upstream of it.
None of this is visible from inside the program. Both configurations report
class=blocking, sub-ppm drift against the panel, and frame intervals with
sub-millisecond scatter. An experiment quoting an absolute onset would be 32 ms
wrong under X11 with no way to know.
Flip timestamps track the panel¶
Two machines, BBTK v3 photodiode, 1010 cycles each, 2026-08-17, after the flip-timestamp anchoring was corrected:
| Raspberry Pi 4 (V3D/kmsdrm) | Radeon Pro W5700 (radeonsi/X11) | |
|---|---|---|
| flip → photons, slope | +0.01 ppm | +0.13 ppm |
| total drift over the run | 0.006 ms / 8.3 min | 0.066 ms / 8.3 min |
| de-trended scatter | 0.128 ms | 0.278 ms |
| one-frame jumps | 0 | 0 |
| TTL → light | 23.43 ms, sd 0.075 ms (GPIO, kmsdrm) | 54.88 ms, sd 0.296 ms (DLP-IO8, X11) |
The W5700 is the informative case for drift. Its GPU and CPU keep independent crystals, so its loop cadence runs −4.20 ppm off nominal; before the anchoring was corrected, its flip timestamps advanced on the CPU clock at the nominal rate while the panel ran on the GPU clock, and that difference showed up as a measured −4.73 ppm of drift. The two numbers are the same quantity. That the cadence is still −4.20 ppm while the drift is now +0.13 ppm is the evidence that onsets follow the panel rather than the schedule.
For a sense of what a healthy display run looks like on a good stack, the
laptop above on kmsdrm (2026-08-09,
tests/Timing-Tests/report-dell-precision-5490-laptop-kmsdrm/display-gc-off.txt) estimated
60.037 Hz with frame intervals mean 16.656 ms, SD 0.008 ms over n=677, and
not one interval more than 0.5 ms late.
Audio: the buffer size is the whole story¶
Measured on a Raspberry Pi 4 (PipeWire, 48 kHz) on 2026-08-17, capturing the line output directly with an Analog Discovery 3 at 100 kS/s and taking the onset from a running-RMS envelope:
| driver | -audio-frames |
period | n | median AV lag | SD | period/√12 |
torn tones |
|---|---|---|---|---|---|---|---|
| PipeWire | 2048 | 42.67 ms | 60 | 101.50 ms | 12.28 ms | 12.32 ms | none |
| PipeWire | 1024 | 21.33 ms | 481 | 49.88 ms | 6.17 ms | 6.16 ms | none |
| PipeWire | 512 | 10.67 ms | 500 | 27.30 ms | 7.28 ms* | 3.08 ms | 10 (2.0 %) |
| PipeWire | 256 | 5.33 ms | 483 | 9.43 ms | 2.36 ms | 1.54 ms | 100 (20.7 %) |
| ALSA direct | 512 | 10.67 ms | 463 | 4.95 ms | 6.74 ms | 3.08 ms | 463 (100 %) |
* inflated by a mid-run escalation, below; its first half scatters by 3.10 ms.
1024 is the recommended setting on this hardware — the only one that is both
clean and stable, and what run-timing-tests.sh now uses.
A large buffer costs jitter, and the jitter is a beat, not noise. The tone
is handed over on time and then waits for the queue, so the audio onset scatters
across one buffer period — matching period/√12 to within 0.2 % at 1024 and
2048. But it is deterministic: the tone's position in the buffer grid advances
by cycle mod period every trial and wraps. At 512, a 499.830 ms cycle predicts
+1.503 ms per trial with a −9.163 ms wrap; observed, +1.500 ms (n=161) and
−9.170 ms (n=58) — agreement to 7 µs. So a per-trial lag can be predicted
rather than merely bounded, and a cycle chosen as a whole multiple of the buffer
period removes the beat altogether.
A small buffer costs glitches, and PipeWire moves the goalposts. At 512, ten of the first 245 tones were torn (gaps to 37.7 ms); then at trial 245 the lag stepped by +10.85 ms — one buffer period — and the tearing stopped dead, with no glitch in the remaining 255 tones. That is the audio server adding a period to the graph after repeated underruns. A naive linear fit through that step reports a 15 ms "drift" that does not exist.
The floor is the hardware, not the audio server. Bypassing PipeWire entirely
(SDL_AUDIODRIVER=alsa on hw:2,0, server stopped) gave the lowest latency of
anything measured — median 4.95 ms, some trials negative — and was completely
unusable: every one of 463 tones was torn, typically five times each. So the
Pi cannot sustain a 10.7 ms buffer, and PipeWire's mid-run escalation was it
discovering the same floor and working around it. Sit above the floor
deliberately rather than removing the thing that was hiding it.
Scheduling priority is not involved. The 2×2 of 512/2048 against
-realtime-priority 50/0 scratched at 512 under both policies and was clean
at 2048 under both.
None of this appears in what the host prints. In these same runs the
software-side SOA read 0.080 ± 0.035 ms — handing the tone over on time is
the part the software does correctly. Check underruns at the source instead:
pw-top over ssh, watching the ERR column.
One methodological note worth keeping. The 512 case was first measured through a BBTK microphone channel, which reported 23 % of tones torn by gaps whose median was 22.3 ms; the electrical capture found the tearing real but affecting 2.0 % of tones, mostly by about 2 ms. A threshold detector watching an acoustic envelope needs the signal to recover past its threshold before it calls the tone present again, so short electrical glitches read as long acoustic gaps. A second instrument measuring by a different route is the only way to learn what the first one is adding — and prefer the route with fewer transducers when the question is about software.
How much the trigger device costs¶
Two campaigns on 2026-08-21 put trigger devices on one schedule and one timebase, an Analog Discovery 3 at 1 MS/s:
| comparison | machine | result |
|---|---|---|
| DLP-IO8 vs FT232H | Precision 5490, SCHED_FIFO 50, 1.5 V threshold |
DLP-IO8 lags by 179.4 µs, sd 5.7, n=322 over three 60 s runs |
| DLP-IO8 vs parallel port | Precision 3650 Tower, 2.5 V threshold | DLP-IO8 follows by 185.9 µs, sd 12.6, n=900; worst pulse 254.8 µs |
The host's own contribution — flip return to trigger write — was 0.03 µs median. So the ~180 µs is the USB serial path, it is consistent across two machines and two comparison devices, and against a 1 ms trigger budget it leaves a 3.9× margin.
The practical reading: the DLP-IO8 is fine for a millisecond-scale budget, and
the ioctl paths (parallel, gpio) are measurably tighter if you have them.
Full data in tests/test_triggers/report-is158520/ and
tests/test_triggers/report-precision3650/.
What we have not measured¶
Stated so the next reader knows where the edges are:
- The browser. WASM frame pacing has been characterised in software (16.666 ms, SD 0.11–0.12 ms over 299 frames, headless Chrome, 2026-07-13) but there is no photodiode validation of actual pixel onset — see WASM.md.
- VRR. No photodiode capture of a
vrrsweep exists. The console numbers describe the busy-wait, not the panel. - Reaction time. No solenoid or hardware-actuator characterisation of the
rtinput path. - Scan-out gradient. No current report directory carries a top-to-bottom gradient measurement, which is why Part 1 tells you to measure your own.
- macOS and Windows. Every figure on this page is from Linux.
Related tests¶
tests/test_gv_sync—.gvvideo playback: doesPlayGvFuncput a frame on screen when it says it does?tests/test_linuxgpio— GPIO character-device output/input, used to verify header wiring before a session.tests/test_parallel_port— LPT data lines via ppdev, the equivalent check for a parallel-port trigger.tests/test_dlpio8— square-wave output for characterising the trigger box itself against an oscilloscope.tests/test_triggers— two trigger devices against each other on one timebase; the source of the 180 µs figure above.tests/test_clear_only_frames— regression test for the compositor presentation bug described at the top of this document.tests/test_vsync_blocking— doesSDL_RenderPresentblock on VSYNC on this platform, or return immediately?
Contributing a report¶
If you measure your own rig, we would like to see it — good results help people
choose hardware, and bad ones tell us what to fix. See
performance-reports/README.md for what to include.