Skip to content

Squeeze more throughput out of the engine - #5

Merged
dhh merged 11 commits into
masterfrom
perf/event-dispatch-and-render
Aug 10, 2026
Merged

Squeeze more throughput out of the engine#5
dhh merged 11 commits into
masterfrom
perf/event-dispatch-and-render

Conversation

@dhh

@dhh dhh commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

An autoresearch pass over ttfx's hot paths. Profiled with a SIGPROF sampling
shim and a malloc-interposing call-site counter, on a 200×50 canvas with 40
lines of input. Each commit is an independent optimization, kept only when it
beat the noise floor on a per-effect benchmark; the 354-check effect parity
suite, the 41-check tty byte-stream suite and the CLI corpus stay green
throughout, so frames remain byte-identical to upstream TTE.

Result

36% less wall clock across all 37 effects — 9.97 s → 6.35 s for one run of
each. Every effect improved; nothing regressed.

rings −59%
overflow −54%
vhstape −53%
sweep −42%
waves −40%
beams, colorshift −40%
binarypath, thunderstorm, synthgrid −37%
median across all 37 −32%
slowest mover (print) −8%

What was actually wrong

The engine was paying string prices for identity work, and allocating to ask
questions rather than to store answers.

  • Event dispatch built a key to look one up. Path stepping emits
    SEGMENT_ENTERED/EXITED per segment per character; each emission allocated
    an owned CallerKey before a linear scan concluded nothing was registered. A
    bitmask of subscribed event kinds short-circuits that, borrowed CallerRef
    lookups removed the allocation from the emissions that do match, and a small
    per-entry fingerprint replaced the string compares in the scan.
  • Active path and scene were resolved by string, repeatedly. Several
    lookups per character per tick, each a memcmp. OrderedMap now keys on
    Rc<str> and hands out its own key, so the lookup settles on a pointer
    compare — and a tick resolves the entry slot once instead of four or five
    times.
  • Waypoint identity allocated twice per clone. Reference-counting the id and
    the bezier control points turns path activation and segment crossing into
    refcount bumps.
  • Every cell copy went through a memcpy call, and every restyle through
    core::fmt.
    Formatted symbols are stored inline so the frame writer copies
    a fixed block, assembled once in a reused buffer; SGR sequences emit their
    digits directly.
  • Exit walked the whole arena to free it. Nothing in the crate implements
    Drop and stdout is already flushed, so the engine is handed to process
    teardown instead.

Not kept

Five experiments were reverted after measuring: a dense per-cell symbol buffer
(+10.6%), the same idea with raw pointers (+2.1%), inlining
CharacterVisual::symbol (−0.9% overall but +23% on middleout), a libm-free
floor (noise), and unchecked row writes in the frame emitter (−0.9% for a
chunk of unsafe). Reserving scene frame capacity up front and swapping
VecDeques instead of draining them both measured as noise.

One thing for you

The README's performance table is now understated — it quotes a 9.6× median
against Python TTE and per-effect figures that this branch beats by a wide
margin. I left it alone rather than substitute numbers from my machine; it
wants a regeneration on whatever hardware produced the original table.

— 🤖 Claude, posting on behalf of @dhh

dhh added 11 commits August 10, 2026 08:22
Path stepping emits SEGMENT_ENTERED/EXITED for every segment of every
active character, and each emission built an owned CallerKey — a String
clone plus, for waypoints, a Vec<Coord> clone — before a linear scan of
the character's event table decided there was nothing registered. On
rings that scan and its memcmp traffic was ~45% of run time.

EventHandler now keeps a bitmask of the event kinds it has registrations
for, and emission sites test it before constructing the key. Registration
order and lookup semantics are unchanged; the mask only short-circuits
lookups that could not have matched.

rings -25%, binarypath -5%.
Every tick looks up the active path and the active scene by string id
several times per character — in Path.step's segment walk, in Motion.move,
in step_animation, and again in the is_active sweep at the end of update.
Each lookup ran a memcmp; together they were ~9% of run time.

OrderedMap now keys on Rc<str> and hands out its own key allocation via
shared_key. Motion::active_path and Animation::active_scene hold that
handle, so the lookup's cached-position check settles on a pointer compare
and never reaches memcmp. Non-aliased keys still compare by value, so
lookup results are unchanged.

10% off the benchmark total: rings -32%, binarypath -15%, fireworks -12%.
Effects that subscribe to SEGMENT_ENTERED/EXITED pay a linear scan of the
character's event table per emission, and the derived comparison on
WaypointKey led with waypoint_id — a String, so every candidate cost a
memcmp. On rings that was 12% of run time.

Coordinates are at least as selective and compare as two integers, so
leading with them rejects non-matches before the string is touched.
Equality is the same relation, just reached sooner.

rings -11%, waves -13%.
At exit, dropping EngineCtx walks tens of thousands of characters and
frees each one's scenes, frames, paths and formatted symbols individually.
Nothing in the crate implements Drop and stdout is flushed before the
engine goes out of scope, so that walk buys nothing but exit latency —
around 4% of a binarypath run, and more on short effects.

Forget the engine after the run and let process teardown reclaim it.

thunderstorm -15%, sweep -16%, wipe -10%.
Two costs sat on the frame writer. Emitting a cell called push_str with a
couple of dozen bytes, so a memcpy call dominated the copy itself — 8% of
run time across the suite. And every CharacterVisual built its SGR string
with a fresh String, so effects that restyle characters each frame paid an
allocation per character per frame.

FormattedSymbol keeps the bytes inline when they fit in 63, which covers a
24-bit foreground/background pair plus the reset, and the writer copies the
whole fixed block before advancing by the real length. Assembly moves to a
reused thread-local scratch buffer, so building a visual no longer
allocates. Symbols too long to inline still go to the heap and copy the
old way.

9% off the benchmark total: overflow -30%, vhstape -28%, sweep -22%,
spotlights -20%.
Ticking a character looked its active scene up four or five times — for
the frames check, for sync/ease, for the step itself, and again inside
the completion check — and its active path once per segment of the walk.
Every one of those repeated the same map lookup.

OrderedMap now exposes entry slots, so a tick resolves the slot once and
reuses it. Only a reentrant event action can move or drop an entry, so
path stepping re-resolves after each emission and keeps the same
"path removed mid-step" failure.

Together -5.5%: randomsequence -15%, sweep -5%.
A Waypoint clone allocated twice: once for its id string and once for its
bezier control points. Activating a path clones the first waypoint, builds
a synthetic origin waypoint with a fresh "origin" string, and clones the
resulting segment twice — and effects that loop paths do that every cycle.
Segment crossings clone again to build event keys.

Both owned fields are now Rc, so a clone is two refcount bumps, and the
origin id is a single shared allocation. Equality still compares by value,
so event keys match exactly as before.

rings -8%, binarypath -7%, swarm -4%.
Restyling a character reassembles its escape sequence, and effects that
shift color do that for every character every frame. Building the sequence
through write! dragged in the formatting machinery — Display for u8, a
Formatter, and a dynamic write_str per fragment — for what is at most three
decimal digits per channel.

Emitting the digits directly cuts that out.

5% off the benchmark total: spotlights -14%, highlight -12%, colorshift
-11%, waves -11%.
The active-character sweep runs is_active for every active character every
frame. It ORs two predicates: one is a null check on the active path, the
other looks the active scene up in a map. Testing the null check first
short-circuits the lookup for every character that is still moving.

randomsequence -3%, highlight -3%; -1% overall.
Firing an event allocated a String for the caller id purely so the lookup
had something to compare against — and a looping scene fires SCENE_COMPLETE
on every tick of every character it owns.

Emission sites already hold the id, so the lookup now takes a borrowed
CallerRef and compares against the table in place. Registered keys are
still owned and still compare by value.

rings -6%, highlight -8%.
Characters that register many events — rings gives each character a
registration per ring it passes through — turned every dispatch into a walk
of the table comparing caller ids string by string. That scan was a tenth
of a rings run.

Each entry now carries a small hash of its caller, so a lookup hashes the
query once and rejects the rest on an integer compare. Tables with a couple
of entries skip the hash and compare directly, which is cheaper there.
Registration order and match semantics are unchanged.

rings -16%, randomsequence -9%.
@dhh
dhh merged commit 3260f00 into master Aug 10, 2026
3 checks passed
dhh added a commit to yashranaway/ttfx that referenced this pull request Aug 10, 2026
Six fixes from the review of omacom#4, plus a pty-driven regression test that
fails on all of them without these changes.

Only react to SIGWINCH when stdout is a terminal. The signal reaches every
process in the terminal's foreground group whatever its stdout points at,
and terminal_size() falls back to stderr, so `ttfx pour | less` in a window
being resized restarted mid-stream: the consumer saw a truncated first run
followed by a complete second one. A file sink drains too fast to show it,
which is why the test drives a deliberately slow reader.

Compare the canvas geometry, not the raw terminal size. With an input-sized
canvas and no anchor offsets, most resizes cannot move a single rendered
cell, and restarting for those is pure loss. compute_layout is now factored
out of Terminal::new so a resize can re-derive the geometry from the stored
line lengths and compare.

Wait for the size to settle before rebuilding. Dragging a window edge emits
a SIGWINCH per step; rebuilding for each one pinned the animation at its
opening frames for the whole drag and started it over on release. A 6-step
drag now costs at most 3 rebuilds instead of 7.

Wipe the old area instead of reusing the canvas. Reusing it moved up by the
new visible_top from an anchor that no longer had that much room above it,
so the blank-line loop ran past the anchor and scrolled a line of scrollback
away per rebuild. The resize path now returns to the top of the area it
allocated, erases to the end of the screen, and lays the new canvas out from
there.

Leave the cursor hidden across a rebuild. Restoring it between runs strobed
the cursor 20-40 times a second through a drag.

Drop the libc dependency. Only signal(2) and a handful of constants were
needed, and signal was already hand-declared; the pacing sleep now slices
rather than reaching for nanosleep, which also makes it portable.

Rebasing onto master also had to reconcile the run loop with the arena
teardown skip that landed in omacom#5: forgetting the engine now happens on the
exit paths inside the loop, never on a resize rebuild, which still drops
its engine so a long session of resizes cannot accumulate them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant