All posts
Engineering

Latest-at queries, and the bug that taught us to cache them

A user opened a recording with 400,000 entities and the viewer took nine seconds per frame. The fix was small. Finding it was not.

The report was terse: "viewer unusable on my recording." Attached was a 12 GB file. It opened fine. It rendered at roughly one frame every nine seconds.

The wrong hypothesis

Nine seconds with a big file says I/O. We spent a day and a half on that theory — profiling reads, checking memory-map behaviour, suspecting the decoder. Disk was almost idle.

The clue we ignored for too long: frame time did not depend on how much data was on screen. A view showing three entities was as slow as one showing everything. That is not an I/O signature. That is per-frame fixed cost, and it was scaling with something other than the visible set.

What it actually was

400,000 entities. Every frame, for every entity in the recording — not the visible ones — the viewer resolved the transform chain from that entity up to the world root, to decide where it would be drawn if it were drawn.

Each resolution is a handful of latest-at queries. Each is individually fast: a range scan and a binary search, tens of microseconds. Multiply by 400,000 entities times an average chain depth of four and you get nine seconds of entirely wasted work per frame, because almost none of those entities were in view.

The fix

Two changes. The obvious one: resolve transforms lazily, only for entities a view actually queries. The recording had 400,000 entities; the blueprint touched about 900.

The second: cache the resolution, keyed by entity and query time, invalidated when a transform component in that chain is superseded. Scrubbing backwards and forwards over the same window — which is what people actually do — now hits cache almost every frame.

// Before: eager, for everything.
for entity in store.all_entities() { resolve_transform(entity, t); }

// After: lazy, for what a view asked for, memoised on (entity, t).
let world_from_entity = cache.get_or_insert((entity, t), || resolve_transform(entity, t));

What we changed about how we work

The bug was invisible in every benchmark we had, because our benchmarks used realistic data volumes and unrealistic entity counts. We measured big recordings with tens of entities, never small recordings with hundreds of thousands.

We now fuzz the shape of recordings as well as the size: wide-and-shallow, narrow-and-deep, pathological chain depths. Two more O(total) paths turned up the week we added it.

If a frame time does not vary with what is on screen, the cost is not where you are looking.