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:
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.
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:
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.