Skip to content

Profiling with Tracy

Contour is instrumented for the Tracy frame profiler, so the whole pipeline — PTY read → VT parse → grid write → render buffer → frame — can be measured rather than guessed at, and two commits can be compared mechanically.

Call sites use Tracy's own macros directly. There is deliberately no first-party profiling abstraction: ZoneScoped and TracyPlot mean exactly what Tracy's documentation says they mean.

Options

All default to OFF, and with all of them off nothing is fetched and no compiled output changes — contour::tracy merely puts a no-op header on the include path.

Option Builds Cost
CONTOUR_TRACY Instrumentation compiled into contour (the client). Fetches TracyClient (a single source file).
CONTOUR_TRACY_TOOLS tracy-capture, tracy-capture-daemon, tracy-csvexport. Tracy CPM-fetches capstone, zstd, PPQSort and json. Minutes, once.
CONTOUR_TRACY_GUI The tracy-profiler GUI. Adds glfw, freetype, imgui, nativefiledialog, md4c, base64, tidy, usearch, pugixml and libcurl, and needs EGL/Wayland (or X11) development packages.
CONTOUR_TRACY_GUI_X11 Selects X11 over Wayland for the GUI. —

The tool options are independent of CONTOUR_TRACY: build the viewer and the exporter once and keep them, and rebuild only contour when you want a different instrumented binary. Neither tool is packaged on Arch, which is why they are buildable from this tree at all.

TRACY_VERSION in cmake/Tracy.cmake pins all three. The client and the tools must be the same version — Tracy's capture protocol is version-specific.

Building a profiling tree

cmake --preset clang-release -B out/clang-tracy \
    -DCONTOUR_TRACY=ON -DCONTOUR_TRACY_TOOLS=ON
cmake --build out/clang-tracy -j4

Do not profile the default preset. clang-asan is Debug plus AddressSanitizer and UndefinedBehaviorSanitizer; its timings say nothing about release behaviour. clang-release is RelWithDebInfo, which keeps the symbols Tracy needs.

The tools are not part of all — build them by name:

cmake --build out/clang-tracy -j4 --target tracy-tools   # capture + csvexport
cmake --build out/clang-tracy -j4 --target tracy-gui     # the profiler GUI

Both land in out/clang-tracy/tracy-tools/bin/. If the GUI's configure step fails with No package 'egl' found (or wayland-egl, wayland-cursor, xkbcommon), install those development packages or reconfigure with -DCONTOUR_TRACY_GUI_X11=ON.

Taking a capture

Live, with the GUI. Start tracy-profiler, run the instrumented contour, and connect. The frame view shows the terminal's real frame rate, because there is exactly one FrameMark in the tree.

Scripted, to a file. tracy-capture waits for a client, so start it first. Give it -s so the capture ends on its own rather than waiting for the client to disconnect:

out/clang-tracy/tracy-tools/bin/tracy-capture -o /tmp/notcurses.tracy -f -s 50

then, in another terminal, a workload — notcurses-demo is a good one, since -c pins its PRNG seed and -d 0 removes its pacing delays, making the run repeatable and parse/render-bound:

out/clang-tracy/src/contour/contour early-exit-threshold 0 \
    execute sh -c 'notcurses-demo -c -d 0 -p /usr/share/notcurses ixetun'

The client listens on 127.0.0.1:8086 from process start, so the two can be started in either order. TRACY_NO_EXIT=1 in the environment makes the client hold its data until a profiler has collected it — use it for a run too short to attach to, but not together with -s, or the two wait on each other. The same recipe profiles bench-headless, which inherits every backend zone by linking vtbackend.

Reading a capture

out/clang-tracy/tracy-tools/bin/tracy-csvexport /tmp/contour.tracy

One row per zone: name, source location, total time, share of the whole, call count, and mean/min/max/stddev. This is the terminal-readable form — no GUI needed — and it is how two commits are compared: capture the same workload on each, export both, and diff the totals for the zones you changed.

Two things to know when reading it:

  • readFromPty will dominate every capture. It is the blocking read, so its total is mostly idle. That is the point of zoning it — the wait is accounted for rather than sitting in an unexplained gap — but it means the interesting number is everything below it.
  • A zone in a template appears once per instantiation, with the same name and source line. Both Parser::parseFragment rows come from Parser-impl.hpp; they are different instantiations of the parser, not a double count.
  • A zone that was never entered is simply absent from the export.

Self time, and counting frames

Two flags decide what a number means, and mixing them up produces confident nonsense:

  • -e gives self time. Nested totals double-count: shapeRunWithFallback once reported 81 seconds in a 30-second capture because it recurses. Reach for -e whenever the question is "where did the time go".
  • -u gives one row per event, which is the only way to count how many frames missed the budget rather than look at a mean. prepareFrameRhi's duration is the frame's CPU cost, and a frame is prepareFrameRhi → paint → renderImpl → recordFrameRhi, with the only FrameMark at the end of recordFrameRhi.
  • -p (with -u) exports plot data, which is how pty.read.bytes answers what the PTY actually delivered.

Per-cell zones are compiled out unless CONTOUR_TRACY_CELL_ZONES=ON (@see Options). They cost about 20 ns each to record and nothing to leave off, but a full-screen workload emits tens of millions of them, and every export has to decode all of them to answer a question about frames.

Measuring pipeline latency

Frame cost and pipeline latency are different questions and need different arithmetic. Latency is a join over two zone streams: for each parseFragment event, the first prepareFrameRhi that starts after it ends is the earliest frame that could carry that output, and the delta is the latency. tracy-csvexport -u -f <zone> gives the two streams cheaply — the filter matters, since a notcurses-demo capture can hold tens of millions of zones.

Two traps, both easy to fall into:

  • parseFragment fires on the GUI thread too. Terminal::updateIndicatorStatusLine re-parses the three status-line segments through writeToScreenInternal, so a capture has three GUI-thread parseFragment events per frame that are output of the frame, not input to it. Counting them as PTY input turns a p50 of 0.14 ms into 16.6 ms, because each is matched to the next frame. Filter on the thread: the parser thread is the one that is not prepareFrameRhi's.
  • parseFragment nests, so drop any event contained in the previous one on the same thread.

The result is a lower bound on felt latency: it ends at the frame's start, so it excludes the frame's own CPU cost, GPU time, the swap and the compositor, and it begins at the PTY parse, so it excludes the keyboard leg entirely.

Two rules worth stating outright

  • Do not record on a busy machine, and prefer a paired run — the same workload recorded on both builds back to back — over a solo run on a quiet one. Background load that is common to both cancels; load that differs between them does not.
  • A workload driven by synchronized output does not have a frame rate you can read off our frame count. Count block closes (syncOut.refreshed + syncOut.dropped.*) instead: the cap can expire mid-body and draw the same application frame several times.

Zone and plot inventory

Parser thread (Terminal.Loop)

Zone Where
Terminal::processInputOnce vtbackend/screen/Terminal.cpp
Terminal::readFromPty The blocking read — what separates idle from busy.
Terminal::writeToScreen Locally injected text only; PTY input does not pass through it.
Terminal::parseFragmentChunked Same — the injection path, not the PTY path.
Parser::parseFragment vtparser/Parser-impl.hpp — the state machine.
Screen::writeText The bulk overload; carries its cell count as the zone value.
Screen::writeTextEnd
Screen::executeControlCode One per control byte (LF, CR, BEL …).
Screen::processSequence One per escape sequence, named after the sequence (see below).
Screen::processAPC
Screen::scrollUp / scrollDown / clearScreen
Terminal::screenUpdated The parser thread's hand-off to the GUI.
Terminal::resizeScreen
Screen::applyPageSizeToMainDisplay The main grid's reflow.
Terminal::fillRenderBufferInternal
Grid::resize vtbackend/grid/Grid.cpp — reflow. Three call sites aggregate here: the main display and the two one-line status grids. applyPageSizeToMainDisplay is what separates them.

Screen::processSequence renames its zone to the sequence's mnemonic, so the timeline reads SGR, CUP, ED rather than a wall of identical rows. Two caveats: the rename is visible only in the GUI — tracy-csvexport aggregates by source location, so the CSV keeps one processSequence row — and it costs about 12 ns per sequence, which is measurable against a ~20 ns dispatch but negligible in absolute terms (~4 ms across 324k sequences).

Screen::writeText(char32_t) and writeTextInternal are deliberately not instrumented: they run once per codepoint, so a zone there would cost more than the work it measures and would bury the trace. The bulk writeText(string_view, size_t) overload above is the instrumented one.

Plots: pty.read.bytes (bytes returned by each PTY read) and pty.parse.bytes (bytes handed to the parser). Together they show throughput and how the stream is chunked.

GUI / render thread

Zone Where
TerminalDisplay::prepareFrameRhi Pipeline build and resource uploads (before beginPass).
TerminalDisplay::paint Called from prepareFrameRhi; drives Renderer::render.
TerminalDisplay::recordFrameRhi Draw-command recording. Ends with the frame's FrameMark.
Renderer::renderImpl Opens before _applyMutex is taken, so the wait is inside the zone.
Renderer::renderCells / renderLines / renderGutter
TextRenderer::beginFrame / endFrame
OpenShaper::shape / rasterize Shaping and rasterization — where a cache miss becomes a hitch.

Lock contention

Terminal::_stateMutex is declared through TracyLockable, so Tracy's lock view shows how long the render path waits on the parser thread and vice versa. That is the cross-thread question the per-thread zones cannot answer on their own.

Adding instrumentation

Use Tracy's macros directly — #include <tracy/Tracy.hpp> and ZoneScoped;.

Any macro you use must also be defined in src/crispy/tracy-stub/tracy/Tracy.hpp, the no-op header that stands in when CONTOUR_TRACY=OFF. ctest -R check_tracy_stub enforces this, and it matters: a macro missing from the stub breaks the default build — the one CI and every packaging job run — while the profiling build stays green.

The stub is a faithful proxy, not merely a permissive one: it declares the same ___tracy_scoped_zone name the real macros declare, so two zone macros in one scope collide and ZoneText/ZoneValue without a zone in scope fail, exactly as they do with Tracy enabled. It is constexpr and odr-used only inside sizeof, so no storage is emitted and disabled macros never evaluate their arguments.

That fidelity is not complete, though — the stub models the macros, not the library. Build with -DCONTOUR_TRACY=ON before pushing instrumentation changes.