Skip to content

perf: large-repo render + load pass (#75)#109

Merged
thalida merged 22 commits into
mainfrom
feat/issue-75-large-repo-perf
Jul 19, 2026
Merged

perf: large-repo render + load pass (#75)#109
thalida merged 22 commits into
mainfrom
feat/issue-75-large-repo-perf

Conversation

@thalida

@thalida thalida commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Closes #75

Architectural perf pass for large repos (Linux-scale 80k files / 100k+ commits, and image-heavy repos like PostHog), driven by a headless per-phase profiler rather than guessing. Each change is justified by a before/after number and guarded by a test (or a bit-identical assertion).

Render / interaction

  • Picker → ObjectBVHintersectObjects tested every one of ~80k building instances per pointer move (~34 ms/cast). three-mesh-bvh's ObjectBVH (each instance is a BVH primitive) drops it to ~0.077 ms/cast (~430×). Equivalence-guarded against a brute-force raycast.
  • Street draw-call collapse — the base scene submitted ~28.7k draw calls at PostHog scale (ads off), dominated by un-batched streets (every street was 2 meshes) and per-label meshes. Merged all asphalt → 1 draw and all sidewalks → 1 draw (sidewalks keep picking via a faceIndex→street map + hover tint via per-vertex colors). Net ~28.7k → ~7.8k draws.
  • Street labels — the per-frame flip loop over ~12k labels (4.2 ms/frame) now only runs on a threshold crossing; label visibility is a per-frame size LOD (hidden when sub-pixel, shown up close) instead of a build-time drop.
  • bbox — inline min/max, dropping ~190k per-point Vector3 allocations (bit-identical).

Media / billboards

  • Distance + per-instance LOD — transparent, DoubleSide, never-frustum-culled ad panels caused a GPU overdraw hang on zoom-out for image-heavy repos. Panels now cull per-instance by on-screen size (consistent per distance) and the mesh hides when zoomed out.
  • Streamed, on-screen loading — every media building fired its fetch/decode/upload on rebuild (~1.4k at once on PostHog). Now loads are queued and started only for on-screen panels, budgeted per frame.
  • AD_ENABLED toggle — Buildings → Ad panels → Enabled, to disable billboards entirely (A/B the media cost).

Layout / worker

  • Worker payload — the layout worker was sent the full manifest incl. the commits array (100k–1M entries), structured-cloned on the main thread every apply (~240 ms at 200k commits). layoutCity reads only tree + stats, so it's sent just that (~65 ms). Byte-identical layout guarded.

Load experience

  • Icon atlasapplyManifest awaited one CDN SVG fetch per extension before rendering anything. Now the UV map is assigned synchronously and icons fill the texture in the background, so the city renders without waiting on the fetch burst.
  • Loading overlay — hid the instant the stream ended, while layoutCity was still running async → an empty 3D flash on big repos. Now held until the city actually renders.
  • Recents scroll — a long recents list (many big repos loaded) grew the landing page and scrolled the whole thing. The action column is now capped to the viewport, so the "Open a project" card stays put and the recents list grows into the remaining height, then scrolls its own rows.

Clone (hit while testing torvalds/linux)

  • HTTP/1.1 + retry — GitHub's HTTP/2 stream multiplexing RSTs the pack mid-transfer on huge clones (curl 92 … CANCEL → early EOF). Clone/fetch now force HTTP/1.1 and retry a transient drop.
  • Clean errors — a failed clone no longer dumps the entire Receiving objects: …% progress stream; it's classified and stripped to the real error.

Profiler

app/bench/largeRepoProfile.bench.test.ts times each apply phase for both workloads, with the picker core-vs-BVH and media-LOD numbers as regression guards. (Note: the street-label phase's absolute ms is jsdom-canvas-inflated and not browser-representative — flagged in the file.)

All unit tests green (2583 frontend), backend suite green in docker (287).

🤖 Generated with Claude Code

thalida and others added 21 commits July 17, 2026 13:15
Times each manifest→city apply phase (layout, worker-payload clone, bbox,
buildings assembly, media ad-panel registration, street labels, picker
raycast) for two workloads: raw scale (80k files, Linux-ish) and image-heavy
(PostHog-ish). Profile-first driver for #75 so fixes target the phase that
actually dominates.

Key caveat baked into the file: the street-label phase is jsdom-inflated
(node-canvas rasterizes text ~100x slower than a browser GPU canvas), so its
absolute ms is not browser-representative — the machine-independent label cost
(geometry/mesh allocation) is small. The trustworthy, machine-independent
numbers are layout (off-thread), buildings assembly, clone, bbox, and the
picker raycast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of the "load PostHog, zoom out + rotate → browser hangs" report:
the ad-panel InstancedMesh is transparent + DoubleSide + depthWrite:false and
frustumCulled=false, with no distance/size LOD. Every media panel
(media_count × 4) draws every frame; zoomed far out they all collapse into a
small screen region and the transparent overdraw stalls the GPU, and rotating
forces continuous re-render so it never recovers. Ruled out the picker (hover
raycast is suppressed while the camera moves) and the fader (event-driven, zero
per-frame cost) — the only image-scaling per-frame cost is this render.

Fix: InstancedAdPanels.updateLOD(camera, viewportHeightPx), called once per
frame from the buildings tick. It estimates the panels' on-screen height from
the panel-cloud AABB + camera and hides the whole mesh (zero draw) once panels
are sub-pixel, re-showing above a higher threshold (hysteresis, no flicker).
O(1) per frame.

Validation: adPanels.test.ts asserts the toggle (hidden zoomed-out, visible
close, no-op at unknown viewport); largeRepoProfile.bench shows PostHog-scale
zoom-out now draws 0 / 8192 panel instances (was all every frame). Owner to
confirm the on-zoom-out smoothness in the browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
At Linux scale the picker's THREE.Raycaster.intersectObjects tested every one
of ~80k building instances on every pointer move (~33ms/cast — sub-30fps
hovering, and each orbit/pan drag re-picks). The building geometry is a shared
12-triangle box, so a per-geometry BVH (three-mesh-bvh's acceleratedRaycast)
does nothing here — measured 33→30ms. The cost is instance COUNT, so index the
instances themselves: three-mesh-bvh's ObjectBVH treats each InstancedMesh
instance as a BVH primitive. Measured 32.9 → 0.077ms/cast at 80k (~430x), one
~170ms build.

The BVH is built lazily on the first pickAt after a refresh: keeps the build off
the manifest-apply path and guarantees the object world matrices it reads are
already fresh from the frame loop. Invalidated on every pickables refresh
(cityRevision / decorationRevision).

picker-bvh.test.ts guards equivalence: pickAt through the BVH returns the same
object + instanceId + point + distance a brute-force core raycast would, against
a real cell scene. largeRepoProfile.bench reports both paths side by side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real root cause of the media-heavy load hang (confirmed via the GitHub API:
posthog/posthog ships ~1094 PNG + ~290 SVG ≈ 1.4k billboards): cellAssembly
fired asyncLoadMediaForBuilding for EVERY media building synchronously on
rebuild, regardless of visibility. So once the world painted, ~1.4k fetches +
base64 decodes + canvas scales + copyTextureToTexture uploads drained in bursts
on the main thread, janking every zoom/rotate until they finished.

Defer + gate loading by visibility instead:
- registerMediaBuilding now only QUEUES a load (records the panel center);
  cellAssembly no longer loads eagerly.
- updateLOD (already per-frame) starts loads only for panels inside the camera
  frustum, at most AD_LOAD_BUDGET_PER_FRAME (4) per frame, and none at all while
  the panels are LOD-hidden (zoomed out). Off-screen panels stream in as the
  user rotates them into view.

Behavior change to confirm in-browser: billboards now populate as you look
around / zoom in rather than all at once on load (on PostHog's initial fit
framing they may start blank until you zoom in — which is what removes the load
hang). Tunable via AD_LOAD_BUDGET_PER_FRAME + the LOD px thresholds.

Tests: adPanels.test.ts covers no-eager-load, frustum gating, per-frame budget
draining without double-starts, and no loads while zoomed out. The load starter
is injected so scheduling is observable without touching the network.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The whole-mesh visibility toggle was too coarse: at a mixed/medium zoom the
foreground keeps the mesh visible, so every panel still renders — including the
tiny unreadable background billboards that are pure overdraw (still laggy, per
owner). Cull per building instead: each frame, collapse a building's 4 panels to
zero-scale (a degenerate quad → no fragments) when it projects below
AD_CULL_HIDE_PX or leaves the frustum, and restore above AD_CULL_SHOW_PX
(hysteresis). Loading uses the same gate — a panel too small to read never
fetches/decodes/uploads. The whole-mesh toggle stays as a cheap early-out when
even the nearest panel is a speck (zoomed all the way out).

Real matrices are cached per building so a culled panel restores without
recompute. Cost is ~1 distance + frustum test per building per frame (only while
the mesh is visible).

adPanels.test.ts adds a mixed-scene guard: a close foreground panel renders +
loads while a distant in-frustum one is collapsed to zero-scale + never loads.
(Note: THREE's Matrix4.decompose reports [1,1,1] for an all-zero matrix, so the
test checks the X-axis column length directly to detect the collapse.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The world-bbox computed expanded the Box3 with a freshly allocated THREE.Vector3
(and a rectOfStreet object) per corner — ~190k allocations over every street +
building at Linux scale (issue #75 ranked bottleneck #4). Replace with direct
min/max on box.min/max, inlining rectOfStreet's orientation swap. Bit-identical
(guarded by cityState.test.ts; verified equal to the old expandByPoint output on
an 80k layout) and ~2.4x faster with zero allocation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st (#75)

The layout worker was posted the entire Manifest, whose commits array runs
100k–1M entries at Linux scale — and postMessage structured-clones it on the
MAIN thread every apply. layoutCity reads only manifest.tree + manifest.stats
(never commits), so that clone is pure waste: measured ~240ms for a 200k-commit
manifest vs ~65ms for the {tree, stats} slice, and it scales with commit count
(exactly the issue's "80k files AND 100k+ commits" case).

compute() now posts only { tree, stats }; LayoutRequest.manifest is typed to a
new LayoutManifest = Pick<Manifest,'tree'|'stats'>. This is the real
worker-payload win (issue ranked bottleneck #1) — the geometry-only RETURN
payload I also explored nets only ~10-25ms after re-association, so it's not
worth the protocol churn.

slimManifest.test.ts guards that layoutCity({tree,stats}) is byte-identical to
layoutCity(fullManifest), so dropping commits can never change the city.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…size (#75)

The per-instance cull gated on each panel's OWN projected height, so on one row
at the same distance a small file's short billboard fell below the threshold and
got culled/not-loaded while its taller neighbor loaded — a finicky mixed row,
and it skipped panels the user was zoomed in on. Gate on a single reference
height (the tallest panel in the repo) instead, so every panel at a given
distance decides together: a row loads or culls as a unit. Also drop the
aggressive per-instance thresholds (28/44 px) back to the whole-mesh ones
(6/12), erring toward showing — a zoomed-in row now fully loads; only genuine
far-background specks (sub-pixel) cull. Frustum + per-frame budget still bound
loading to what's on-screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An A/B switch to isolate whether the media billboards are the perf cost: under
Buildings → Ad panels, "Enabled" (default on, Rebuild route). When off,
cellAssembly skips the InstancedAdPanels entirely — no mesh, no fetch/decode/
upload, no draw, no per-frame LOD — while the media buildings themselves still
render. Toggling re-applies via the existing Rebuild reaction (same route as the
other AD_* knobs), so it recreates the cells with or without panels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A large media building at the screen edge could have its center just off-frame
while its geometry clearly filled a corner — containsPoint(center) then culled it
and never loaded its image (the front-right building in the report). Test the
building's bounding sphere against the frustum instead, so an on-screen-but-
off-center building loads and renders. Guarded by a test where the center fails
containsPoint but the sphere rescues it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tick() walked all ~12k street labels every frame to orient them toward the
camera (~4.2ms/frame at PostHog scale). But every X-oriented label flips together
(all keyed off the camera's world-right X), likewise Y-labels off Z — so the flip
is two booleans, not per-label. Track them, and only walk the label meshes when a
boolean actually crosses its ±0.15 hysteresis threshold (rare) or after a rebuild
swaps in fresh labels. Common frame is now O(1). Bit-identical flip behavior
(guarded by the existing hysteresis test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Profiling PostHog-scale (ads OFF, so not the billboards): the base scene submits
~28.7k draw calls/frame, and streets are the bulk — every one of ~8.5k streets
was TWO separate meshes (sidewalk + asphalt). Asphalt is a single solid color and
never picked, so bake every street's asphalt pill into ONE merged geometry: ~8.5k
asphalt draws → 1. Sidewalks stay per-street (they're the pickable directory
targets + individually tinted) — merging those is a follow-up.

streets.ts now exposes createSidewalkMesh + createMergedAsphaltMesh + capStyleFor
(was the monolithic createStreetMesh); the component builds sidewalks per street
and one asphalt mesh, disposes them individually, and the theme effect recolors
the single asphalt material.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Street labels were ~11.7k separate transparent meshes at PostHog scale — each a
draw call plus a per-frame transparent-depth sort, and the whole set enters the
frustum when you zoom out (the lag case). The smallest-tier (leaf-directory)
streets are ~35% of them, and their labels are unreadable specks exactly when
zoomed out. Skip labels on the smallest-width streets once a city exceeds
LABEL_LOD_MIN_STREETS (500) — 11.7k → 7.6k planes here; smaller repos keep every
label. Behavior change worth an eyeball: deep leaf-dir streets no longer carry a
name on big repos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes the street batching. Sidewalks were the other ~8.5k per-street draw
calls, but unlike asphalt they're pickable AND individually tinted, so a plain
merge isn't enough. The merged sidewalk carries a per-vertex color attribute
(hover/select recolor only the hovered street's vertex span, with a partial
GPU upload via addUpdateRange) and a faceIndex→street map on userData; the picker
resolves an ObjectBVH raycast hit's faceIndex back to its directory
(sidewalkStreetForFace, binary search). _sameHover now compares directories by
dir.path since the mesh is shared.

Net across the whole streets batch: PostHog-scale draw calls drop from ~28.7k to
~7.8k (streets ~16.9k → 2). Remaining draws are labels; a label atlas is the next
lever if needed.

Guards: mergedSidewalk.test (face→street mapping + contiguous ranges) and a
picker-bvh case driving the full ObjectBVH → faceIndex → interpretHit chain to
the correct dir. All 2580 unit tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
)

The build-time leaf-dir label skip removed labels permanently, so zooming right
up to a small street showed no name (owner report). Same lesson as the ad
panels: cull by on-screen size per frame, don't drop at build time. Build every
label again, and in tick() hide the ones that project below ~7px (re-show above
~12px, hysteresis) — up close a label is tens-to-hundreds of px tall and always
shows; only far-away specks (the zoomed-out draw-call cost) get culled. The
recompute runs only when the camera moves (else O(1)/frame), and doesn't cost
more memory than before since all labels existed pre-change anyway.

Guarded by a test: a label hides at a far camera and re-shows up close.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Labels were still culling too eagerly at a normal zoom. Base the size estimate
on the street's NATURAL label height (so a long directory name on a narrow
street isn't culled just for being text-shrunk), and drop the thresholds to
3px hide / 6px show. Anything you're plausibly reading now stays visible; only
far-away specks get culled when zoomed out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ound (#75)

applyManifest awaited buildIconAtlas before setting any signal — and the atlas
fetches one SVG per unique file extension from a CDN and awaited them ALL before
returning. On an extension-diverse repo like PostHog that's a multi-second burst
of network fetches blocking the ENTIRE city from rendering (the "empty during
Sketching layout" gap).

The iconName→slot UV map is just an index walk, so assign it up front and return
the atlas synchronously; fetch + draw each SVG in the background, flagging the
CanvasTexture for re-upload as each lands. Buildings bake correct roof UVs
immediately and the icons pop onto already-built roofs progressively. A failed
fetch just leaves its slot blank (UV stays mapped, so roofs never reshuffle).

Bonus: applyManifest no longer awaits icon <img> loads, which never fire in
jsdom — so tests that apply a real manifest can't hang on the atlas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…until the stream ends (#75)

The overlay hid when SCAN_PROGRESS cleared — which the stream's finally does the
moment the final manifest is received. But setManifest only KICKS OFF
applyManifest; its layoutCity runs async for a second-plus on a big repo, so the
overlay vanished while the city was still assembling → the "empty 3D world on
load" the owner saw on PostHog.

loadingReactions now also watches REBUILD_STATUS: once the stream ends, it holds
the overlay on "Building city" while the apply is still Rebuilding, and hides it
only when the status leaves Rebuilding (Decorating/Idle) — i.e. when the
buildings+streets are actually on screen. Settings rebuilds (no stream) still
don't raise the overlay (the footer owns that).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gress (#75)

A mid-transfer network drop on a huge repo (torvalds/linux) surfaced the ENTIRE
git --progress stream as the error — thousands of "Receiving objects: N%" lines
burying the actual failure. Two fixes in clone.py:

- Classify the interruption: early EOF / RPC failed / unexpected disconnect /
  invalid index-pack output → a new CloneInterruptedError with a clean, retryable
  message ("interrupted mid-download … please try again"). The router already
  forwards str(e), so no routing change.
- Strip the progress spam from every generic clone-failure message
  (_clean_git_stderr): drop the Receiving/Resolving/Enumerating/Counting lines,
  keep the last ~12 meaningful (error:/fatal:) lines. So even an unclassified
  failure never dumps a wall of percentages.

Tests: the real linux failure tail classifies to CloneInterruptedError; the
progress-stripper keeps the error lines and drops the percentages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… stream resets (#75)

Root cause of the torvalds/linux failure: GitHub serves git over HTTP/2, and its
stream multiplexing intermittently RSTs the pack transfer on very large repos
("curl 92 … HTTP/2 stream … CANCEL (err 8)" → fetch-pack disconnect → early EOF).
~1.4 GiB into an 8.5M-object clone, the stream got canceled and git aborted.

Fix (_run_net_git wraps clone + fetch):
- Force HTTP/1.1 (`-c http.version=HTTP/1.1`): a single unmultiplexed stream that
  doesn't hit the reset; throughput is equivalent for a one-pack clone. This is
  the actual cause fix.
- Retry a transient drop up to 3x with backoff, cleaning the half-written clone
  between tries (git clone can't resume into it). Non-network failures (auth,
  not-found, branch) still fail fast; cancellation short-circuits.

Tests: retry-then-succeed forces HTTP/1.1 on every attempt; a non-network error
isn't retried; before_retry cleanup runs between attempts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@thalida thalida linked an issue Jul 19, 2026 that may be closed by this pull request
A long recents list grew the landing page and scrolled the whole thing.
Cap the action column to the viewport (minus the inner padding) so the
"Open a project" card stays put and the recents list grows into the
remaining height, then scrolls its own rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thalida
thalida merged commit 4d313cb into main Jul 19, 2026
1 check passed
@thalida
thalida deleted the feat/issue-75-large-repo-perf branch July 19, 2026 16:37
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.

Large-repo render hang: architectural perf pass (profile-first)

1 participant