Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

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:

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.