ksqsf / latency-profiler

Last active 2 weeks ago

Like 0

Revision 01577fe76d9d55aaf50165bdaafa187a4b194895

latency-profiler Raw
1EMACS HOLISTIC LATENCY PROFILER
2
3Status: design
4
5Initial scope: POSIX termcap (`-nw`) sessions whose terminal input and output
6have file descriptors. GUI event delivery, the Windows console, MS-DOS,
7terminal-emulator rendering, compositors, and physical presentation timing are
8deliberately excluded.
9
101. Objective
11
12The profiler answers one question: after work becomes observable to Emacs,
13where does time pass before Emacs has emitted the resulting terminal output?
14
15The primary unit is an interaction, not a function sample. A source becomes
16observable when terminal input is reported readable or read, a timer becomes
17due, process output is reported readable, or a file notification is queued.
18An execution interaction begins when Emacs dispatches that source. It ends
19when synchronous dispatch finishes and the immediately associated redisplay is
20completed, skipped, or superseded. If that redisplay emits terminal bytes, the
21interaction also records the final successful stdio flush. Deferred work is a
22new interaction linked to the interaction that scheduled it; it does not keep
23the original interaction open indefinitely.
24
25This operational boundary is intentionally narrower than "all causally
26related work", which is not generally decidable in Emacs Lisp. The primary
27output is a latency distribution and a latency budget for each interaction
28class. CPU throughput is supporting evidence.
29
30The profiler must:
31
32* run continuously and silently while explicitly enabled;
33* cover TTY input, the command loop, timers and process events, GC, redisplay,
34 and TTY output before adding function attribution;
35* retain every interaction and its aggregate semantic spans;
36* optionally write an append-only, compact, process-crash-recoverable file;
37* state the uncertainty and availability of every clock boundary;
38* account for its own overhead and all lost records; and
39* avoid recording keys, buffer contents, file names, process output, or other
40 user data by default.
41
42This instrumentation can identify work whose removal would yield a large
43latency reduction. It cannot promise that such work exists. It measures only
44the portion controlled by Emacs: terminal-input readiness through a successful
45`fflush`. That means bytes have left the C library for the kernel; it does not
46mean a terminal, PTY peer, emulator, SSH client, or display has consumed or
47rendered them. `tcdrain` may be used only by an explicit diagnostic run,
48because waiting for it changes the latency being measured.
49
502. Terms and clocks
51
52All internal timestamps are unsigned 64-bit nanoseconds from one
53profiler-private monotonic clock selected when profiling starts. Prefer
54`CLOCK_MONOTONIC_RAW` where available, then `CLOCK_MONOTONIC`, with a
55platform-specific monotonic fallback. Profiling does not change the clocks
56used by normal Emacs timekeeping. The file records the clock identity,
57advertised resolution, measured read cost, and whether the clock includes
58suspend time. Wall-clock time appears only in session metadata. An
59unavailable boundary is absent, never zero.
60
61TTY input normally has no per-byte kernel timestamp. An optional native
62observer polls the input descriptor without reading it and records the first
63readable observation while the main thread may be busy. The main thread marks
64the descriptor empty only after its existing nonblocking drain finds no input;
65the observer then rearms. Closing or replacing a terminal descriptor must
66first detach it from the observer, and the observer never changes descriptor
67flags or consumes input.
68
69A read batch records the last known-empty time, first observed-readable time,
70and read begin/end. Input in that batch arrived no earlier than the known-empty
71time and no later than read completion. The first-readable observation is a
72tighter upper bound only when the decisive source range ends with the first
73byte after an empty state; it says nothing about later bytes received while the
74descriptor remained readable. If the observer is unavailable, the main
75thread's poll/read observation is a lower-fidelity fallback.
76
77Each queued TTY byte carries a sidecar provenance token: batch ID and byte
78offset. Decoding combines tokens into a source range; function-key
79translation and replay propagate that range. Fabricated, unread, macro, and
80synthetic events have explicit synthetic or unknown provenance. The trace
81never stores the bytes themselves.
82
83Each timestamp has one of these qualities: observer-ready, main-poll-ready,
84read, internal, write, flush, or inferred. An input arrival interval carries
85its width as uncertainty. A derived latency therefore has lower and upper
86bounds when exact input arrival is unavailable.
87
883. Causal model
89
90Every dispatched root of work receives a monotonically increasing 64-bit
91`cause_id`. Root kinds are TTY command, synthetic command, timer, process
92data, process sentinel, file notification, terminal resize, thread wakeup, and
93internal maintenance. Nested synchronous work keeps the current `cause_id`
94and a `parent_span_id`. A nested root also records `parent_cause_id`.
95Deferred work records `scheduled_by_cause_id` and receives a new `cause_id`
96when it eventually runs.
97
98A key sequence can contain user think time. It therefore records the source
99range of both the first physical event and the decisive final physical event.
100Command latency begins at the decisive event's arrival interval; prefix dwell
101time is reported separately. A command without physical provenance has no
102input-to-command latency.
103
104For commands, `read_char` associates the next requested redisplay with the
105command that just finished. If Emacs deliberately skips that redisplay
106because input was already pending, the interaction is `superseded` and has no
107input-to-flush latency. Timer and process roots similarly own redisplay done
108before the event loop waits again. A redisplay invoked synchronously inside
109any root already belongs to that root.
110
111Each redisplay attempt gets a `redisplay_id` and names its serving cause.
112The first nonempty write to the TTY stdio stream starts an `output_epoch`; a
113successful stdio flush completes the greatest written epoch. One flush may
114therefore complete several coalesced attempts. This attributes emitted work,
115not arbitrary buffer mutations: an interaction with no bytes written has
116completion latency but no input-to-flush latency.
117
1184. Numbers recorded for every interaction
119
120The following are raw observations. Durations are derived on reload so a
121future reader can correct clock mappings without rewriting the trace.
122
123| Field | Meaning |
124|-------|---------|
125| `cause_id` | Stable identifier for this interaction. |
126| `parent_cause_id`, `scheduled_by_cause_id` | Optional synchronous and deferred causal links. |
127| `root_kind` | TTY command, synthetic command, timer, process, file notification, resize, thread wakeup, or maintenance. |
128| `terminal_id`, `frame_id`, `thread_id` | TTY execution context; opaque session-local identifiers. |
129| `first_source`, `decisive_source` | Batch and byte ranges for the first and decisive physical events. |
130| `t_input_earliest`, `t_input_latest` | Decisive-input arrival bounds; latest uses first-readable only when the decisive source is the first byte after empty. |
131| `t_input_ready` | First non-consuming readable observation for the batch, when available. |
132| `t_read_begin`, `t_read_end` | TTY read interval for the decisive batch. |
133| `t_queue_insert`, `t_queue_remove` | Queue interval for the decisive raw event. |
134| `t_key_decode_done` | Completion of key-sequence decoding and keymap lookup. |
135| `t_dispatch_begin`, `t_root_work_end` | Operational execution interval for the root. |
136| `t_pre_command_begin`, `t_pre_command_end` | Pre-command hook interval. |
137| `t_command_begin`, `t_command_end` | `command-execute` interval, including abnormal exit status. |
138| `t_post_command_begin`, `t_post_command_end` | Post-command hook interval. |
139| `first_redisplay_id`, `last_redisplay_id`, `final_output_epoch` | Associated presentation range, or zero. |
140| `t_redisplay_begin`, `t_redisplay_end` | C redisplay interval serving the cause. |
141| `t_output_begin`, `t_output_end` | Encoding and writes for the relevant output epoch. |
142| `t_flush_begin`, `t_flush_end` | Successful stdio `fflush` interval for the output epoch. |
143| `thread_cpu_begin`, `thread_cpu_end` | Main-thread CPU clock at interaction boundaries. |
144| `status` | Normal, quit, error, throw, superseded, dropped, or still open. |
145| `validity_mask` | Which optional timestamps and counters are present. |
146| `input_uncertainty_ns` | `t_input_latest - t_input_earliest`. |
147
148The reader derives at least these numbers:
149
150| Metric | Definition |
151|--------|------------|
152| `batch_ready_wait_ns` | `t_read_begin - t_input_ready`; time the batch waited after its first readable observation. |
153| `input_queue_ns` | `t_queue_remove - t_queue_insert`. |
154| `key_decode_ns` | `t_key_decode_done - t_queue_remove`. |
155| `pre_command_ns` | `t_pre_command_end - t_pre_command_begin`. |
156| `command_ns` | `t_command_end - t_command_begin`. |
157| `post_command_ns` | `t_post_command_end - t_post_command_begin`. |
158| `redisplay_wait_ns` | `t_redisplay_begin - t_root_work_end`. |
159| `redisplay_ns` | `t_redisplay_end - t_redisplay_begin`. |
160| `tty_emit_ns` | `t_flush_end - t_output_begin`; encoding through stdio flush, not terminal rendering. |
161| `input_to_command_min_ns` | `t_command_end - t_input_latest`. |
162| `input_to_command_max_ns` | `t_command_end - t_input_earliest`. |
163| `input_to_flush_min_ns` | `t_flush_end - t_input_latest`. |
164| `input_to_flush_max_ns` | `t_flush_end - t_input_earliest`. |
165| `main_cpu_ns` | `thread_cpu_end - thread_cpu_begin`. |
166| `main_off_cpu_ns` | Interaction wall time minus main-thread CPU time; inclusive of nested roots. |
167| `prefix_dwell_min_ns` | `max(0, decisive_earliest - first_latest)`; excluded from command latency. |
168| `prefix_dwell_max_ns` | `decisive_latest - first_earliest`; excluded from command latency. |
169
170Reports show count, minimum, median, p90, p95, p99, maximum, arithmetic mean,
171total time, and input-arrival uncertainty. They add p99.9 only when the sample
172count supports it. Means are never the only displayed latency statistic.
173Interactions without terminal output and superseded interactions are separate
174populations. Session comparison adds confidence intervals only after a stable
175replay workload exists.
176
1775. Work and state recorded per interaction
178
179The first implementation records only counters already available at the hook
180or cheaply accumulated inside the measured loop:
181
182* input queue depth at insertion and removal, read-batch byte count, decoded
183 event count, key-sequence length, prefix depth, and extra reads while
184 decoding;
185* selected-buffer size and modification-tick delta, selected-window rows and
186 columns, and window count;
187* GC count and GC wall time during the interaction;
188* redisplay attempts, preemptions and retry reasons, frames and windows
189 considered and updated, rows generated and reused, glyphs emitted, terminal
190 bytes requested, stdio calls, and flush failures; and
191* profiler records emitted and dropped, ring high-water mark, writer CPU time,
192 clock-read time, and measured observer cost.
193
194Partial writes, `EAGAIN`, and kernel output-queue depth are not claimed while
195TTY output uses stdio, which hides those details. Syscall interposition,
196per-object allocation accounting, hardware counters, every wait kind, and
197every redisplay subphase are added only in a targeted diagnostic build after a
198coarser span identifies a need. Existing OS tools are preferred for hardware
199and scheduler counters.
200
201`SESSION` records the Emacs revision, executable and dump build IDs, configure
202and optimization settings, GC settings, OS and CPU identity, the profiler
203clock, TTY type/capabilities/size/coding/baud, local-device versus PTY, and
204profiler settings. It records whether an SSH-related variable is present, not
205its value, and never records the terminal device path.
206
207Symbol and feature names can reveal installed packages. The initial file
208contains no Lisp symbol or feature names. A later command-grouping or sampling
209phase may add them behind an explicit option. Startup profiling,
210loaded-feature inventories, and file-name hashes are outside the initial
211subsystem.
212
2136. Spans
214
215Manual spans cover the semantic boundaries needed for the first report:
216
217* TTY readiness observation, read, queue insert/remove, key decoding, and
218 command loop;
219* pre-command, command, and post-command;
220* GC as one span;
221* the central event poll; and
222* redisplay decision, desired-matrix construction, matrix comparison, terminal
223 encoding/write, and flush.
224
225The profiler does not claim to observe blocking hidden in arbitrary Lisp,
226modules, libraries, or syscalls. Add a wait span at an Emacs wrapper only when
227the initial waterfall leaves material unknown off-CPU time. Timer, process,
228file-notification, and resize spans arrive with their root kinds in the second
229phase.
230
231A completed span stores `span_id`, `parent_span_id`, `cause_id`, kind,
232start time, wall duration, thread CPU duration, thread ID, status, detail ID,
233and the counter deltas relevant to that kind. Spans open during a crash are
234reconstructed from begin records and marked incomplete.
235
2367. Function attribution
237
238Function attribution is not part of the first implementation. Phase timing
239comes first; controlled reruns use the existing Lisp profiler and an external
240OS profiler such as `perf` for function-level evidence. This reuses working
241machinery and avoids adding a second signal source before semantic boundaries
242have proved useful.
243
244If those reruns cannot explain a repeatable slow phase, a later phase may add
245one randomized on-CPU sampler. It may reuse the timer setup in
246`src/profiler.c`, but not its Lisp-object hash-table signal path. Samples go
247to a fixed numeric ring and carry the current `cause_id` and `span_id`. A
248mixed C/Lisp stack requires a separately validated shadow stack across
249interpreted, byte-code, native-compiled, primitive, module, and nonlocal-exit
250gateways. The signal path performs no allocation, hashing, Lisp call, symbol
251lookup, locking, or file I/O.
252
253Off-CPU time initially comes from wall time minus thread CPU time and known
254wait spans. A second wall-clock sampling signal is justified only if material
255unknown off-CPU time remains. Compiler-wide C/Lisp function tracing is out of
256scope: it changes the workload too much and duplicates targeted spans and
257existing external tools.
258
2598. C and Lisp symbols
260
261The initial file contains no function identities. If command grouping or
262mixed-stack sampling is added later, signal records contain only
263module-relative PCs and fixed Lisp frame IDs. Symbolization happens off the
264signal path using executable/shared-object build IDs and opt-in Lisp names.
265Unknown frames remain explicit module-relative addresses. A prefix-tree stack
266dictionary is useful only after sample volume demonstrates that it saves more
267space than it costs in writer complexity.
268
2699. Collection and overhead control
270
271The main thread and the optional input observer each write to a preallocated
272single-producer ring. Producers never allocate, lock, wait, symbolize, or do
273file I/O. A native writer thread drains the rings without taking the Lisp
274lock, forms chunks, and writes with `writev`. Its CPU time is reported.
275
276Because the first phase stores aggregate interactions and spans rather than
277function samples or per-glyph events, it retains every record and needs no
278flight recorder. Ring, chunk, and rotation sizes are configuration values
279chosen by a burst test, not architectural constants. The default file has no
280compression and no forced `fsync`; a process crash can lose a partial current
281chunk, while a machine or filesystem failure can lose more.
282
283Any loss emits a `LOSS` record with stream, first/last time, records, bytes,
284and reason. A producer increments an out-of-ring drop counter before it
285continues, so a full ring cannot hide the loss record it prevents. Silent
286loss invalidates a profile.
287
288The acceptance budget for continuous mode is less than 1% additional CPU,
289less than 1% median command latency, and less than 250 microseconds added to
290p99 command latency on the reference workload. These are gates, not presumed
291facts. The input observer is measured separately and disabled automatically
292when its platform implementation cannot meet the gate.
293
29410. File format (`.emlat`)
295
296Integers are little-endian in fixed headers. Record integers use unsigned or
297signed LEB128. Timestamps are deltas from the first timestamp in their block.
298IDs start at one; zero means absent. Strings are UTF-8 byte strings. No Lisp
299reader syntax or dumped Lisp object appears in the file. IDs are stable only
300within one rotated session. Cross-session comparison uses explicit metadata,
301never coincidentally equal numeric IDs.
302
30310.1 File header
304
305The fixed 64-byte header is:
306
307| Offset | Size | Field |
308|--------|------|-------|
309| `0` | `8` | Magic bytes `EMLAT\0\r\n`. |
310| `8` | `2` | Format major version. |
311| `10` | `2` | Format minor version. |
312| `12` | `4` | Header flags. |
313| `16` | `16` | Random session UUID. |
314| `32` | `8` | Rotated-file sequence number. |
315| `40` | `8` | Realtime start in Unix nanoseconds. |
316| `48` | `8` | Monotonic start tick in nanoseconds. |
317| `56` | `4` | CRC32C of bytes `0..55`. |
318| `60` | `4` | Reserved zero. |
319
32010.2 Chunks
321
322The remainder is a sequence of independently valid chunks, padded with zeros
323to an eight-byte boundary. A 40-byte chunk header contains:
324
325| Offset | Size | Field |
326|--------|------|-------|
327| `0` | `4` | Chunk magic `CHNK`. |
328| `4` | `2` | Chunk type. |
329| `6` | `2` | Type-specific version. |
330| `8` | `4` | Flags, including compression. |
331| `12` | `4` | Header size, initially `40`. |
332| `16` | `4` | Stored payload bytes. |
333| `20` | `4` | Uncompressed payload bytes. |
334| `24` | `8` | Monotonic chunk sequence. |
335| `32` | `4` | Payload CRC32C after decompression. |
336| `36` | `4` | Header CRC32C with this field zeroed. |
337
338The core format requires no compression library. Delta encoding, varints, and
339dictionaries provide the baseline compactness. If compression is added, a
340numeric codec ID lives in the chunk flags so a reader can identify or skip the
341chunk without first decoding `SESSION`. Unsupported compression is reported
342as loss.
343
344Chunk types are:
345
346| Type | Payload |
347|------|---------|
348| `SESSION` | Build/configuration, clocks, OS, CPU, TTY capabilities, profiler settings, privacy policy, and feature flags. |
349| `STRING` | ID, byte length, bytes. |
350| `INPUT` | Read-batch bounds and byte-provenance ranges, without byte contents. |
351| `INTERACTION` | Cause links, output epochs, validity mask, raw timestamps, status, and fixed counters. |
352| `SPAN` | Completed or open semantic spans and their counter deltas. |
353| `LOSS` | Dropped/corrupt/unsupported data intervals. |
354
355Dictionary definitions precede their first use. Each record block begins with
356a base timestamp, thread ID when homogeneous, record count, and schema
357version. Records then use timestamp deltas and varint IDs. Unknown chunk
358types and newer type-specific versions are skipped by chunk length and
359reported, rather than guessed or misdecoded. Sampling can add `MODULE`,
360`SYMBOL`, `STACK`, and `SAMPLE` chunk types in a later format-minor version.
361
362The writer constructs a complete chunk in memory before appending its header
363and payload. On reload, scanning stops at the first incomplete header,
364impossible size, or failed CRC; every preceding chunk remains usable. An
365index can be added later if sequential scans become measurably slow. Rotation
366never reuses IDs within a session.
367
36811. Embedded interface and reports
369
370Profiling starts after a termcap terminal exists, through a Lisp primitive or
371`--latency-profile[=FILE]`. Lisp primitives start, stop, rotate, and query
372status. Dump loading and earlier startup are outside the initial clock.
373
374The first loader scans chunks sequentially and materializes summaries, not one
375Lisp object per record. Memory mapping and lazy iterators are added only if
376profile size makes that necessary. The first reports are:
377
3781. command-latency distributions, split into flushed, no-output, and
379 superseded interactions;
3802. a waterfall for one selected tail interaction, including input uncertainty;
3813. redisplay work and emitted TTY bytes for that interaction; and
3824. profiler overhead, loss, and unavailable-boundary diagnostics.
383
384The report distinguishes wall time, main-thread CPU time, measured wait time,
385unknown off-CPU time, and TTY emission time. Function rankings and session
386comparisons wait for the later attribution and replay phases.
387
38812. Initial implementation map
389
390The semantic recorder belongs in a small native `latency` core; it does not
391modify `src/profiler.c` or install a sampling signal. The existing Lisp CPU
392and memory profiler remains unchanged. Only a later mixed-stack sampler may
393share timer setup with `src/profiler.c`.
394
395The first semantic hooks belong at these existing funnels:
396
397* `wait_reading_process_output` for poll start/end, ready descriptor classes,
398 fallback readiness, and main-thread idle time;
399* `tty_read_avail_input`, `kbd_buffer_store_event`,
400 `kbd_buffer_get_event`, `read_decoded_event_from_main_queue`, and
401 `read_key_sequence` for TTY read batches, queue latency, provenance folding,
402 and key decoding;
403* `command_loop_1` around pre-command hooks, `command-execute`, post-command
404 hooks, and root completion;
405* the redisplay decision in `read_char`, which is where the command's following
406 redisplay is actually performed or skipped;
407* `garbage_collect` for GC attribution;
408* `redisplay_internal`, `redisplay_window`, `display_line`, `update_frame`,
409 and `update_window` for redisplay decisions and work volume;
410* one small TTY-output wrapper used by the glyph `fwrite` sites and `cmputc`,
411 plus one flush wrapper used by `tty_update_end`, `flush_terminal`, and the
412 other normal redisplay flush paths, for byte counts and stdio-flush latency.
413
414Timer callbacks, process filters/sentinels, file notifications, and resize
415roots are the next phase after the command path meets its overhead budget.
416The input observer follows only if main-thread input intervals are too wide to
417answer the latency question. Mixed-stack sampling follows only if semantic
418spans plus controlled `profiler.el`/OS-profiler reruns leave a repeatable tail
419unexplained.
420
421The native core exposes inline, allocation-free producer operations. Lisp
422report code is loaded only when a profile is opened.
423
42413. Validation
425
426Before optimization work uses these files, the first phase needs:
427
428* round-trip tests for each initial record and dictionary type;
429* truncated, corrupted, unknown-version, and rotated-file tests;
430* fuzzing of the loader with strict allocation and record-count limits;
431* synthetic CPU, sleep, GC, redisplay, error, quit, and nonlocal-exit commands
432 with known durations and causal relationships;
433* tests that force ring overflow and verify explicit `LOSS` records;
434* PTY tests that control read batching, queueing, redisplay, terminal output,
435 backpressure, and stdio-flush timing;
436* privacy tests that scan default traces for injected keys, buffer text, file
437 names, process output, terminal paths, and non-dumped symbol names; and
438* A/B runs of an identical replay workload to enforce the continuous-mode
439 budget. The observer gets a separate A/B gate when added.
440
441Only after these checks should profile results choose optimization targets.
442The first target is the largest repeatable component of p95/p99
443input-to-flush latency, not the hottest function in an average CPU profile.
444