Gunnar

nordisk/facett

.nornir/facett-benchmarks.md

# Benchmarking facett

facett declares **22** bench arms in `bench/examples/nornir-bench.rs` — a detached bench
crate with its own `[workspace]`, so the heavy `nornir` dev-dependency never reaches the
published facett crates' lockfile. Run them through the declared door:

```bash
NORNIR_BENCH_ONLY=facett. nornir bench run facett
```

**Always scope with `NORNIR_BENCH_ONLY=facett.`** The linked binary carries **231** arms,
not 22: facett links `nornir`, whose benchers share the same `inventory` registry, so an
unscoped run executes and records nornir's arms under `repo=facett`. (The same defect hit
korp — 143 of 148 recorded results were `nornir.*`. `nornir-bench/src/api.rs:253` records
it for skade on 2026-07-22.)

`wgpu` is OFF by default, so `facett.core.gpu_upload` self-reports `skipped=1` and no GPU
is touched. Add `--features wgpu` only when you have the device to yourself.

## MEASURED — the first numbers facett has ever produced (2026-09-05, oden)

facett had **zero rows** in `bench_runs` before this date, and the reason was not that
nobody ran it: **the bencher had not compiled since egui 0.35**. It called
`egui::Context::run`, which 0.35 removed, so `facett-bench` failed to build and all 22
arms were unreachable. Fixed in `8af62ef` (`Context::run_ui`; `Panel::show` now takes
`&mut Ui` rather than `&Context`). A door nobody can open looks exactly like a door that
works — which is why "has a bencher" is only half the gate, and "the bencher still
compiles" is the other half.

oden, 32 cores, facett `v0.1.19`. **22 arms, 22 passed, 0 failed.** Each arm carries
`host-busy N/32`, the harness's instantaneous whole-box sample.

### The headline: parallel efficiency FALLS as the work grows

`facett.core.cpu_raster` — the gatling-governed compositing raster, all cores against a
forced single worker (108.85 s, host-busy 32/32):

| quads | composite speedup | efficiency on 32 cores |
|---|---|---|
| 10 000 | **16.85×** | 53 % |
| 50 000 | **14.00×** | 44 % |
| 200 000 | **12.70×** | 40 % |

Fan-out is usually assumed to improve with more work; here it degrades. **Not diagnosed** —
memory bandwidth on a 200 k-quad composite is the obvious suspect and it has not been
measured. This is the number to watch.

### The rest

| arm | measured |
|---|---|
| `facett.demo_frame` | **4.74 ms/frame** (211 fps, 60 iters) — a real frame, the most trustworthy figure here |
| `facett.force_layout_2d` / `_3d` | **307 ms** / **238 ms** per 1 k nodes |
| `facett.scene_chrome_frame` | 31 panes, chrome **on 638 µs** vs **off 261 µs** |
| `facett.graph_render` | 1.05 M nodes/s |
| `facett.table_render` | 14.5 M rows/s |
| `facett.map_render` | 3.25 M points/s |
| `facett.map3d.*` | 3.3–12.5 M tris/s across project · cull · near_clip · shade · composite · scissor |
| `facett.core.sdf_circle/ring/marker`, `thick_line` | 126–337 M instances/s |
| `facett.core.gpu_upload` | `skipped=1` (no `wgpu`) |

The `core.sdf_*` rates look impossible and are not: `n = 10 000 000` at **3–8 ns/op** is
~10 cycles for a distance evaluation. But `ns_per_op` is rounded to whole nanoseconds, so
read them as **±½ ns ⇒ ±6–16 %** and do not chase small movements.

## Two things to fix before trusting a cross-check

**`cores_busy_*` is not a measurement — it is `speedup` under a second name.**

```rust
speedup_{n}              = seq_ns / par_ns        // real
cores_busy_{n}           = speedup.min(cores)     // DERIVED from the line above
composite_speedup_{n}    = cseq  / cpar           // real
composite_cores_busy_{n} = cspeedup.min(cores)    // DERIVED
```

It never touches the machine, and because the speedup never reaches 32 the clamp never
fires — so `composite_speedup_10000` and `composite_cores_busy_10000` are both `16.85`,
byte-identical, at every size. The speedups are genuine; the *name* is the hazard. Anyone
asking "did the fan-out actually saturate the box?" would compare the two, find perfect
agreement, and have verified nothing. Sample `/proc/stat` for a real cores-busy figure
(korp's `tests/gatling_gen_saturation_bench.rs` does exactly that) or drop the name.

**`facett.map3d.depth_sort` (223.8 M tris/s) times a nearly-sorted array.** Its loop is
`faces.rotate_left(1); faces.sort_by(descending)` × 200. After the first sort the array is
sorted, and rotating one element to the end leaves it sorted-except-one — which Rust's
run-detecting sort finishes in ~O(n). That may be the *honest* steady state, since a
slowly-moving camera really does leave last frame's depth order nearly correct; but the
comment calls `rotate_left(1)` a *"shuffle-ish perturb"* and it is not a shuffle. Decide
which case is meant and say so — a camera cut hands the sort genuinely unordered input,
and that number is not in this run.

## GPU lanes at 100k — the lane-B matrix (2026-09-29)

Door: `cargo run --release -p facett-graphview --features gpu --example layout_matrix --
[--sizes 1000,10000,100000] [--steps 80] [--cpu-budget-s 20] [--fdeb 500,2000]
[--cloud 100000,1000000] [--cpu-only]`. One markdown row per measurement, each stamped
with the adapter and the load average. The graph is the browser bench's community graph
(`browser_bench::graph_fixture` shape: sqrt(n) rings with a chord each + one long edge per
16 nodes, ~2n edges). Lanes:

| lane | what runs | per step |
|---|---|---|
| `gpu` | `layout.wgsl` `cs_layout`, all pairs, `GpuLayout::new` | O(n²) |
| `gpu-grid` | `layout_grid.wgsl` grid pyramid, forced at every n with `GpuLayout::new_grid` (shipped selector `new_for` takes it at ≥ `GRID_MIN_NODES` = 16 384) | O(n log n) |
| `cpu-1` | `barnes_hut::fr_step_serial` — graphnav's single-core lane (exact below `BH_THRESHOLD` = 2 000, serial Barnes–Hut above) | O(n log n) |
| `cpu-all` | `barnes_hut::fr_step` — the same step fanned over every core by gatling (graphnav's shipped CPU lane) | O(n log n) |

`--cpu-only` exists for the build container, which has no GPU: the GPU rows are then
printed as NOT MEASURED, never replaced with something else (GPU ruling 2026-09-28).

### dwarf, CPU rows only (2026-09-29 00:16 UTC)

Box: dwarf `claude` container, AMD Ryzen 5 7535HS, 12 threads, **no `/dev/dri`**, shared
with a gunnar session: **load 22.1 / 25.2 / 23.3 at start, 23.0 / 25.1 / 23.4 at end**
(≈ 2× oversubscribed), bench under `nice -n 10`. Read every number here as an upper bound.

| bench | lane | n | ms/step | converge (80 steps) | note |
|---|---|---|---|---|---|
| layout | cpu-1 | 1 000 | 2.32 | 186 ms | exact all-pairs (below BH threshold) |
| layout | cpu-all | 1 000 | 1.88 | 150 ms | 12 workers; fan-out barely pays at 1k on a loaded box |
| layout | cpu-1 | 10 000 | 13.6 | 1.09 s | Barnes–Hut |
| layout | cpu-all | 10 000 | 6.56 | 0.52 s | 2.1× over cpu-1 at load 22 on 12 threads |
| layout | cpu-1 | 100 000 | 194.5 | 15.6 s | 78 of 80 steps timed, rest extrapolated |
| layout | cpu-all | 100 000 | 111.4 | 8.9 s | 1.75× over cpu-1 |
| fdeb | cpu-1 | 500 edges | 0.82 | 49 ms (60 it) | 6 subdivisions |
| fdeb | cpu-1 | 2 000 edges | 7.33 | 440 ms (60 it) | 6 subdivisions |
| layout | gpu / gpu-grid | 1k / 10k / 100k | — | — | **NOT MEASURED** — no GPU in the container |
| fdeb | gpu | 500 / 2 000 | — | — | **NOT MEASURED** — no GPU in the container |
| cloud | gpu | 100k / 1M | — | — | **NOT MEASURED** — no GPU in the container |

Native `frame_cost` (the CPU half of one wrapped component, no GPU lane installed, so the
layout is graphnav's CPU lane and the paint is the egui painter), same box and load:

| component | n | parse | build | first frame | steady frame p50 / max | `state_json` | state bytes |
|---|---|---|---|---|---|---|---|
| graphnav | 10 000 | 55 ms | 47 ms | 3.81 s | 14.2 / 14.4 ms | 97 ms | 1.79 MB |
| graphnav | 100 000 | 218 ms | 225 ms | 8.35 s | 96.8 / 146.3 ms | 1.42 s | 17.9 MB |

**The 8.35 s is the layout, not the frame.** `frame_cost graphnav-split` times
`positions()` (graphnav's CPU FR lane natively; in a WebGPU browser the GPU `LayoutJob`
owns it) apart from the first `ui` frame. Same box, 2026-09-29 ~06:00 UTC, load 23-27:

| n = 100 000 | before (`8b4be60` graphnav) | after (`df3c7bd`) |
|---|---|---|
| CPU layout (`positions()`, the GPU's job in a browser) | 6.5-15.6 s | 9.8-20.3 s (same code; load noise) |
| first frame after layout | 140-201 ms | 193-197 ms |
| steady frame (CPU painter, no GPU cloud natively) | 97-160 ms | 139-151 ms |
| `state_json` first read (analytics computed once) | 1 421 / 1 374 ms | 1 235 / 1 233 ms |
| `state_json` cached read (each later `facet_state()` poll) | **741 / 655 ms** | **380 / 405 ms** |
| state bytes | 17 935 702 | 17 935 702 |

What a cached read cost (instrumented once, not committed): `stats()` cache HIT ~100 ms
(`graph.edge_indices()` rebuilt a 100k-key `BTreeMap` just to hash the signature),
`cycles_json` → `is_acyclic` ~150 ms (adjacency + DFS every read, uncached), `schema` ~80
ms (recounted though `build_frame_index` already builds it). `df3c7bd` signs over the
frame index's edge list, caches acyclicity, and keeps the schema in the frame index.
What is left per cached read: the 100k per-node `json!` objects (~100 ms) and the
serialisation of 17.9 MB — the shape of `canvas.nodes`, which robots read, so it is not
cut here.

### t14s — AMD Radeon 890M (integrated), 2026-09-29 10:00-10:15 local

Box: owner's laptop t14s, 16 threads, Ubuntu glibc 2.43, `AMD Radeon 890M Graphics
(RADV STRIX1)` over Vulkan (vulkaninfo; `llvmpipe` is also listed — every native row
below names the adapter it got, and it is the Radeon). Idle: load 0.2-0.4 at every
start, edge 38-47 °C / Tctl 40-67 °C around each run (`sensors`). Binaries built on dwarf
at lane B `bbf50ab`'s tree, nothing compiled on the laptop.

**Force layout, `layout_matrix`** (80 steps + readback on the GPU; CPU lanes timed):

| n | gpu (all-pairs) ms/step | gpu-grid ms/step | cpu-1 ms/step | cpu-all (16) ms/step |
|---|---|---|---|---|
| 1 000 | 0.495 | 0.249 | 2.16 | 0.785 |
| 10 000 | 1.507 | 0.222 | 8.76 | 2.13 |
| 100 000 | not run ¹ | **1.499** (80-step converge 120 ms) | 91.4 (7.3 s) | 20.8 (1.67 s) |

¹ All pairs at 100 000 nodes on this iGPU is one submit that outlives amdgpu's ring
timeout: the first matrix run (10:06:53) got `ring gfx_0.0.0 timeout … Process
layout_matrix`, a ring reset and a lost device. The shipped selector never runs it there
(grid at ≥ 16 384); the bench now skips it on non-discrete adapters and says so.

| bench | gpu | cpu-1 |
|---|---|---|
| FDEB 500 edges (60 it) | 17.5 ms | 32.7 ms |
| FDEB 2 000 edges (60 it) | 44.9 ms | 220.1 ms |
| point cloud 100 000, 1600×1000 + bloom + RGBA readback | upload 0.2 ms, first frame 5.7 ms, steady 6.0 ms | — |
| point cloud 1 000 000 | upload 1.9 ms, first frame 23.9 ms, steady 18.1 ms | — |

**Parity** (`grid_all_pairs_and_cpu_agree_on_a_small_graph`, 400 nodes): all-pairs vs CPU
after 30 steps max |Δ| 1.84e-3; grid vs CPU one step mean cosine 1.0000, median length
ratio 1.000; after 30 steps mean edge 6.697 vs 6.706 and span 29.90 vs 28.66. Per step:
cpu-1 2.89 ms, gpu all-pairs 0.67 ms, gpu grid 0.40 ms (incl. readback). gpu_layout:: 15/15.

**The flake, found and fixed.** 20 runs each of the graphview gpu lib tests:

| binary | parallel | `--test-threads=1` |
|---|---|---|
| `gv-test-baseline` (`2bab36c`, no GPU turn in 7 tests) | **8 / 20 SIGSEGV (exit 139)** | 0 / 20 |
| `gv-test-fixed` (`d8b6dd6`) | 0 / 20 | 0 / 20 |

Kernel log for the crashes: `segfault … in libvulkan.so.1.4.341` in the threads of
`tests::gpu_dispatch_frame_is_valid_or_cpu_fallback`, `gpu::tests::construct_and_render_or_fall_back`
and `gpu_layout::tests::*` — concurrent device bring-ups, the shape `gputurn.rs` records.
(Every run of both binaries also reported the old parity bar failing — exit 101 with a
full `test result` line; that bar is replaced in `20dcd9a`.)

**100k in the browser, headless Chrome on the render node** (`browser_bench --headless`,
graphnav, the Streamlit loader's steps; adapter `amd / rdna-3`; loaded bundle from lane B
`bbf50ab`'s tree, i.e. with the no-size-floor GPU job):

| n | first frame | settled (layout done) | layout lane / fallback | fps while dragging (rAF / app) | app ui ms mean | wasm memory | JS heap | `facet_state()` read p50 / max |
|---|---|---|---|---|---|---|---|---|
| 100 000 | 229 / 228 ms | 1 375 / 1 356 ms | gpu-job / — | 60.4 / 60.7 · 60.5 / 60.8 | 8.3 / 8.0 | 285 MB | 118 MB | 174 / 599 ms |
| 10 000 | 108 ms | 282 ms | gpu-job / — | 60.3 / 60.6 | 1.8 | 33 MB | 11 MB | 53 ms |

Mount road at 100k: wasm init 31-37 ms, `JSON.parse` 59-73 ms, `facet_check` 265-288
ms. Headless caveat (lane A, same box): synthetic pointer events may not reach eframe, so
the drag fps is the frame rate under a synthetic drag, not proof the camera moved.

The same laptop natively (`frame_cost graphnav-split 100000`, CPU painter, no GPU lane):
CPU layout 294-343 ms (16 threads, idle — against 7-20 s on dwarf at load 25: gatling
under oversubscription), first frame after layout 101-148 ms, steady 79-123 ms,
`state_json` 539-609 ms first / 178-181 ms cached.

### loki — NVIDIA RTX 5080 (discrete), 2026-09-29 20:49-22:36 loki local

Box: loki, 12 threads, Ubuntu glibc 2.43, `NVIDIA GeForce RTX 5080` (Blackwell, GB203),
driver **595.91.07**, Vulkan. Every binary built on oden (rustc 1.97.1) from facett main
**`12264fd`**, copied with rsync, nothing compiled on loki. Idle box: load 0.00-0.17 around
every bench row (4-9 during the test run below, not ours), GPU **40-43 °C, util 0-4 %** at
every stamp before and after, never waited on heat. `korp-server` untouched.

**Force layout, `layout_matrix`** (80 steps + readback on the GPU; one device, the shipped
high-performance probe; load 0.00-0.16 through the whole run, GPU 40 → 41 °C):

| n | gpu (all-pairs) ms/step | gpu-grid ms/step | cpu-1 ms/step | cpu-all (12) ms/step |
|---|---|---|---|---|
| 1 000 | 0.237 | 0.077 | 1.391 | 0.358 |
| 10 000 | 1.198 | 0.092 | 7.312 | 1.329 |
| 100 000 | 17.894 (80 steps 1.43 s) | **0.167** (80 steps 13.4 ms) | 99.5 (7.96 s) | 12.1 (0.97 s) |

All pairs at 100k runs on the discrete card (the t14s iGPU lost its ring there), but it is
**107×** the grid lane per step — the shipped selector's grid at ≥ 16 384 is right.
Grid vs cpu-1 at 100k: **596×** per step.

| bench | gpu | cpu-1 |
|---|---|---|
| FDEB 500 edges (60 it, 6 subdiv) | 13.9 ms | 32.8 ms |
| FDEB 2 000 edges | 19.0 ms | 217.7 ms (11.5×) |
| point cloud 100 000, 1600×1000 + bloom + RGBA readback | upload 1.1 ms, first frame 10.9 ms, steady **2.9 ms** | — |
| point cloud 1 000 000 | upload 11.1 ms, first frame 7.3 ms, steady **5.4 ms** | — |

**Native `frame_cost`** (CPU half, no GPU lane natively; load 0.09-0.17, GPU 41-43 °C):

| run | n | parse | build | layout (CPU lane) | first frame | steady p50 / max | `state_json` first / cached | state bytes |
|---|---|---|---|---|---|---|---|---|
| `graphnav` | 10 000 | 14.6 ms | 14.6 ms | (in first frame) | 395 ms | 4.4 / 5.0 ms | 41 ms | 1 789 770 |
| `graphnav` | 100 000 | 143 ms | 154 ms | (in first frame) | 355 ms | 50.9 / 56.5 ms | 466 ms | 17 977 667 |
| `graphnav-split` | 10 000 | 6.4 ms | 6.8 ms | 467 ms | 10.2 ms | 5.6 ms | 41 / 19 ms | 1 789 770 |
| `graphnav-split` | 100 000 | 72 ms | 68 ms | 335 ms | 72 ms | 55.8 ms | 462 / 166 ms | 17 977 667 |

**100k in the browser — HEADLESS, not headful.** loki had no graphical login: `loginctl`
showed only the sddm greeter on seat0, `/run/user/1000` had no `wayland-N` socket, and
rickard's ACL on `/dev/dri/renderD*` is absent (sddm holds it). So `browser_bench
--headless` with the shared headless set (`--headless=new --enable-gpu
--ignore-gpu-blocklist --disable-software-rasterizer --enable-features=Vulkan
--use-angle=vulkan --render-node-override=/dev/dri/renderD128`, 01:00.0 = the RTX 5080).
Chrome 154, adapter **`nvidia blackwell`** on every run, graphnav, the Streamlit loader's
steps, core bundle of `12264fd` (wasm `492d862a…`, 12 601 657 B):

| n | first frame | settled | layout lane / kernel | fps while dragging (rAF / app) | app ui ms mean | wasm memory | JS heap | `facet_state()` read p50 / max | GPU °C · load |
|---|---|---|---|---|---|---|---|---|---|
| 100 000 (run 1) | 238 ms | 1 079 ms | gpu-job / grid | 60.4 / 60.7 | 8.45 | 288 MB | 105 MB | 558 / 558 ms | 40 → 41 · 0.00 |
| 100 000 (run 2) | 235 ms | 1 078 ms | gpu-job / grid | 60.4 / 60.7 | 8.40 | 288 MB | 105 MB | 562 / 562 ms | 40 → 42 · 0.05 |
| 10 000 | 131 ms | 414 ms | gpu-job / all-pairs | 60.5 / 60.8 | 1.20 | 34 MB | 12 MB | 18 / 46 ms | 40 → 42 · 0.00 |

Mount road at 100k: wasm init 25-26 ms, `JSON.parse` 44-54 ms, `facet_check` 254-258 ms.
Same headless caveat as the t14s: the drag is synthetic pointer events.

**Gallery probe, headless on the 5080** (`gallery_probe --headless --groups all --dev` +
the docs and model3d group dists of `12264fd`): **48/48 PASS** (45 core, 2 docs, 1
model3d), wrong-bundle refusals **2/2**, adapter `nvidia blackwell` on all 48,
`adapter_attempts` 1 on all 48, 6 distinct navgraph frames on all 48. 4 min 6 s, GPU 42 →
48 °C, load 4.9-5.6 (not ours).

**GPU-only test binaries of `12264fd` on the 5080.** Built on oden (`cargo test --workspace
--no-run`, the gate's feature unification), the **431** of 482 test binaries that link
`wgpu_hal`, debug info stripped (61 → 24 GB), run ONE at a time with `--test-threads=1`,
`VK_LOADER_DRIVERS_SELECT=*nvidia*`, cwd = the crate, the worktree's sources rsynced to the
same absolute path (`~/runs/facett-0929/wt-bench`) so `CARGO_MANIFEST_DIR` paths resolve.
31 min 44 s; GPU 40-48 °C before and after each binary, never waited.

**424 green, 7 red; 3620 passed, 8 failed, 17 ignored.** No red is a 5080/driver
difference:

| red | tests | class | evidence |
|---|---|---|---|
| `facett-demo` `snapshot` | 1 | main debt | 18 090 of 921 600 px (1.96 %), max delta 2 — the SAME count as the 4090 gate (gpu-gate-2026-09-29.md) |
| `facett-demo` `mega_matrix` | 1 of 5 | main debt | `palette/facett_demo_default` 15 565 px (1.69 %), max delta 2 — the same count as the 4090 |
| `facett-core` `attribute_owner_law` | 1 of 4 | main debt, NEW since the gate | red on oden's 4090 too with the same binary: 6 hits, `facet-wrapped/src/devmode.rs:86`, `lib.rs:86`, `registry.rs:264/297/309/371`, each a test fn under `#[test] #[allow(unexpected_cfgs)] #[cfg(not(feature = "group-only"))]` stacked on ONE line — the law's scanner does not read stacked attributes on one line; from `59b8767` (bundle groups). Not fixed here. |
| `facett-core` `rayon_free_law`, `webgl_free_law` | 1 + 1 | tool-bound | run `cargo tree`; loki has no cargo (never compile on loki) |
| `facett-core` `gated_tests_actually_run` | 2 of 2 | tool-bound | runs `cargo metadata` |
| `facett-core` lib | 1 of 512 | tool-bound | `law::resolution_tests::a_feature_naming_a_feature_that_does_not_exist_does_not_resolve` runs `cargo metadata` on a temp repo |

Two first-run reds were staging artefacts, green on a rerun of the same binary:
`facett-jobview` `step_bar_png` (writes to the compiled-in `CARGO_TARGET_TMPDIR`
`~/runs/facett-0929/target-bench/tmp`, created on loki → 2/2) and `facett-demo`
`robot_demo` (`in_test_binary()` asks whether the exe sits in a dir named `deps`; staged in
`bin/` the demo built its 100 000-node graph3d instead of the test's 2 124 → from `deps/`
2/2). The other staged binaries that call `in_test_binary()` passed at the larger size.

### oden — NVIDIA RTX 4090 (discrete), 2026-09-29 20:08-20:31 oden local

**Headful rows: not run, owner stopped headful on oden 2026-09-29 ~20:05.** Two headful
gallery-probe windows were closed by the owner's order before they reported; three headful
`browser_bench` runs that did finish (19:27-19:29, graphnav 10k/100k, adapter `nvidia
lovelace`, in `bench-logs`) are not quoted. The first headful window (19:16, before the
flag fix) opened with `--ozone-platform=wayland` and no Vulkan flags and got **no WebGPU
adapter on 48/48 mounts** — the fix is below. The rows here are HEADLESS on the 4090.

Box: oden, 32 threads shared with other sessions (load **29-72** during these rows),
`NVIDIA GeForce RTX 4090` (Lovelace), driver **595.91.07**, Chrome 153, render node
`renderD128` (41:00.0). The owner's own Chrome held a context on the 4090 during the 100k
runs (util 19-35 % between run 1 and run 2 — the cool-wait held run 2 until < 10 %).
GPU 46-53 °C. Binaries and bundle as on loki (`12264fd`; browser_bench / gallery_probe
with the flag-list fix `6ef38ac`).

| n | first frame | settled | layout lane / kernel | fps while dragging (rAF / app) | app ui ms mean | wasm memory | JS heap | `facet_state()` read p50 / max | GPU °C · load |
|---|---|---|---|---|---|---|---|---|---|
| 100 000 (run 1) | 575 ms | 2 706 ms | gpu-job / grid | 52.4 / 52.7 | 16.5 | 288 MB | 118 MB | 400 / 1 145 ms | 46 → 48 · 31.7 → 56.5 |
| 100 000 (run 2) | 398 ms | 2 271 ms | gpu-job / grid | 60.2 / 60.5 | 13.9 | 288 MB | 118 MB | 317 / 1 014 ms | 48 → 49 · 72.0 |
| 10 000 | 225 ms | 584 ms | gpu-job / all-pairs | 60.8 / 61.1 | 1.65 | 34 MB | 12 MB | 34 / 92 ms | 46 → 48 · 28.8 |

Mount road at 100k: wasm init 36-114 ms, `JSON.parse` 65-73 ms, `facet_check` 417-526 ms.
**Read against loki, not as a GPU verdict:** the 4090 is not slower than the 5080 — the
check (CPU, 417-526 vs 254-258 ms) and the ui frame (13.9-16.5 vs 8.4 ms) are the CPU
side of a box at load 30-72, and settle follows them. Same lane, same kernel, same memory.

**Gallery probe, headless on the 4090** (`--headless --render-node /dev/dri/renderD128
--groups all --dev` + docs/model3d dists): **48/48 PASS**, wrong-bundle refusals **2/2**,
adapter `nvidia lovelace` on all 48, `adapter_attempts` 1 on 47 and 2 on one, 6 distinct
navgraph frames on all 48. 4 min 14 s, GPU 48 → 53 °C, load 50.9 → 37.3.

**The headful flag list, fixed in one place (`6ef38ac`).** `browser_bench::headful_gpu_args(ozone)` is
now THE headful set (`--enable-unsafe-webgpu --enable-features=Vulkan --use-angle=vulkan
--enable-gpu --ignore-gpu-blocklist --ozone-platform=<session>`), and both
`browser_bench` and `examples/gallery_probe.rs` take it; gallery_probe's headful path used
to drop Vulkan under Wayland (a 2026-09-28 loki note) and got no adapter on oden. Chrome
still logs `'--ozone-platform=wayland' is not compatible with Vulkan`, but Dawn picked
`NVIDIA GeForce RTX 4090 … backend=Vulkan, Discrete GPU` with the new set before the stop;
a full headful probe with it is NOT measured.

### Runbook — staging the set for a box (lane B, dwarf, 2026-09-29 00:20 UTC)

The Radeon 680M on dwarf is **not measured**: the container has no `/dev/dri`.

The binaries are built and staged on dwarf in `~/runs/facett-0929/stage-laneB/`:
`layout_matrix`, `browser_bench`, `frame_cost` (release), `gv-test-baseline` (graphview
gpu lib tests as of `2bab36c`, before the GPU-turn fix) and `gv-test-fixed` (as of
`d8b6dd6`, every device-opening lib test takes `gputurn::hold()`), and `dist/` (the
facet-wrapped wasm bundle from `cargo xtask wrap-wasm`). Nothing in the set names a box:
`browser_bench` finds the desktop session itself (`$WAYLAND_DISPLAY`, else a `wayland-N`
socket in `$XDG_RUNTIME_DIR` or `/run/user/<uid>`, else `$DISPLAY` / `/tmp/.X11-unix/XN`
with `--ozone-platform=x11`) and, without `--port`, takes the first free port of
8701-8719. **One host requirement: glibc ≥ 2.43** — the binaries were linked on dwarf
(Ubuntu glibc 2.43) and import `asinf@GLIBC_2.43` & co.; `ldd --version` on the target
first, and a box older than that needs the set rebuilt in a container of its glibc
(the Build Thing road), not on the box. The run on any Linux box with a GPU and a
logged-in desktop (loki: RTX 5080; t14s: the integrated-GPU row), nothing compiled there:

```text
rsync -a ~/runs/facett-0929/stage-laneB/ <box>:~/runs/facett-0929-laneB/
# on the box, record GPU temperature (nvidia-smi on NVIDIA; `sensors` amdgpu edge on AMD) and /proc/loadavg around each:
./layout_matrix > matrix-<box>.md
for b in gv-test-baseline gv-test-fixed; do for t in "" "--test-threads=1"; do for i in $(seq 20); do ./$b $t > run.log 2>&1; e=$?; grep -E "test result|panicked" run.log; echo "$b $t exit $e"; done; done; done
./browser_bench --dist dist --component graphnav --nodes 100000 --out browser-<box>.jsonl            # discrete / default adapter
./browser_bench --dist dist --component graphnav --nodes 100000 --power low --out browser-<box>.jsonl # integrated adapter
./frame_cost graphnav-split 100000 3                                                                  # native CPU half, same box
```

## The demo's graph tabs at 100k / 1M in the PAGES bundle — lane D (2026-10-01)

`nornir:class=design` · measured on the **t14s** (AMD Radeon 890M, RADV STRIX1, WebGPU adapter
`amd rdna-3`), **headless** Chrome 154 with the hardware flag set (`--headless=new --enable-gpu
--ignore-gpu-blocklist --disable-software-rasterizer --enable-features=Vulkan --use-angle=vulkan
--render-node-override=/dev/dri/renderD128`, `VK_LOADER_DRIVERS_SELECT=*radeon*`), one Chrome per
scenario. Bundle: the `facett-demo-wasm` row built in `nordisk-cargo-toolchain@sha256:668468e7…`
(`cargo ops demo-wasm`), served by `facet-wrapped/examples/demo_pages_probe` in a 1600 × 1000 iframe,
opened through the deep link `index.html?gen=<source>:<size>:1#<tab>`. Every number is the app's own
(`window.__facett_state()`): gen/build = generation and component build, first frame = build end →
the start of the second drawn frame, settle = first frame after 30 within 25 % of the running mean,
drag fps = app frames over a 3–4 s synthetic pointer drag (rAF p50 beside it). "first state" = iframe
load → first published state (wasm fetch + init + `DemoState::new` for the 81 tabs), ~7.5 s for all.

| tab · data | nodes / edges | gen · build | first frame | settle | lane | drag fps (rAF p50) | Tctl before → after |
|---|---|---|---|---|---|---|---|
| `Graphs` · community rings | 100 000 / 206 250 | 10 · 89 ms | 296 ms | 796 ms | layout `gpu-job` (GridLane), resident cloud `gpu_cloud: true` | **57.9** (16.9 ms) | 41.9 → 71.6 °C |
| `Universe` · crates (galaxy) | 99 935 / 197 500 | 3 · 2 ms | 76 ms | 553 ms | resident cloud, `lane: gpu` | **60.4** (16.6 ms) | 51.1 → 67.5 °C |
| `Universe` · crates (galaxy) | **998 250 / 1 989 000** | 23 · 22 ms | 130 ms | 675 ms | resident cloud, `lane: gpu` | **60.3** (16.7 ms) | 48.4 → 64.9 °C |
| `graphpan` · SBM | 100 000 / 309 704 | 7 · 33 ms | 265 ms | 2 274 ms | per-frame cloud, `gpu_cloud: true` | 14.8 (68 ms) | 52.4 → 78.9 °C |
| `decograph` · SBM | 100 000 / 309 704 | 15 · 49 ms | 967 ms | — | per-frame cloud, `gpu_cloud: true` | 2.8 (393 ms) | 53.3 → 86.6 °C |

Read: the RESIDENT lane (upload once per generation, the camera per frame — `Graphs`/`lineage` above
`SCALE_NODES`, `Universe`) holds vsync at 100k and at 1M. The PER-FRAME lane (`graphpan`,
`decograph`) is on the GPU but CPU-bound at 100k: every frame rebuilds an id → index `HashMap`, the
instance buffers and (decograph) the `BTreeSet<String>` visible set. Moving those two panes to the
resident lane is the next step, not done here.

Found and fixed on the way (all in lane D): the `Graphs` node table computed exact betweenness
(Brandes, O(V·E)) for 100k nodes on construction — the tab never drew; it is now built only up to
`SCALE_NODES` (5 000). The demo's control strip nested the palette picker at the tail of a full
row, which at 1 600 px wrapped it into a one-item column 1 680 px tall and pushed every tab's canvas
below the window (`decograph` then drew into a 0-px rect and fell back to the CPU). A navigator's
full `state_json` at 100k costs 7–12 s natively without a device (decograph 1.0 s, graphpan 0.6 s);
a generated tab above 5 000 nodes now publishes a summary (counts + lane readouts, 0.1 ms).

## decograph + graphpan on the RESIDENT lane at 100k — lane E (2026-10-01)

`nornir:class=design` · same box, browser, flag set, probe and deep link as lane D above (t14s,
Radeon 890M, RADV STRIX1, adapter `amd rdna-3`, headless Chrome 154, `demo_pages_probe`, 1600 × 1000
iframe, one Chrome per scenario, ≥ 60 s and Tctl < 60 °C between scenarios, stop at 85 °C). Bundle:
the `facett-demo-wasm` row from branch `lane-e/resident-100k` `c4db2da`, built in
`nordisk-cargo-toolchain@sha256:668468e7…` (`cargo xtask build --variant facett-demo-wasm`, then
`cargo ops demo-wasm`), wasm sha256 `1cb72bac…`.

| tab · data | nodes / edges | gen · build | first frame | settle | lane | drag fps (rAF p50) | Tctl before → after (max) |
|---|---|---|---|---|---|---|---|
| `decograph` · SBM | 100 000 / 309 704 | 7 · 48 ms | 423 ms | 905 ms | **resident**, `resident_uploads: 1` | **60.4** (16.6 ms) — was 2.8 | 36.1 → 57.8 (69.0) °C |
| `graphpan` · SBM | 100 000 / 309 704 | — | 159 ms | 691 ms | **resident**, `resident_uploads: 1` | **60.3** (16.6 ms) — was 14.8 | 37.8 → 58.9 (70.9) °C |
| `Graphs` · community rings (regression) | 100 000 / 206 250 | — | 320 ms | 885 ms | layout `gpu-job`, resident cloud | 58.5 (16.6 ms) — lane D 57.9 | 38.5 → 67.1 (72.4) °C |
| `Universe` · crates (regression) | 99 935 / 197 500 | — | 73 ms | 544 ms | resident cloud, `lane: gpu` | 60.4 (16.7 ms) — lane D 60.4 | 39.4 → 59.3 (71.4) °C |
| `falkor` · SBM (new generator) | 1 000 / 3 063 | 1 · 151 ms | 161 ms | — | CPU painter (fixture scale) | 60.3 | → 67.3 (74.1) °C |

`resident_uploads` stayed 1 through the 4 s drag: the drag sent the camera only. Native, release, no
tessellation (`tests/resident_lane.rs … camera_frame_cost_at_100k`, dwarf): a camera frame costs
27 µs (graphpan) / 6 µs (decograph); the one upload frame 29 / 127 ms (id map, visible set, points).
What moved: above `cloud::RESIDENT_ABOVE` (5 000) both panes upload points + edge index pairs once
per model (decograph: per model or unfold change) through `cloud::ResidentSlot`; per frame they draw
only the selection ring, decoration rings/badges, decoration edges, the lit trace (≤ 4 096) and the
on-screen labels (≤ 2 000). The price, as in graphnav's resident lane: edges take their tail node's
colour, and a selection does not dim the cloud (the per-frame lane did not dim it either).

## The Streamlit wheel's generated graphs at 100k — lane F (2026-10-02)

`nornir:class=design` · the wheel `facet_wrapped_web-0.1.19` from branch `lane-f/streamlit-parity`
`f125c0a` (four bundles built in the container road: core 12 910 365 B sha256 `00a9734b…`, docs
9 060 446 B, model3d 7 251 854 B, sim 9 575 469 B), served by `facet-wrapped/examples/gallery_probe`
(`--headless --dev --groups all`, 900 × 600 iframe, one Chrome, the probe's ONE headless flag set),
every component mounted through the loader's `facet:render` with the gallery's payload table, and
`universe` / `decograph` / `graphpan` given `{"generate": …}` requests at 100 000 nodes. Numbers are
the components' own `facet_state()` (`gen` object) read 3 s after mount.

| box · adapter | probe | `universe` crates 100k | `decograph` sbm 100k | `graphpan` sbm 100k | Tctl / GPU °C |
|---|---|---|---|---|---|
| t14s · `amd rdna-3` (Radeon 890M) | **51/51**, wrong-bundle 3/3 | 99 935 / 197 500 · gen 4 + build 3 ms · first frame 29 ms · settle 510 ms · **61.5 fps** (lane `gpu`) | 100 000 / 309 704 · gen 5 + build 75 ms · first frame 418 ms · `resident_uploads` 1 · compact state | 100 000 / 309 704 · gen 9 + build 49 ms · first frame 73 ms · `resident_uploads` 1 · compact state | 59.6 → max 81 |
| loki · `nvidia blackwell` (RTX 5080) | **51/51**, wrong-bundle 3/3 | 99 935 / 197 500 · gen 4 + build 3 ms · first frame 116 ms · settle 600 ms · **61.8 fps** (lane `gpu`) | 100 000 / 309 704 · gen 6 + build 73 ms · first frame 367 ms · `resident_uploads` 1 · compact state | 100 000 / 309 704 · gen 9 + build 54 ms · first frame 137 ms · `resident_uploads` 1 · compact state | 38 → max 53 |

Read: the universe repaints every frame, so its fps is a rate (vsync). decograph and graphpan are
reactive egui panes: with no input they draw only when nudged, and the probe nudges every ~250 ms
(9 frames, `frame_ms` ≈ 283 — the probe's cadence, NOT a cost). Their drag rate on the same code
(the builders are shared with the demo, `facett-graphgen::build`) is lane E's: 60.4 / 60.3 fps on
the t14s. The Streamlit page itself, through a reverse ssh tunnel to dwarf's :8501, headless:
`?pick=universe&gen=crates:100000:1`, `?pick=decograph&gen=sbm:100000:1`,
`?pick=graphpan&gen=sbm:100000:1`, `?pick=manual`, `?pick=simview` each print
`facet:mounted … canvas_api webgpu` on `amd rdna-3` (t14s) and on `nvidia blackwell` (loki).

## falkor · World coderoom · graph · graphview at 100k — lane G (2026-10-02)

`nornir:class=design` · lane E had capped these four tabs at `FIXTURE_SCALE` = 1 000 (CPU all-pairs
force layout / O(V·E) seam layout / an AccessKit button per node / CPU L0 raster per camera move).
Same box, browser, flag set, probe, iframe and deep link as lanes D/E (t14s, Radeon 890M, RADV
STRIX1, adapter `amd rdna-3`, headless Chrome 154, `demo_pages_probe` built on dwarf, one Chrome per
scenario under `flock ~/runs/t14s.gpu.lock` on the t14s, ≥ 60 s and Tctl < 60 °C before each, stop
at 85 °C). Bundles: the `facett-demo-wasm` row built in `nordisk-cargo-toolchain@sha256:668468e7…`
(`cargo xtask build --variant facett-demo-wasm` + `cargo ops demo-wasm`).

**The owner's power profile moves every number.** The t14s ran `power-saver` for the first runs and
`balanced` later (the owner's setting, not touched). Under `power-saver` even lane E's untouched tabs
drop from vsync, so an A/B against the LIVE pages bundle (lane E, wasm `1cb72bac…`) was run in the same
hour: no regression from lane G.

| tab · data | lane G bundle (power-saver) | live lane E bundle (power-saver, same hour) | lane E (2026-10-01) |
|---|---|---|---|
| `Graphs` · community 100k | 29.2 / 28.9 fps (rAF p50 34 ms) | 29.7 fps | 58.5 |
| `Universe` · crates 100k | 51.6 | 53.0 | 60.4 |
| `decograph` · SBM 100k | 50.9 | 51.5 | 60.4 |
| `graphpan` · SBM 100k | 51.8 | 53.4 | 60.3 |

The four tabs, 100 000 nodes, drag fps (rAF p50), first frame · settle, lane read off the app's own
state. `power-saver` = wasm `b21eada0…` (branch before the fixes below); `balanced` = wasm `39b146a4…`
(force layout for `graph`, the resident edge at the CPU painter's width) — the coderoom row on the
final code is in the final table below.

| tab · data | nodes / edges | power-saver | balanced | lane |
|---|---|---|---|---|
| `falkor` · SBM | 100 000 / 309 704 | **52.8** (18.8 ms) · 885 · 2 478 ms | **60.4** (16.7 ms) · 547 · 1 953 ms | layout `gpu-job` kernel `grid`, resident cloud, `resident_uploads: 1` |
| `World coderoom` · crates | 99 935 / 197 500 | **52.6** (18.8 ms) · 584 · 1 171 ms | **60.4** (16.6 ms) · 310 · 790 ms | O(V+E) layers, resident cloud, 1 upload |
| `World coderoom` · SBM (cyclic, before the DAG fix) | 100 000 / 309 704 | 21.7 (26.8 ms) | 34.3 (p95 70 ms) | one screen-wide band of ranks: fill-bound |
| `graph` · SBM, circle | 100 000 / 309 704 | 45.3 (22 ms) · 220 · 902 ms | — | scale path, resident, a11y 2 000 of 100 000 |
| `graph` · community, force (GPU job) | 100 000 / 206 250 | 45.7 (circle) | **59.8** (16.6 ms) · 155 · 670 ms | layout `gpu-job`, resident, 2 uploads (circle, then the layout) |
| `graphview` · SBM | 100 000 / 309 704 | **52.6** (18.5 ms) · 317 · 917 ms | — | `lane: resident`, `resident_uploads: 1` |

Read: all four are on the GPU at 100k — layout (falkor, graph: `gpu_layout::LayoutJob`, the grid
kernel above 16 384, never all-pairs) and paint (the resident cloud: one upload per model, drag
frames send the camera only — `resident_uploads` stayed 1 across every 4 s drag). What held them
below vsync was FILL, not the CPU: 300 000 long edges on an iGPU. Three fixes: `graph` at scale is
force-laid on the GPU instead of the circle (100k on one circle = 300k screen-wide chords, a white
disc); the generated coderoom points edges newer → older (Cargo forbids cycles; an SBM's cycles
stacked 100k crates into one band of ranks; now 96 layers); and `DecoratedGraphView`'s resident edge
is the CPU painter's 1.4 px stroke (it drew 3 px — twice the ink).

Native, dwarf, release (`facett-demo/tests/four_tabs_at_scale.rs`, the cloud gate opened without a
device — what the lanes SEND): falkor 100k build 330–360 ms (no device: `layout.lane: "no GPU"`, no
CPU all-pairs above `CPU_LAYOUT_MAX` = 1 000), drag frame 17–24 µs, 1 upload; World coderoom 100k
crates / 309 704 edges build (seam + O(V+E) layout + scene) 0.88–1.0 s, 96 layers, drag frame 10 µs,
what-breaks rebuild 146–171 ms (re-coloured, not re-laid; 1 upload, camera kept); graph 100k first
frame 3 ms, drag frame 1.3–1.7 ms (on-screen scan + 2 000 a11y buttons), 1 layout + 1 upload;
graphview 100k first frame 36–54 ms (upload), drag frame 4–6 µs. `DepGraphLayout::build` 100k nodes
/ 400k edges incl. a 100k-deep chain: 0.51 s (was 3.6 s for the build body alone, and O(V·E) for
the root scan — the reason for the 1k cap); parity with the old recursive rank on 59 cyclic graphs.

## The final GPU pass: every graph tab at 100k on ONE bundle, RTX 5080 — lane L (2026-10-02)

`nornir:class=design` · the pass lane G could not finish on the t14s (Tctl 76–78 °C during a video
call). Box: **loki**, `NVIDIA GeForce RTX 5080` (Blackwell, 01:00.0 = `/dev/dri/renderD128`), driver
595.91.07, Chrome 154 **headless** (`--headless=new --enable-unsafe-webgpu --enable-gpu
--ignore-gpu-blocklist --disable-software-rasterizer --enable-features=Vulkan --use-angle=vulkan
--render-node-override=/dev/dri/renderD128`), WebGPU adapter **`nvidia blackwell`** on every run. Bundle:
the LIVE pages bytes — `pages` `3ef70a7` (main `1d73e36`, `?v=4088fa9`), wasm sha256 `28016392…`
(26 924 313 B), extracted with `git archive origin/pages` and checked against the served file.
`demo_pages_probe` (lane G's binary, built on dwarf; the probe source is unchanged since `ed3071d`),
1600 × 1000 iframe, deep link `index.html?gen=<source>:<size>:1#<tab>`, 4 s synthetic drag, one Chrome
per scenario under `flock ~/runs/loki.gpu.lock`, ≥ 30 s and GPU < 60 °C before each. Nothing compiled on
loki. GPU **39–43 °C** across the whole pass (never waited on heat), util 0 % before each run, load
0.0–1.7. The owner's own Chrome window was open on loki's desktop (idle, GPU 0 %) and was not touched.

First frame · settle (ms) · drag fps (rAF p50) · `resident_uploads` after the drag; t14s = Radeon 890M
(lanes D/E/G rows above; `balanced` unless marked):

| tab · data | nodes / edges | RTX 5080 (loki) | t14s | lane on loki |
|---|---|---|---|---|
| `Graphs` · community | 100 000 / 206 250 | 232 · 700 · **60.2** (16.7) — rerun 232 · 704 · 60.3 | 320 · 885 · 58.5 (lane E); 29.2 under power-saver (lane G) | layout `gpu-job` / `grid`, resident cloud |
| `lineage` · community | 100 000 / 206 250 | 302 · 843 · **56.8** (17.6) — rerun 304 · 830 · 58.3 (17.1) | not measured | layout `gpu-job` / `grid`, resident cloud |
| `decograph` · SBM | 100 000 / 309 704 | 306 · 767 · **60.3** (16.7) · uploads 1 | 423 · 905 · 60.4 · 1 (lane E) | `cloud_lane: resident` |
| `graphpan` · SBM | 100 000 / 309 704 | 118 · 583 · **60.4** (16.7) · uploads 1 | 159 · 691 · 60.3 · 1 (lane E) | `cloud_lane: resident` |
| `graph3d` · community | 100 000 / 206 250 | 110 · 577 · **60.3** (16.7) | not measured | `layout: given`, GPU util 98 % at the end of the drag |
| `Universe` · crates | 99 935 / 197 500 | 66 · 531 · **60.4** (16.7) | 73 · 544 · 60.4 (lane E) | resident cloud, `lane: gpu` |
| `Universe` · crates | **998 250 / 1 989 000** | 121 · 589 · **60.4** (16.7) | 130 · 675 · 60.3 (lane D) | resident cloud, `lane: gpu` |
| `falkor` · SBM | 100 000 / 309 704 | 307 · 970 · **60.3** (16.7) · uploads 1 | 547 · 1 953 · 60.4 (lane G) | layout `gpu-job`, resident |
| `World coderoom` · crates | 99 935 / 197 500 | 206 · 677 · **60.3** (16.7) · uploads 1 | 310 · 790 · 60.4 (lane G) | O(V+E) layers, resident |
| `World coderoom` · SBM, **after the DAG fix** | 100 000 / 309 704 | 316 · 787 · **60.3** (16.7) · uploads 1 | 34.3 before the fix (lane G); after: not measured | O(V+E) layers, resident |
| `graph` · SBM, **force** | 100 000 / 309 704 | 119 · 591 · **60.3** (16.7) · uploads 2 | 45.3 on the circle, power-saver (lane G) | layout `gpu-job`, `scale_layouts: 2`, resident |
| `graph` · community, force | 100 000 / 206 250 | 108 · 576 · **60.4** (16.7) · uploads 2 | 155 · 670 · 59.8 (lane G) | layout `gpu-job`, resident |
| `graphview` · SBM | 100 000 / 309 704 | 113 · 589 · **60.3** (16.7) · uploads 1 | 317 · 917 · 52.6, power-saver (lane G) | `lane: resident` |

Read: on the discrete card every tab holds vsync at 100k and the Universe at 1M; `resident_uploads`
stayed 1 (2 for `graph`: the circle, then the GPU layout) through every 4 s drag, so drag frames send
the camera only. No tab regressed against the t14s rows: every first frame and settle is shorter and
every drag rate is at vsync (± 0.1 fps) or higher — `Graphs` at 60.2 on the same bytes confirms lane G's 29 fps was the
t14s's power-saver profile, not the code. `graph` (SBM, force) and `World coderoom` (SBM, after the DAG
fix) are measured here for the first time on the final code. The one tab under vsync is `lineage`
(56.8 / 58.3, rAF p50 17.1–17.6 ms, steady — not hitches): the same `GraphNav` as `Graphs` with the same
layout and resident cloud, the one difference in its published state is `hover_candidates: 42` (the
drag path crosses nodes on the lineage placement; `Graphs` 0) — the per-frame hover/tooltip path under
the pointer, about 1 ms. Not a regression (no earlier 100k row exists); not fixed here.

Found on the way: with `VK_LOADER_DRIVERS_SELECT='*nvidia*'` (the t14s recipe's `*radeon*` analogue)
Chrome's `vkCreateInstance` fails with -7 on loki and the page falls back to SwiftShader — the probe
refuses it (`FAIL: the browser's WebGPU adapter is not a GPU`). Leave the variable unset on NVIDIA; the
render node override alone selects the 5080. On SwiftShader the demo then PANICS in
`CloudModelPaintCallback::prepare` (`createBuffer` 1 600 000 B with `mappedAtCreation` "too large for
the implementation") instead of reporting "no GPU" — a software adapter is refused upstream, so this
is recorded, not fixed.

**Gallery probe + the Streamlit page, same box, same flag set.** `gallery_probe --headless --dev
--groups all` (built on dwarf from main `1d73e36`) over main's FOUR bundles, built in the container road
(`cargo xtask wrap-wasm <group>`, `nordisk-cargo-toolchain@sha256:668468e7…`): core 12 912 341 B
sha256 `7078870c…`, docs 9 058 752 B `c3794460…`, model3d 7 251 923 B `42a7c3e1…`, sim 9 575 300 B
`dc7a75c8…`; the gallery's payload table with `universe` crates, `decograph` SBM and `graphpan` SBM all
as `{"generate": …}` at 100 000. **51/51 PASS**, wrong-bundle refusals **3/3**, adapter `nvidia
blackwell` and `adapter_attempts` 1 on all 51, 6 distinct navgraph frames on all 51; 4 min 25 s, GPU
39 → 48 °C (max 47 sampled), load 0.1–0.3.

| component (generated, 100k) | gen · build | first frame | settle | rate | lane |
|---|---|---|---|---|---|
| `universe` crates 99 935 / 197 500 | 5 · 3 ms | 110 ms | 588 ms | **61.7 fps** | `lane: gpu`, resident cloud |
| `decograph` SBM 100 000 / 309 704 | 5 · 73 ms | 373 ms | — | probe cadence (3.6 Hz nudges) | `cloud_lane: resident`, `resident_uploads` 1 |
| `graphpan` SBM 100 000 / 309 704 | 8 · 52 ms | 145 ms | — | probe cadence | `cloud_lane: resident`, `resident_uploads` 1 |

Lane F's loki row on the lane-F wheel (116 / 600 ms / 61.8 fps; 367 ms; 137 ms) is reproduced on main's
bundles within noise. The Streamlit page itself — dwarf's `127.0.0.1:8501` (container
`draupnir-facet-wrapped-demo`, the lane-F wheel, core `00a9734b…`, NOT rebuilt from main) through a
reverse ssh tunnel opened from dwarf to loki's `127.0.0.1:8501` for this run and closed after:
`?pick=universe&gen=crates:100000:1`, `?pick=decograph&gen=sbm:100000:1`,
`?pick=graphpan&gen=sbm:100000:1`, `?pick=manual`, `?pick=simview` each print `facet:mounted …
"canvas_api":"webgpu"` for the picked component (bundle core / core / core / docs / sim) and for every
other component on the page (geomap, boxplot, binview, calendar, table ×2), adapter `nvidia blackwell`,
`software: false`, no panic in any page log.

## lineage at vsync + SwiftShader says "no GPU" — lane M (2026-10-02)

`nornir:class=design` · lane L left two defects: `lineage` at 100k under vsync on loki's RTX 5080 (56.8 / 58.3
fps) and a PANIC on SwiftShader. loki was switched off mid-lane, so the GPU checks ran on the **t14s** (Radeon
890M, `amd rdna-3`, `balanced`, headless Chrome 154, the handover flag set + `--render-node-override=/dev/dri/renderD128`,
`VK_LOADER_DRIVERS_SELECT=*radeon*`, one Chrome per run under `flock ~/runs/t14s.gpu.lock`, Tctl < 60 °C
before each, stop at 85). Same probe (`demo_pages_probe`, built on dwarf), same scenario, A/B in one session:
**before** = the live pages bytes (`pages` `3ef70a7`, wasm sha256 `28016392…`), **after** = this lane's
`cargo ops demo-wasm` (`nordisk-cargo-toolchain@sha256:668468e7…`, wasm sha256 `069f6f97…`, 26 949 374 B).

**The cause was not the hover path.** A drag neither rebuilds nor probes the hover index
(`a_drag_neither_rebuilds_nor_probes_the_hover_index`; `hover_builds` / `hover_probes` now in the state, 3
probes in every after-run, none during the drag); `hover_candidates: 42` is the press-frame probe — a readout.
Native timing on dwarf (`facett-demo/tests/lineage_drag_at_scale.rs`, the page's frame = `draw_app` + the
published state): the page serialised **every tab's state every frame** (`main.rs`: `demo.state_json()`), and
with `lineage` on screen that included the inactive `Graphs` tab's full demo-data state (its population
sample: 102 kB, **1.2–1.9 ms per frame** natively, vs lineage's six-row fixture: 34 µs when `Graphs` is on
screen). Fix: `FacetDeck::state_json_rolling` + `serialize_rolling` — the tab on screen every frame, ONE
inactive tab per frame in turn, the rest (values and their JSON text) from a cache, moved not cloned, bytes
identical to serde's; demo `publish_rolling` on the page. Second, `GraphNav::positions()` and `appearance()`
each hashed all 206 250 resolved edges every frame for a cache key (`structure_sig`) — memoised per frame
index. Native drag frame (dwarf, load ~13–20, 100k community): every-tab publish 16–36 ms → rolling 9–11 ms.

| tab · 100k community | before (live 3ef70a7) | after (lane M) |
|---|---|---|
| `lineage` | **45.4** fps (p50 20.9 ms, p95 34.8) · rerun 37.9 (26.0) | **60.4** (16.5, p95 18.5) · rerun **60.3** (16.6) |
| `Graphs` | 56.6 (17.2, p95 20.9) · rerun 42.2 (23.4) | **60.3** (16.7) · rerun **60.3** (16.6) |

First frame / settle unchanged within noise (lineage 471–521 / 1110–1434 → 493–502 / 1061–1063 ms). The
before-reruns ran warmer (Tctl 59 at start vs 38–42); the after-runs all started at Tctl 59 and still held
vsync. Not re-measured on the RTX 5080 (loki off): lane L's 56.8 / 58.3 there is the same defect.

**SwiftShader.** Repro on the t14s: a plain headless Chrome without `--enable-gpu` / `--use-angle=vulkan`
(`--no-gpu-flags` on both probes) → WebGPU adapter `google swiftshader`. Before: the demo installed its lanes
on it and panicked in `CloudModelPaintCallback::prepare` (lane L, loki); on the t14s the before-run burned the
CPU rasterising (Tctl 43 → 86 in 20 s, stopped by the heat guard). After: `wasm_start` reads `GPUAdapter.info`
first and refuses — page text and `__facett_state()` = `{"gpu":"no GPU","gpu_reason":"no GPU: software adapter
(SwiftShader/llvmpipe) — … (google swiftshader) …","adapter_attempts":1}`, 0 panics, Tctl max 59.
facet-wrapped core bundle (this lane's `cargo xtask wrap-wasm core`) through `gallery_probe` at 100k
(universe crates, decograph SBM, graphpan SBM): on the Radeon **3/3 PASS** (`amd rdna-3`, gpu_cloud); with
`--no-gpu-flags` all three refuse with the same message in `facet_error()`, 0 panics. Second line of defence:
the cloud lanes create buffers without `mappedAtCreation` (the throwing call) and check the device limits
(`buffer_refusal`); a refused buffer draws nothing and is said as `gpu_error` in the state.

## facett-flow + facett-grid after PR #2 (2026-10-04, loki RTX 5080)

facett main `c9a6443d` (PR #2 = `a294d344` by Andreas Persson, merged as `a367f9aa`). Release
binaries built on oden, run on loki (nothing compiled there), `flock ~/runs/loki.gpu.lock`, no
`VK_LOADER_DRIVERS_SELECT`, Tctl 59–68 °C. **A/B in one session:** `head` = `c9a6443d`; `base` =
`c9a6443d` with `facett-flow/src` and `facett-grid/src` taken from `2478f9d1` (main just before the
merge), so the only difference between the two binaries is PR #2's code.

### `flow_lab --benchmark --layout hub`

loki had no desktop session (sddm greeter), so the window ran inside a headless
`kwin_wayland --virtual --xwayland` (3000×2000) with `WINIT_X11_SCALE_FACTOR=2`: pane 1170×802
points at ppp 2.0, the same pane as the 2026-10-03 baseline (`falsett-loki`, the falsett
prototype `24e947e` on the owner's desktop). Xwayland ran without glamor (no `/dev/dri` access
without a seat; NVIDIA Vulkan renders through `/dev/nvidia*`), so presentation is a software copy
with no vsync cap: **fps here does not compare with the baseline's desktop/vsync fps; the GPU pass
(timestamp queries over the offscreen flow pass) does.** ms, p50 / p95, fps in brackets.

| nodes | phase | baseline 2026-10-03 | base (pre-PR #2) | head (PR #2) |
|---|---|---|---|---|
| 10k | raw-overview | 0.518 / 0.938 (59.9) | 0.231 / 0.236 (80.2) | 0.230 / 0.236 (84.7) |
| 10k | raw-pan-zoom | 0.734 / 1.106 (59.9) | 0.266 / 0.300 (80.6) | 0.266 / 0.299 (68.6) |
| 10k | clusters | 0.126 / 0.147 (59.7) | 0.008 / 0.012 (78.8) | 0.008 / 0.012 (75.4) |
| 10k | raw-detail | 0.320 / 0.348 (59.9) | 0.036 / 0.040 (77.8) | 0.035 / 0.039 (83.8) |
| 1M | raw-overview | 23.219 / 23.663 (32.2) | 23.183 / 23.560 (32.5) | 23.210 / 23.601 (30.3) |
| 1M | raw-pan-zoom | 25.514 / 28.080 (30.8) | 25.391 / 28.043 (29.9) | 25.385 / 27.895 (29.9) |
| 1M | clusters | 0.121 / 0.168 (60.0) | 0.006 / 0.011 (133.9) | 0.008 / 0.012 (81.5) |
| 1M | raw-detail | 3.138 / 3.144 (60.0) | 3.094 / 3.104 (105.6) | 3.099 / 3.105 (95.2) |

**PR #2's level of detail changes nothing in these four phases** — base and head agree within
0.2 % on every GPU pass. The segment count is `ceil(sqrt(max|B''| · px_per_world / 4))`, clamped
2..16, and `max|B''|` is taken over ALL edges, so the longest edge decides the count for every
edge. In every phase above the graph spans the pane, the GPU pass is unchanged, so the count
stays at its 16 ceiling. It
drops below 16 only when the longest edge's `|delta − 3·bend|` is under 150 physical px, i.e. a
flat edge under ~580 px or a diagonal one under ~150 px: a graph zoomed far out. The 1M baseline
reproduces (23.2 ms); the 10k baseline's 0.52 ms was the prototype on the desktop with a compositor
on the same GPU, today's 0.23 ms is facett's copy on an idle GPU — the A/B is the comparison.
Results: loki `~/runs/pr2/results/flow-{base,head}-{10000,1000000}/benchmark.json`.

### `grid_bench 100000` (CPU, headless egui; loki, median of 3 runs)

`base` runs the same example with the `while grid.filter_pending()` loop removed (the pre-PR
filter is synchronous, so `filter_submit` = `filter_complete` there).

| ms | base (pre-PR #2) | head (PR #2) |
|---|---|---|
| ingest (`Grid::from_record_batch`) | 8.84 | **1.82** |
| first frame | 3.20 | 4.54 |
| scroll frame p50 / p95 | 0.309 / 0.320 | **0.162 / 0.173** |
| sort a Float64 column (`click_header(1)`) | 9.36 | **32.87** |
| filter "north": UI blocked on submit | 26.55 | **0.00** |
| filter: complete | 26.55 | 15.20 (spread over frames) |
| search frame p50 / max | 0.71 / 0.71 | 2.13 / 4.14 |

Wins: ingest 4.9× faster (shared Arrow buffers), scroll frames 1.9× cheaper, a 100k search no longer
blocks a frame for 26 ms (the longest search frame is 4.1 ms). **Regression: the Float64 sort is
3.5× slower** (9.4 → 32.9 ms, all three runs 29.8–33.3) — every comparison now goes through
`Column::value` on the Arrow column (edit-overlay lookup, null bitmap, variant match) where the
old path indexed a `Vec<f64>`; the first frame also costs 1.3 ms more. Results: loki
`~/runs/pr2/results/grid-{base,head}-100k-run{1,2,3}.json`.

### `grid_bench 100000` after the fix (dwarf, CPU, branch `fix/grid-sort-2026-10-04`)

Box: dwarf (`claude` container, AMD Ryzen 5 7535HS, 12 threads, no GPU), shared, **load 30-34**
through the runs, `nice -n 10`, release, rustc 1.98.1. Three binaries from one tree: `base` = main
`b81078a3` with `facett-grid/src` from `2478f9d1` and the bench's `filter_pending` loop removed (the
loki method above), `head` = main `b81078a3`, `fixed` = the branch. Interleaved base/head/fixed, 10
runs each, median, 2026-10-04 22:50-22:51 UTC.

| ms | base (pre-PR #2) | head (PR #2) | fixed |
|---|---|---|---|
| ingest (`Grid::from_record_batch`) | 10.90 | 2.07 | **0.48** |
| first frame | 3.78 | 4.98 | **3.49** |
| scroll frame p50 / p95 | 0.324 / 0.461 | 0.182 / 0.206 | 0.182 / 0.216 |
| sort a Float64 column (`click_header(1)`) | 10.21 (9.93-15.38) | 31.86 (29.62-51.58) | **5.86** (5.68-8.66) |
| filter "north": UI blocked on submit | 35.33 | 0.00 | 0.00 |
| filter: complete | 35.33 | 19.42 | 17.42 |
| search frame p50 / max | 1.16 / 1.16 | 2.72 / 5.23 | 2.64 / 3.60 |

A first 5-run batch at 22:00 UTC (before the fix, the box slower then) read base / head sort 21.95 /
65.14 ms and first frame 6.34 / 8.88 ms: the same 3x and +1.3-2.5 ms as loki.

**The cause, both regressions:** whole-column work read its cells one at a time through
`Column::value` -> `ArrowColumn::value` (an edit-map probe, the null bitmap, a 13-way array match,
an edit parse for edited cells): the sort twice per comparison (n log n comparisons), the Σ footer
once per row on the first frame (later frames hit the sum cache). With totals off, PR #2's first
frame was already no slower than base. **The fix:** a sort reads each key column once, edits
applied, into a contiguous `Vec<Option<K>>` (`Column::keys`), sorts the primary key as `(key, row)`
pairs with `sort_unstable_by` (`total_cmp` for floats, nulls last both ways, secondary keys on ties,
source row last); sums and ranges fold the typed Arrow buffer once (`Column::fold_values`), summing
in source-row order so Σ no longer moves with a re-sort.

Per type, `tests/perf.rs` (100k rows, header click on a fresh grid vs the pre-PR #2 sort copied from
`2478f9d1` into the test, same process, interleaved, best of 7, release; tests in parallel on the
loaded box, so read the ratio, not the ms):

| sort, 100k | main (PR #2) vs pre-PR | fixed vs pre-PR |
|---|---|---|
| Float64 (grid_bench's column) | 46.53 vs 15.23 ms = 3.05x | 8.09 vs 18.16 ms = **0.45x** |
| Int64 | 50.22 vs 19.12 ms = 2.63x | 6.07 vs 33.89 ms = **0.18x** |
| UInt64 | 31.11 vs 16.32 ms = 1.91x | 7.84 vs 41.91 ms = **0.19x** |
| Utf8 | 42.48 vs 31.17 ms = 1.36x | 16.88 vs 69.68 ms = **0.24x** |
| Σ footer, 2 numeric columns, cold | 1.522 vs 0.141 ms = 10.78x | 0.148 vs 0.172 ms = **0.86x** |

Budgets: sort <= 1.1x (release and debug; debug fixed 0.50-0.73x), Σ <= 1.5x in release (both loops
are bound by the dependent float add, 20 runs at load 31 read 0.80-1.00x). Unoptimised, a fold and
the per-cell path cost the same (~3x the pre-PR loop), so the debug verdict for Σ is the counted
guard `sorting_and_totals_read_no_cell_one_at_a_time` (no cell read one at a time, except each
edited cell once), red on main and green here in both profiles.

## facett-flow after PR #3 (flow-pipelines `c224fc11`): plain flow in a default and an all-features build (2026-10-04, loki RTX 5080)

Branch `merge/flow-pipelines-2026-10-04` (main `b81078a3` + PR #3 `c224fc11`). Release
`flow_lab` built on dwarf, run on loki the same way as the PR #2 section above (headless kwin +
Xwayland, ppp 2.0, pane 1170×802 points; fps is not vsync-comparable, the GPU pass is).
`default` = `--features wgpu`; `all` = `wgpu` + host, labels, boxes, arrows, status, rate,
layered, compact-state, hit-test, lod. Both run the default `plain` scenario. `main` = the PR #2
`head` rows above (`c9a6443d`). GPU pass p50 / p95, ms.

| nodes | phase | main | PR #3 default | PR #3 all features |
|---|---|---|---|---|
| 10k | raw-overview | 0.230 / 0.236 | 0.231 / 0.236 | 0.232 / 0.237 |
| 10k | raw-pan-zoom | 0.266 / 0.299 | 0.267 / 0.298 | 0.268 / 0.302 |
| 10k | clusters | 0.008 / 0.012 | 0.008 / 0.011 | 0.006 / 0.008 |
| 10k | raw-detail | 0.035 / 0.039 | 0.035 / 0.039 | 0.036 / 0.042 |
| 1M | raw-overview | 23.210 / 23.601 | 23.162 / 23.538 | 23.237 / 23.644 |
| 1M | raw-pan-zoom | 25.385 / 27.895 | 25.363 / 28.153 | 25.410 / 27.875 |
| 1M | clusters | 0.008 / 0.012 | 0.008 / 0.013 | 0.008 / 0.013 |
| 1M | raw-detail | 3.099 / 3.105 | 3.094 / 3.103 | 3.095 / 3.104 |

**Plain flow is unchanged by PR #3, with or without the features compiled in** (every GPU pass
within 0.3 % of main; plan R4 proof 1). For scale, the all-features build running
`--scenario all` at 10k (boxes, arrows, status, rates, labels, hit probe, compact state read
every frame): GPU pass 0.372 / 0.367 / 0.014 / 0.389 ms over the four phases, Facet UI CPU p50
about 1.1–1.2 ms in the raw phases (0.01 ms plain), compact `state_json` 0.003–0.004 ms.
Results: loki `~/runs/pr2/results/flow-pr3-{default,all}-{10000,1000000}`, `flow-pr3-all-10000--scenario-all`.

Same session, same card: Chrome WGSL gate 65/65 (33 flow compositions + 32 other shaders; adapter
nvidia/blackwell, negative control rejected); facett-flow all-features tests compositions 3,
contracts 8, lib 7, host 10, gpu_panes 1, gpu_host 1, large_scene 1 (the ignored 1M acceptance
run) — all green with `FACETT_TEST_GPU=discrete`.