Kanzo UI
Graph

Benchmarks

How far the bounded path goes, what it costs to move the camera, and the one number that is not flat in N.

The harness is a showcase you can run: it sweeps five corpus sizes through the real reader, the real renderer and a real DuckDB, and parks its results where a headless runner can read them.

Build the corpora first

The fixture is compiled, not generated. The page used to build its own graph in the tab, which capped the sweep at what a tab can generate and spent the main thread doing it; now fossil writes a GraphAr tree once, offline, and the browser only reads it.

node docs/showcases/graph-bench/corpus/build-corpus.mjs

They land in docs/public/bench/<size>/ and are gitignored — tens of megabytes, 93 MB at a million. A sweep against a size nobody wrote is a row of 404s, which is the honest failure.

What the sweep asks

Two layers, and they are not variants of each other. Bounded is the path a product uses: a window asked of a corpus, capped at twenty thousand marks, holding nothing. cosmos.gl alone is the control — the same renderer fed typed arrays with nothing of ours in the way.

Four numbers matter and they pull in different directions:

  • First paint should follow the window rather than the corpus. It is the claim the page exists to make.
  • Pan is the cost that did not exist before. Holding a graph pans on the GPU for free; this asks the database every time the camera stops. A bounded path that pans slowly is not an improvement, it is a different kind of unusable.
  • Redraw is the renderer's own ceiling, and it is here to be ruled out: the slice never exceeds the limit, so the frame rate cannot follow N, and "make it smoother" has to be answered at Pan.
  • Shown / matched is the honesty column. When they differ the view is not showing everything in the rectangle, and a reader is owed that.

Read the two rates together, never one of them

A benchmark that published a single "fps" here would be publishing whichever one flattered it. The renderer's ceiling is in the hundreds of frames a second and never follows N; what a reader feels at a million is a picture that moves at the display's rate and becomes correct about twice a second. The canvas never waits — geometry is pushed when a slice lands and the last one keeps being drawn — so the pair measures the gap between moving and being right.

Neither half is flat, and they fail for opposite reasons

Writing is bounded by the machine and reading is supposed to be bounded by the window. Measured 2026-08-25 against corpora from two thousand to ten million, only one of those two sentences is about a curve that behaves, and it is not the one anybody would guess.

Reading was not bounded by the window, and the answer was never why. The same rectangle — one percent of the extent — answers 812 vertices at a million and 771 at five, both out of a single tile, and it took 75.6 ms and 1,000.8 ms. At ten million, 4.7 seconds for 3,152 vertices.

The cause was one query. openCorpus handed DuckDB every tile URL in the corpus for the vertex half and pruned with a WHERE — 245 paths at a million, 2,442 at ten, a statement 245,649 characters long — so a window opened ceil(N / 4096) Parquet footers however few rows it wanted. The edge queries in the same window opened two, because they are addressed from the tiles the vertex answer reported. The addressing worked; one query did not use it.

It is fixed, and the shape of the fix is the point. The arithmetic says which tiles exist and only the footers say which ones a rectangle intersects, so the boxes are read once per corpus and kept — a few thousand rows of metadata, no column data — and every window after that opens the tiles it names: 13.6 ms at a million, 15.2 at five, 64.7 at ten, one file each and two at ten million. Same answers, complete still true, fossil's eighty-eight tests green. It is 010451a in the fossil checkout, and the reasoning now lives on the cost model page beside the streaming one.

What is still unbounded is the first window. The boxes have to come from somewhere, so opening a corpus and asking once costs 112 ms at a million, 1,117 at five and 5,327 at ten. Locally that is a footer read; over HTTP it is range requests before anything is drawn, and the last chart prices them over a plain origin: 4,892 requests and 40 MB to open a ten-million corpus, against 71 requests and 0.97 MB for a window of it — two ranges and about 16 kB of footer per tile, so 2·tiles + 8 reproduces the curve to the request. The writer knows every tile's box when it cuts it, and publishing them — tile, min_x, max_x, min_y, max_y, about 100 kB at ten million — is what would make a cold window bounded too.

And how a tile is stored is now a manifest field rather than an assumption. fossil writes a tile as its own file or as a row group inside one file, and both readers read both. On the same corpus, answering identically: a window opens 15 files one way and 3 the other, and priced in ranges at five million, 5.6 requests per window against 22.3 with the footer in one 496 kB piece instead of 1,221 totalling 1.15 MB. Same bytes stored to within one percent. The charts above are the one-file-per-tile container throughout.

None of this page would have caught it. A window returning the right answer looks exactly like a window returning it cheaply, and it took timing the same rectangle against two corpus sizes from outside the repository. The assertion that does catch it is a shape rather than a number: files opened per window does not grow with N. A pinned number was always correct.

Nor would the conventions suite, and the reason is worth the line. fossil checks seventeen corpus conventions, and both corpora it checks them against are written by JavaScript — the harness that asks fossil itself to write one runs in no CI job. Two implementations of one convention were diffed against each other and agreed, because they made the same mistake. The guard that does bear on this, spatial-tiles, is exercised on a 300-vertex corpus in five tiles; measured here on bench/1000000 its number is 0.89% against a bound of 50%, which is a property the published fixture is far too small to have. BENCHMARKS.md carries both.

Writing is bounded by the machine, and that part is structural. The whole build peaks at 20.72 GiB at ten million — 404.4 seconds, for 1.5 GB of corpus — and the curve has no ceiling in it: extrapolated, it meets this machine's memory somewhere between twenty and thirty million vertices. There is no spill path anywhere in the layout pass, so exceeding the machine is a dead run rather than a slow one — the opposite of the guarantee.

The peak is a peak because stages hold a whole value instead of passing batches through: community_hierarchy keeps a community array per vertex plus a contracted graph per level, read vertices materialises the vertex file to permute it into Morton order, and GraphArData collects every batch before a byte is written. A stage that streams has a flat working set; a stage that accumulates makes the high-water mark follow the corpus.

The largest of them is the one that was not on that list, and it is the only one already bounded. Run level by level with each level in its own process, the vertex plan at ten million peaks at 13.72 GiB in the merge over a 13.85 GiB aggregate — the dedup that holds every distinct subject. Deleting the collect() beneath it leaves about 13.9 GiB, so the collect is not the term; it is 1.64 GiB sitting on top of one. And that dedup honours --memory-gib: 4.84 GiB under an 8 GiB pool, 3.67 under 4, 2.30 under 2, for roughly 2.5× the wall clock. What the pool does not reach is everything after execute_graph, which is where the other stages are and where the 20.72 GiB stays.

And the writer's half is not intrinsic. Louvain's levels collapse at the first one — ten million vertices become 22,171 communities, then 2,736, then 1,365 — so every level above zero fits anywhere. The whole cost is level zero, and level zero is the graph whose CSR is already on disk in the artefact, already sorted. A pass that scanned it and kept only community[u] would hold O(V) of u32 — 40 MB at ten million — instead of 0.41 GiB per million.

Two harnesses, two graphs — do not read across the charts

The memory charts come from two processes: the whole build at the generator's own degree, and fossil's enrich_memory at mean degree 10. Louvain's state follows degree as well as N, so the series are comparable within a chart and not between them. BENCHMARKS.md carries the run-by-run figures and the one row of fossil's own documentation that did not reproduce.

The claim is not flat, and the reason is the edge join

BENCHMARKS.md carries the recorded runs and the arguments. The one thing worth knowing before you read a table: first paint does grow with the corpus, and the growing term is the slice rather than the upload — the upload is flat, around thirty milliseconds at every size.

Which part of the slice is a live question and the obvious answer is measured wrong. The edge join was the suspect — it is the one part that touches something proportional to the corpus — and reading the edges of a window from their own tiles instead of the whole relation is 1.3×, which is not where the time goes. What does scale with N is reading the vertex side: the first slice asks for the whole extent, so every tile intersects and every one is opened.

There is a second, sharper cost measured the same way: this reader hands DuckDB every chunk URL and prunes with a WHERE, so it skips no row group — and a corpus tiled finely for a reader that does skip costs it several times over. That is the strongest argument for an addressed reader, and it is written up in BENCHMARKS.md with the isolation that produced it.

A flat line is a cache until proven otherwise

Run twice on one page, this sweep once reported a beautiful flat 60–71 ms at every size including a million. Mosaic caches by SQL text and a second sweep asks identical questions. Each measurement now clears that cache — but it is the third time this benchmark has measured its own scaffolding, after a frame counter that counted its own await and a layer that ran the small graph at every size.

Two more things that are the harness and not the product: total() is asked once per size and the first size also pays for DuckDB-WASM booting and fetching its httpfs extension, which is seconds; and threads rides along in every row because a page that is not cross-origin isolated gets a single-threaded DuckDB however many cores the machine has.

On this page