Squeeze more throughput out of the engine - #5
Merged
Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
What was actually wrong
The engine was paying string prices for identity work, and allocating to ask
questions rather than to store answers.
SEGMENT_ENTERED/EXITEDper segment per character; each emission allocatedan owned
CallerKeybefore a linear scan concluded nothing was registered. Abitmask of subscribed event kinds short-circuits that, borrowed
CallerReflookups removed the allocation from the emissions that do match, and a small
per-entry fingerprint replaced the string compares in the scan.
lookups per character per tick, each a memcmp.
OrderedMapnow keys onRc<str>and hands out its own key, so the lookup settles on a pointercompare — and a tick resolves the entry slot once instead of four or five
times.
the bezier control points turns path activation and segment crossing into
refcount bumps.
core::fmt. Formatted symbols are stored inline so the frame writer copiesa fixed block, assembled once in a reused buffer; SGR sequences emit their
digits directly.
Dropand stdout is already flushed, so the engine is handed to processteardown 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-freefloor(noise), and unchecked row writes in the frame emitter (−0.9% for achunk of
unsafe). Reserving scene frame capacity up front and swappingVecDeques 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