← the Atlas

Performance — Where Kolu Can Get Faster

Analysis·seedling·proposed·

A living tuning map of the Kolu monorepo, read through the N/C/U performance model — do fewer things (N), make each thing cheaper (C), use idle capacity (U). Built from a 77-agent survey and adversarial verification, it files every shipped win and open opportunity under the lever it pulls, so we keep Kolu nimble and fast, by measurement, over time.

This is the Atlas hub for keeping Kolu nimble and fast — a living map of the monorepo’s performance surfaces, the wins already banked, and the opportunities worth tuning next, all organized by one model: N/C/U. It was built by a survey workflow with adversarial verification,10 investigators, one per subsystem, each reading real source, then synthesis. Every finding was then checked against the code — this repo’s history shows plausible, code-cited perf diagnoses are often wrong (see memory-learnings and dock-and-eventloop-1308). and re-files every survivor under the lever it pulls.Of 66 raw findings, 35 survived as real or partial, 6 were confirmed already-shipped, and 25 were dropped as speculative or mechanically wrong. This pass also adds one verified new item (the uncompressed bundle) and two low-confidence candidates a second sweep surfaced.

The N/C/U model

Think of any workload as a loop. The time to finish it is roughly:

Time to outcome  ≈  (N × C) / U

The three levers are orthogonal, so they multiply: a 3× on each is 27×, not 9×.That is the aspirational ceiling, not Kolu’s ledger — this repo’s wins are mostly single-lever and single-digit-percent at today’s scale (3–20 terminals, small/medium docs). Here the model earns its keep as a search heuristic: it names the lever each fix pulls, so we don’t double-count a win, and points at the thin levers — C on the wire, and U almost everywhere — where the next structural headroom sits. Then you measure.

Kolu's optimizations by leverTime ≈ (N × C) / Ushippedto tune★ = marquee ✓NDo fewer thingsfewer steps · redundant workwhere Kolu has already wonReactivity re-derivation ✓ #1425Markdown re-sanitize ✓ #1446Scrollback share ✓ #1573Canvas rAF-coalesce ✓ #1368getSubTerminalIds O(n²)Mobile wake-ups ↓ (15s·60s·1s)Nix build-onceCMake each cheaperfewer bytes · less distancefrontier: the wire + the bundle★ Bundle compressed ✓ 4.6× #1643base64 → binary framing +33%Full metadata → deltasTap-rect cache (layout read)bundle compression ✓ bankedUUse idle capacityparallel · pipeline · pre-computenearly untappedOff-thread diff ✓ #1363Workspace test parallelismServer vitest parallelismkaval byte-bounded queuesthin — a single-user client

The backlog

The actionable shortlist, ranked by leverage, tagged with the lever each item pulls. Impact and effort are the verified estimates (post-adversarial correction), not the original claims. Row 1 is new this pass and the only item on the list that is not bounded at today’s scale — it fires on every cold load, now.

# Opportunity Lever Surface Impact Effort The fix, in one line
1 Serve the client bundle compressed shipped C·U Bundle med low Done #1643 — build-time .br/.gz + precompressed-sibling negotiation; the 2.56 MB main chunk → 571 kB brotli (4.6×) on every cold / remote / phone load.
2 Per-key collection deltas on the wire C·N Wire med med Publish {added/changed/removed} keys, not the full key array, on every upsert/remove.
3 Heartbeat: hidden-tab probe interval N Mobile med med Lengthen the background probe (30–60s), don’t stop it (that blinds the watchdog). Reconnect-on-resume already shipped #1598.
4 Workspace-level test parallelism U Dev-loop med low Add --workspace-concurrency so ~44 packages don’t test one-at-a-time.
5 Lazy-load the Code tab — measured, deferred N·U Bundle med high 171 kB gzip / 23% of the eager chunk, but activeTab defaults to code so it defers past first paint rather than skips — a faster-first-paint-vs-cold-flash trade. Deferred.
6 One-shot Nix pnpmDeps hash check N Dev-loop med high Compute the hash once instead of two sequential builds (2m45s on darwin).
7 Stabilize the terminalIds memo shipped N Reactivity med med Done #1425 — the memo keeps its prior array when the id order is unchanged, so terminalIds() stops notifying downstream on non-display metadata writes.
8 Markdown toggle keep-alive shipped N Markdown med med Done #1446 — a Source⇄Rendered flip is a visibility change, not a remount + full re-sanitize (pipeline runs per toggle, was 1×).

Already banked

So this map isn’t re-litigated: the wins below are shipped and verified — do not re-report them as opportunities. Each is tagged with the lever it pulled; remaining slivers inside them are noted in the lever sections as “remaining within …”.

N — Do fewer things

The fat lever, and the one Kolu has mostly already pulled. On a reactive client the enemy is redundant re-derivation, not distance or idle cores — so the banked wins above (reactivity, markdown, scrollback, canvas) all live here, and what’s left is more of the same: scans that could be indexed, timers that wake when nothing’s watching, and round-trips that could be shared or deferred.

Low Keystone — stabilize the terminalIds memo reference — ✓ shipped

terminalIds was a createMemo running meta.keys().filter(...) (useTerminalMetadata.ts), returning a new array reference every run even when the contents were identical. The dependent displayInfos memo tracked that reference, so any single terminal’s metadata mutation re-ran buildTerminalDisplayInfos for all terminals (terminalDisplay.ts) and re-evaluated every tile’s Show gate.Each pass allocated 4–5 intermediate collections and re-checked getDisplayInfo per tile (TerminalCanvas.tsx). Verification corrected the original “O(n³)” claim to O(n log n) — the cost was wasted allocations + re-derivation, not algorithmic blowup. Done #1425 — the memo now carries a sameTerminalIdOrder equals gate, so it keeps the prior array whenever the top-level id set is unchanged; the set-shaped re-run path now fires only on a real add / remove / reorder, not on every metadata mutation. The accessor still re-runs cheaply; what it no longer does is notify downstream when the set is identical. Proven by a re-run-count regression test (useTerminalMetadata.test.ts).displayInfos keeps a second, field-level subscription to git / cwd / parentId inside its own scope; because the surface store writes via reconcile, PR / agent / foreground churn never reached it even before this gate, and a real display-identity change still re-runs it, correctly. The gate closes the set-reference path; the field-level path is already as narrow as it should be.

Low Corollary — the display-info snapshot must carry NO live record — ✓ fixed

The keystone win has a sharp edge worth stating on its own, so a future change doesn’t re-introduce it: precisely because PR / agent / foreground churn never re-runs displayInfos, anything live carried in its output silently goes stale. TerminalDisplayInfo used to bundle the whole TerminalMetadata, so a consumer reading getDisplayInfo(id).meta.pr off that snapshot saw a value frozen at the last git / cwd / membership change — the canvas tile title bar lagged the dock on PR resolution (the dock reads the live getMetadata(id) proxy; the header read the snapshot). This is the reactively-correct shape: a memo hands out only what it invalidates on. Fixed #1897meta is removed from TerminalDisplayInfo entirely, so every live fact (pr / agent / foreground / intent / git) now reads from getMetadata(id) — the fine-grained store proxy — at each consumer’s own leaf, and the header tracks the same leaf the dock does. A terminalDisplay.test.ts guard asserts the display info can never again carry a record; the memo keeps only colors, the identity key, and sub-count.

Medium Markdown preview render cost — the resolver 'fix' was a no-op; the toggle remount was real — ✓ shipped

The original claim — BrowseFileDispatcher passes resolveImageSrc as an inline arrow, so Markdown’s memo re-runs — is a measured no-op (stabilizing the reference eliminates zero sanitizeHtml runs; the inline-arrow prop is static to the Solid compiler, never a reactive dependency). The real cost the reproduction surfaced — now fixed: active() returned only the active branch, so a Source⇄Rendered toggle remounted and re-sanitized the whole doc (a 50-image doc: ~50 image-resolutions + a full parse/sanitize/highlight/DOM-reparse per toggle). Fixed #1446FileView now keeps both toggle modes alive (the #818 RightPanel keep-alive pattern), so a flip is a visibility change, not a remount; the pipeline runs 0× per toggle — a textbook pre-compute + reuse. Proven by an e2e. Full write-up + reproduction: markdown-image-resolver-and-toggle.

Medium Lazy-load the Code tab — measured (171 kB gzip), deferred — N (skip) + U (time-shift)

An A/B production build measured the Code-tab tree — @kolu/solid-pierre’s FileTree, the @kolu/solid-markdown renderer, the diff/source view wrappers, and the comment system — at 629 kB raw / 171 kB gzip (23%) of the eager index chunk (a static import in RightPanel). Splitting it out skips parsing+executing it entirely on mobile / collapsed-panel sessions (N: work never done) and time-shifts it async past first paint on the default desktop case (U: moved off the first-paint window) — not any byte shrink. Lazy-loading works (built, review-clean, e2e 115/115) but is deferred: activeTab defaults to code and the desktop panel opens by default, so on a typical desktop session CodeTab loads anyway, just async — a faster-first-paint-vs-cold- flash trade whose perceptual net is the untraced cold-start TTI. Two premises it refuted: Shiki grammars are already lazy, and ImageAddon can’t lazy-on-first-use (it must precede the image escape sequence). Full write-up + the unblock path: bundle-codetab-lazyload. Note the C sibling below: before deferring this 171 kB slice, compressing the whole 2.56 MB bundle is a strictly larger, unconditional win.

Medium Heartbeat probe: reconnect-on-resume fixed; hidden-tab battery still open

createHeartbeat() runs system.live / identity.info() every 15s while the socket is OPEN. Two distinct costs hid behind one finding, both N:

  • Spurious reconnect on resume — FIXED #1598 (eliminate retries). A laptop sleep / tab freeze paused the event loop; the probe’s 10s timeout fired overdue on resume and forced a reconnect over a still-healthy socket. The watchdog now compares elapsed wall time against elapsed monotonic time across each probe and voids-and-re-probes a window a suspension crossed, and a window-focus / tab-visible wake event re-probes at once.
  • Hidden-tab radio wake — still open (reduce round-trips). The probe still runs ≈240×/hour while backgrounded, forcing the mobile radio idle→active. The tempting fix — stop the interval while document.visibilityState === 'hidden' — is a coverage regression: a hidden tab is still running, so gating the probe blinds the watchdog to a genuine half-open during a long background. A battery fix must lengthen the hidden-tab interval (30–60s), not stop it. Measure: packet-capture probes/hour backgrounded on a real phone — target under ~10, vs ~240 today.

C — Make each thing cheaper

C is dominated by distance — bytes over the wire, layers of indirection — and its marquee item is now banked: the client bundle, which shipped uncompressed, is now served .br/.gz — a 4.6× cut on every cold load #1643. The rest is open wire work (payload shapes) plus one forced layout read.

Low Serve the client bundle compressed — ✓ shipped (2.56 MB → 571 kB)

The production server shipped the client build through a static file handler with no compression in the pipeline, so the ~2.56 MB eager index bundle went out with no Content-Encoding even though every browser offers gzip, deflate, br. Caching was already right, so it only bit the cold load — but that’s exactly the remote / Tailscale / phone path kolu markets, where bytes over a slow radio dominate first paint.Immutable hashed /assets/* with max-age a year, so the miss is first visit, cache-miss, and every post-deploy hash change. Done #1643 — the client Vite build now emits .br/.gz siblings for the immutable /assets/* at build time (brotli q11), and the static layer negotiates Accept-Encoding against those siblings, serving one at zero per-request CPU — the compression time-shifted to build, a U “pre-compute in idle” move on top of the C byte cut.

Measured on the main chunk: 2.56 MB → 571 kB brotli (4.58×) / 726 kB gzip — beating the ~700 kB the map first estimated.The no-store shell (index.html) is deliberately left uncompressed: its commit stamp is seded post-build (kolu#1319), so a compressed shell would strand a returning browser on a stale stamp — the build emits siblings for /assets/* only. A per-request compress middleware was the rejected alternative (per-request CPU vs build-time zero). This was row 1 and the only item on the map not bounded at today’s scale — it fires on every cold load — and it settled the Code-tab lazy-load debate: compressing ships ~2 MB less on the whole eager load, unconditionally, dwarfing that deferred 171 kB slice. Covered by a freshStaticLayer negotiation test; the remaining trace is on-device cold-start LCP over Tailscale (see coverage gaps).

Medium Publish per-key collection deltas, not the full key set — C (payload) + N (client work)

Every upsert/remove publishes the entire key array via keysBus.publish(Array.from(...)) — a fresh object each time — which crosses the wire and triggers client mapArray reconciliation (surface/server.ts:1218-1223, useCollection.ts:60-65). Publishing discriminated {added:[k]}/{changed:[k]}/{removed:[k]} deltas (full set only on init) shrinks the per-event payload/allocation (C) and lets useCollection apply the change instead of re-reconciling the whole key set (N). The batched machinery already exists (deltasBus + createTickCoalescer, useCollectionDeltas) but the useTerminalMetadata call sites pass explicit keys, which forces the per-key path regardless of the deltas verb — so realizing this is a call-site change, not a one-line verb flip. Measure: keysBus publish frequency and payload sizes during terminal spawn/metadata churn.

U — Use the capacity you have

The thinnest lever, because a single-user desktop client has little idle capacity to reclaim — which is exactly why the one banked U win (off-thread diff highlighting #1363) and most of what’s open live in the dev-loop and the backend, where cores and queues actually sit idle. The rule here is don’t waste, don’t bottleneck, pre-compute in slack time.

Medium Parallelize tests across the workspace

pnpm -r serializes package test runs (no --workspace-concurrency), so on a multi-core machine the ~44 packages run roughly one-at-a-time even though each vitest threads internally — workspace-level parallelism is unused (package.json:7). Enabling workspace concurrency in the test:unit recipe fills idle cores (parallelism / raise utilization) without changing the tests run; consider vitest --shard for the slowest packages (git/index.test.ts). Measure: just test-unit baseline vs --workspace-concurrency N.

Low What a port-scan pass actually spends, decomposed and measured

The architecture is “TypeScript calls an external process” on darwin and “TypeScript reads /proc in-process” on linux. Both overheads are now measured rather than argued about, so the decision to keep them is answerable.

Darwin — the ~6 ms pass, decomposed. Round trip measured from Node exactly as padi calls it; the helper’s own share read from its mach monotonic clock in the SAME invocation, so the parts cannot drift against the whole (an earlier attempt timed them in separate loops and the parts summed to more than the total). Instrumentation is a measure-only build; nothing ships with timers. zest, macOS 27.0, 718 rows / 20.9 KB of output, 15 runs:

component median share
the C work itself (libproc walk + format) 4.11 ms 65%
process lifecycle — fork/exec + dyld + teardown 1.96 ms 31%
pipe transfer + execFile plumbing 0.30 ms 5%
round trip, shipping build 5.96 ms

The lifecycle figure is measured, not inferred: it is a no-op C binary spawned from Node under identical conditions. The instrumented build costs 0.41 ms over the shipping one, which is why the shipping total is quoted separately.

TSV parsing is small enough to be a rounding error and is measured with the SHIPPED parseHelperOutput over that same real output: 0.112 ms median for 696 process rows + 21 listener rows (min 0.104, max 0.371, 200 runs). ~2% of the pass.

Linux — the ~14 ms in-process pass, decomposed. No subprocess at all. Instrumented copy of the real reader, 573-process box, 12 runs:

stage median note
pid table (readdir /proc + batched stat/cmdline) 13.29 ms ~96% of the pass
socket table read (/proc/net/tcp{,6}) 3.45 ms runs CONCURRENTLY with the pid table, so it is hidden under it — not additive
fd walk (only the subtree’s pids) 0.31 ms
join 0.07 ms
socket table parse 0.06 ms
total scanSubtreePorts 13.91 ms

That settles a claim the module header had been asserting: the dominant cost really is the HOST pid table, and it scales with the host rather than with the question. It is also the measurement that makes u-port-scan-subtree-descent the right next move — 96% of the pass is the thing subtree descent would eliminate.

Steady state, both platforms. Worst case is the duty-cycle floor’s fast end, one pass per second (nudgeFloorMs keeps a fast pass at the 1 s minimum):

per pass worst-case cadence cost
darwin 5.96 ms 1 / s 0.60% of one core
linux 13.91 ms 1 / s 1.39% of one core

Darwin’s spawn peaks at 1.9 MB RSS, transient, once per pass.

The counterfactual, and why we pay it. An in-process darwin reader (an N-API addon over the same libproc calls) would save the lifecycle plus the plumbing — 2.26 ms per pass, taking ~5.96 ms to ~3.7 ms, or 0.23% of a core at the worst-case cadence. We decline it for reasons that outweigh 2 ms: a native addon couples padi’s build to a Node ABI and forces a rebuild per Node bump inside the nix closure; a crash in a separate process cannot take padi down, whereas a segfault in an addon takes the daemon with it; and the subprocess boundary is what lets the same binary serve drishti later. The cost is small in absolute terms and is stated here so the trade can be re-examined against a number rather than a feeling.

Low The port scan's nudge floor is duty-cycle bounded, so a slow platform cannot become a hot loop

Banked in #1982. The sampler’s nudge floor used to be a fixed 1 s, which quietly makes per-pass cost a property of the PLATFORM rather than of the pass: the darwin path measured 93 ms on a busy Mac, and 93 ms every second is ~9% of a core for as long as any terminal streams output. Since kaval throttles its activity edge to one per PTY per 200 ms, a single streaming agent delivers ~5 edges/s, so the floor — not the 5 s baseline — is the real period. That is the whole duty cycle, paid for the life of the daemon.

The floor is now clamp(lastPassMs * 20, 1 s, 5 s) (nudgeFloorMs), measured around the whole pass in a finally so a pass that FAILED — a 5 s helper timeout is the most expensive pass there is — still pays for its own cost. Effect: ≤5% of one core by construction, on any platform, with no knob and no platform switch. Linux (14-18 ms) and a quiet Mac (17 ms) sit at the 1 s minimum and never engage the bound; 50 ms is the largest pass that still fits under it.

Why bound rather than only optimise: a bound holds for costs nobody has measured yet. The darwin mechanism itself is under active review — a hand-rolled libproc helper vs adopting a maintained tool through nix — and whichever wins, the duty cycle is already capped.

Medium Scan a terminal's subtree, not the whole host process table

padi’s port sensor reads every /proc/<pid>/stat on the host to build a pid→ppid table, because procfs has no children index — except it does: /proc/<pid>/task/<tid>/children. So the pass costs what the HOST costs (515 processes here) rather than what the question costs (a terminal’s subtree, typically a handful). Measured on a 515-process box, through the shipped module: 35-57 ms per pass before batching, 14-18 ms after (the pid table in bounded groups, the fd walk concurrent per pid — #1982), and <1 ms for a children-descent prototype over two subtrees.

That last order of magnitude is worth taking because the cadence is not what it looks like: kaval throttles its activity edge to one per PTY per 200 ms, so one streaming agent delivers ~5 edges/s and the sampler’s ≥1 s nudge floor — not the 5 s baseline — becomes the real period. ~16 ms every second for the life of a padi is ~1.6% of a core; the pre-batching ~40 ms was ~4%.

Why it is not done yet: descending children is a redesign of the walk (it must iterate /proc/<pid>/task/* so a multithreaded parent’s children are not missed), and proc(5) warns the list may be incomplete if the process list changes mid-read. That is the same exit race the scanner already tolerates by policy (procReadFailure → skip), and the current readdir("/proc")-then-stat shape has the identical hazard — so this is a deliberate decision to write down, not a blocker. Measure: a pass on a 500-process box with 1 vs 30 terminals, table-walk vs children-descent.

Low One kaval activity subscription, fanned out in padi

kaval’s host-global activity stream now has three independent resubscribeStream consumers — the finish fold, the live dots, and the port-scan nudge (#1982) — so every activity edge is encoded, transported over the padi↔kaval link and decoded instead of 2×, with a third retry loop and async iterator. One subscription publishing into an inMemoryChannel that all three consume cuts frames on that link by a third.

Held back deliberately: the three are not three of one shape — the live-dots subscription is per-SUBSCRIBER by design while the other two are daemon-lifetime, so a shared receptacle would force the odd one in. The structural review’s own disposition was “extract at the consumer that actually matches”; this records the measured cost so that decision is made against a number.

Low Bound subscriber queues by bytes, not just item count

Each subscriber queue caps at maxQueue (10k items) with no byte bound (kaval/channel.ts:54-132), so a stalled subscriber on a 1 KB/event PTY could pin ~10 MB before being dropped. Tracking queue byte size at publish and dropping when either item-count or a new maxQueueBytes is exceeded right-sizes the drop threshold to actual heap. Note: the #1420 RCA rules this out as the production OOM source — this is a known-constant memory cap, not the leak fix (which lives in scrollback/snapshot retention and needs a dedicated heap snapshot).

From static reads to live traces

Every finding above is a static code read. Verification corrected several overstated claims (no “high” survived; “O(n³)” was O(n log n); a “100–400 KB” snapshot was ~4 MB, not ~4 KB) precisely because nobody had a number. The next round moves from reading to measuring — the gaps this map does not yet rest on: