This repository has no description
bez docs rig-capacity.md
21 kB

Rig capacity #

Measured rates for both halves of the loop: CPU-side verification (building the oracle corpus, compiling candidates, scoring them) and GPU-side generation (the sampling and capacity tiers of docs/infrastructure.md → "Three tiers"). The question is which tier saturates first.

Hardware is described by role and class, never by count, hostname or account. The rig's real counts live in the gitignored bench/rig-inventory.local.json (shape: bench/rig-inventory.example.json); bench/capacity.py combines them with the rates here to print absolute throughput and power draw. Nothing computed from the real counts is written back here.

flowchart LR
  gpu["GPU: writes candidates<br/>~0.1–0.4 completions/s per pool"] --> cpu["CPU: compile + score<br/>144 candidates/s, 272,930 cases/s"]
  oracle["CPU: browser oracle<br/>~1.5 docs/s/core, once"] --> cpu

Writing candidates is the bottleneck by several orders of magnitude.

CPU tier #

Measured on the 16-core, no-GPU development box, shared with other tracks' work; the load is reported beside each figure.

Oracle corpus building #

bench/oracle-scaling.sh runs N oracle_shard processes on disjoint seed shards, each driving all three engines, 8 documents per worker, zero engine errors. Browser memory is peak resident memory minus a pre-step baseline.

workers total docs wall (s) docs/s (fleet) docs/s per core (16-way) browser RSS, MB total / per worker load (1/5/15 min, before)
1 8 5.4 1.48 1.48 862 / 862 43.9 / 39.4 / 26.1
2 16 5.9 2.73 1.36 1721 / 860 43.1 / 39.3 / 26.1
4 32 7.6 4.21 1.05 2783 / 696 43.6 / 39.5 / 26.3
8 64 11.9 5.38 0.67 5160 / 645 45.6 / 40.1 / 26.6
16 128 16.5 7.74 0.48 9539 / 596 47.5 / 40.6 / 26.9
  • Memory is not the limit: under 1 GB per worker for all three engines.
  • With only 8 documents per worker, browser launch (4–13 s) dominates. One worker given 40 documents renders at 21.3 docs/s through all three engines (1.88 s after a 3.8 s launch).
  • Every step ran beside a WebGPU CTS sweep saturating the box, so the falling per-core rate may be contention rather than anything inherent. Open: re-measure in a quiet window.

Batch candidate compilation #

bench/gen_candidates.py generates N synthetic candidates (five templates, 50–300 lines) as one crate (batch) or N crates (separate); bench/compile-batch.sh times cargo check and cargo build --release, uncached and through the shared sccache (warm, not from empty). CPU-seconds, under a load average of 24–50:

N layout uncached check (cpu-s) uncached build (cpu-s) sccache check (cpu-s) sccache build (cpu-s)
10 batch 0.12 0.15 0.15 0.07
10 separate 0.56 0.61 0.63 0.12
100 batch 0.31 0.50 0.33 0.07
100 separate 5.11 5.52 6.05 0.71
1000 batch 2.29 4.69 2.42 0.07
1000 separate 54.57 58.65 57.98 8.74
  • At N=1000, one crate per candidate costs about 24× more CPU per candidate to check (54.6 ms against 2.29 ms) and 12.5× more to build: per-crate overhead, not code size.
  • Wall-clock at N=1000, batch, uncached: 144 candidates/s on the contended box.
  • Normalized-AST deduplication (bench/src/bin/dedup_cost.rs): about 2,000 functions/s, cheap enough to run before every batch.

A real batch on a build lane #

One absolute_real_model_run batch, 32 candidates from qwen3.8-27b-fast at 32 in flight, started from the development box with bench/absolute-mixed-batch.sh and run on a 96-thread lane (docs/infrastructure.md → "Build lane"). CPU is the lane container's own cgroup usage sampled every 2 s (bench/cpu-sample.sh), split by phase with bench/cpu-phases.py; "busy" is CPUs kept busy on average. Probes one crate at a time, as every batch did before candidate_crate::probe:

phase wall (s) CPU-s avg busy peak busy
generation loop (includes the worktree's first release build on the lane) 216.5 1112.2 5.1 79.3
draw: waiting on the model 236.0 11.7 0.0 0.9
probe: 32 candidates one crate at a time (first one built the probe dependencies) 122.3 99.4 0.8 9.3
scoring crate build (8 survivors) 0.8 2.4 3.1 1.2
scoring run 0.5 0 0 0
per-candidate rebuild for compiler error files (since removed) 30.8 16.2 0.5 1.3
whole run 609.1 1280.2 2.1 79.3

8 of 32 compiled; best 82/108, equal to the committed candidate. Outside the one-off builds, one feature run keeps about one CPU busy and spends most of its time waiting on the model; filling the rig's CPUs would take hundreds of such runs at once, so the CPUs are not what a batch waits on.

The same batch after the change: one wide --keep-going probe build with no compiler wrapper, compiler error files written from that build's messages, nested builds free of cargo run's variables (candidate_crate nested_cargo()). Three runs:

run draw (s) probe (s) scoring build + run (s) whole run (s) avg / peak busy compiled best
after 1: 32 × qwen3.8-27b-fast 283.9 19.0 1.5 323.2 0.4 / 8.5 12/32 82/108
after 2: same 297.1 16.3 4.1 318.6 0.2 / 11.0 11/32 82/108
two pools: 16 × qwen3.8-27b-fast, 16 × qwen3.8-flash-next 146.5 / 703.4 5.1 / 4.2 0.9 / 1.3 729.0 0.1 / 9.1 5/16, 13/16 74/108

The probe dropped from 122 s to 4–5 s. Runs 1 and 2 each spent about 15 s of their probe rebuilding ring, rustls, rustls-webpki, ureq, provenance and generation: ring's build tracks CARGO_MANIFEST_DIR, which the batch binary inherited from cargo run and passed to its nested builds, so any build of the same target directory started elsewhere marked it dirty. Nested builds now drop those variables; the two-pool run is the first with that change. The draw is now nearly the whole run, and it runs on the serving hardware: the lane sits at under one busy CPU waiting on replies (about 700 completion tokens/s across 32 requests to the fast pool). The first two-pool run's flash-next draw overlapped another batch on the same pool; a rerun with the pool to itself took 656.9 s for 16 (9 compiled, best 74/108) against 109.4 s for the fast pool's 16 (5 compiled, best 82/108), whole run 660.9 s, probes 4.6 and 1.6 s. Splitting a batch across the two pools does not shorten it: the slower pool sets the wall time, at about twice a 32-candidate fast-pool batch.

Probing replayed on the same sources with warm dependencies (bin/probe_replay.rs, bench/probe-replay-measure.sh), old against new, on one lane:

candidates one crate at a time one wide build through sccache-dist same, compiled
32 absolute (the batch above) 19.0–23.1 s 14.0 s, avg 3.3 busy, peak 16 8 both ways
64 absolute (an earlier mixed batch) 41.6 s, avg 1.0 busy 36.6 s, avg 4.0, peak 47.8 29 both ways
64 overflow (an earlier mixed run) 44.9 s, avg 1.0 busy 42.9 s, avg 4.8, peak 57.0 56 both ways

Both probes admit exactly the same candidates on all three sets. Through sccache-dist the wide build sends each candidate's library to a builder (16–58 distributed compiles per replay) but gains little wall time: the per-candidate work is under a second, so shipping it costs about what it saves.

The same wide build with no compiler wrapper, against sccache-dist run twice back to back (PROBE_REPLAY_RUNS='wide-sccache wide-sccache wide-local', a fresh PROBE_REPLAY_WORK), on one build lane:

candidates sccache-dist, first sccache-dist, second no wrapper compiled
32 absolute 14.5 s (49 dist compiles) 14.6 s (16) 1.4 s 8 in every run
64 absolute 35.7 s (82) 35.8 s (49) 1.4 s 29 in every run
64 overflow 45.5 s (91) 42.8 s (58) 1.6 s 56 in every run

The second sccache-dist pass hands out fewer compiles (some are cache hits) yet takes the same time, so the cost is not a cold cache or a first toolchain upload: it is a fixed delay per compile through the wrapper. Without it the lane's own cores probe a batch 20–30 times faster (peak 7–31 busy CPUs for about a second). An earlier no-wrapper run on a fresh work directory took 2.2 s for the 32. The probe runs without the wrapper.

Inner-loop evaluation #

bench/run-inner-loop.sh runs a stand-in candidate over bench/fixtures/, measuring the harness-side cost every candidate pays (deserialize, clone, compare). Load average 57–59:

threads cases/s (fleet) cases/s per core load (1 min, before)
1 25,709 25,709 57.4
2 65,241 32,621 57.2
4 97,411 24,353 57.2
8 167,757 20,970 57.2
16 272,930 17,058 57.3

No plateau through 16 threads: the inner loop is nowhere near the constraint.

Other CPU workloads #

No dedicated benchmarks yet for the conformance suites, the WebGPU CTS on software Vulkan, or trace requests.

Core budget formula #

verification_cores = machines × cores_per_machine

Corpus building is occasional (a corpus is fixed per browser-version set); compilation and scoring run on every sample and get the rest. bench/capacity.py reports the pool and per-hour throughput; a specific split waits on the real candidate arrival rate.

GPU tier #

sampling_candidates_per_s  = cards    × per_card_candidates_per_s
capacity_escalations_per_s = machines × per_machine_escalations_per_s

All sweeps below use bench/rig_throughput.py with the real width-equation prompt bin/real_model_run.rs assembles, one round per concurrency level (N requests at once).

Measured — the rig's vLLM server #

qwen3.8-27b: dense 27B, int4 AutoRound, 4-way tensor parallel on four 24 GB cards in one VM, max_model_len 196608, vLLM 0.24 at the time (now 0.27.1). Prompt 4,807 tokens, output capped at 3000 (since raised to 8000; the concurrency-1 reply hit the old cap).

concurrency completions/s output tok/s time to first token, mean (s) latency, mean (s) latency, max (s)
1 0.025 76.5 4.7 39.2 39.2
4 0.067 142.2 14.1 49.4 59.9
8 0.094 167.3 25.2 66.6 85.4
16 0.116 240.0 41.5 117.4 137.4
32 0.125 237.2 95.9 186.3 256.6

Aggregate throughput plateaus by 32; latency climbs linearly throughout.

Measured — the eight-card pool (qwen3.8-27b-fast) #

Same model on eight cards (aliases -fast and -long, 131k context). Prompt 5,006 tokens.

concurrency completions/s output tok/s time to first token, mean (s) latency, mean (s) latency, max (s)
1 0.312 21.8 n/a 3.2 3.2
2 0.112 144.3 2.5 10.5 17.8
4 0.207 203.2 2.7 9.1 19.3
8 0.240 338.5 4.2 18.0 33.4
16 0.342 523.0 6.7 27.0 46.7

No plateau by 16; about 2.2× the four-card pool at the same concurrency. The concurrency-1 reply streamed no content before finishing at 70 tokens, so its time to first token is unmeasured.

Measured — the four-card VM (qwen3.8-27b-fast-2) #

The eight-card pool's recipe on four 3090s in a VM, one worker per card, four requests per worker (docs/infrastructure.md → "Where each piece runs"). Prompt 5,040 tokens, 2026-09-24.

concurrency completions/s output tok/s time to first token, mean (s) latency, mean (s) latency, max (s)
1 0.070 106.8 5.9 14.3 14.3
2 0.087 254.1 5.7 21.5 22.9
4 0.174 317.7 5.1 15.8 22.9
8 0.322 685.1 4.7 18.2 24.8
16 0.398 819.3 8.8 27.6 40.2

Not a controlled comparison with the eight-card sweep (different days, other traffic on that pool), but it is not the slower alias.

Measured — GLM-5.3 Flash (2-way tensor parallel, 4-bit EXL3) #

Probably mixture-of-experts. Same prompt, 3000-token cap. Stopped at concurrency 8 because every completion ran to the cap.

concurrency completions/s output tok/s time to first token, mean (s) latency, mean (s) latency, max (s)
1 0.008 25.1 8.9 119.5 119.5
4 0.020 60.4 10.7 192.2 198.7
8 0.020 58.0 94.5 263.5 391.7

12 of 13 completions hit the cap. The reason: with enable_thinking: false this server still writes its reasoning into message.content, so the budget goes to analysis prose and code never arrives. That is a serving problem, not a prompt one, and GLM was replaced.

Attempted — Qwen3.8-Flash-Next #

Chosen over DeepSeek-V4-Flash to replace GLM because it shares qwen3.8-27b's lineage, already proven through the same extraction code, even though published two-Spark figures rate it slower (42.6 tok/s single-stream against 72.4 for DeepSeek-V4-Flash and 41.1 for GLM).

flowchart TD
  a["4-bit EXL3 checkpoint, 101 GB<br/>fits one 121 GB node"] --> b{"any vLLM build<br/>on the rig serves it?"}
  b -- "no: fork too old for the<br/>architecture, upstream has no EXL3" --> c["size a new EXL3 backend<br/>for upstream vLLM (not written)"]
  b --> d["serve with the checkpoint's own<br/>engine (TabbyAPI) instead"]
  d --> e["temperature 1.0 default:<br/>0 of 8 compile (word salad)"]
  e --> f["sampler preset 0.7 / top-k 20 / top-p 0.95:<br/>19 of 32 compile, best 140/227"]
  • Why vLLM failed. The rig's EXL3 fork predates the model's architecture (Qwen4ExpForConditionalGeneration); a newer generic build also predates it; upstream vLLM 0.30.0 registers the architecture but has no EXL3 backend. The fork's EXL3 support is a one-file overlay for routed experts and one codebook only, while this checkpoint quantizes every linear with the newer mul1 codebook and stores its 39 GB n-gram table as a trellis. So either direction needs a new EXL3 backend; the smaller path is an overlay onto upstream 0.30.0's arm64 image. The kernel library (1.5.1) was rebuilt for this GPU class; the backend is not written and stays queued.

  • Single node, the library's own server (TabbyAPI), 3000-token cap:

    concurrency completions/s output tok/s time to first token, mean (s) latency, mean (s) latency, max (s) hit 3000 cap
    1 0.016 24.9 9.6 64.4 64.4 0 of 1
    4 0.025 56.3 3.8 127.3 159.8 1 of 4
    8 0.033 85.1 5.2 212.5 242.1 4 of 8

    Unlike GLM, replies stop on their own. One sequential 8-candidate batch compiled 1 of 8 (114/227, not admitted); the rest put prose inside the first Rust fence.

Serving — four single-Spark replicas through the gateway (2026-09-22, evening). One replica per node as a systemd unit (bench/sparks/), GLM disabled, HAProxy least connections with eight in flight per replica, gateway alias qwen3.8-flash-next. Prompt 4,475 tokens by this engine's tokenizer:

concurrency completions/s output tok/s TTFT mean (s) latency mean (s) latency max (s) hit 3000 cap
1 0.012 25.1 7.9 84.1 84.1 0 of 1
4 0.036 66.4 6.5 73.0 111.2 0 of 4
8 0.064 123.1 1.7 89.5 124.2 0 of 8
16 0.109 161.4 2.3 82.9 146.6 0 of 16
32 0.132 238.9 3.4 145.3 241.8 0 of 32

Sixteen in flight is the latency knee. The first batch through the alias compiled 0 of 8: the client sends no sampling parameters and the engine's fallback was temperature 1.0 with no truncation, so replies drifted into multilingual word salad. With the checkpoint's own preset as fallback (bench/sparks/sampler-genera.yml: temperature 0.7, top-k 20, top-p 0.95), one 32-candidate batch took 7 m 49 s wall and compiled 19 of 32, admitted 14, best 140/227, replacing the committed claude-opus-5 width candidate (132/227). Since 2026-09-24 only two replicas serve this alias (docs/infrastructure.md).

Per-replica batch size, one node (2026-09-23, isolated rerun). One node switched from cache_mode: FP16, cache_size: 65536, max_batch_size: 8 to Q8, 131072, 16 (same memory), HAProxy maxconn 8 → 16. Pool idle before each round. Output tok/s:

in flight pool, all baseline pool, trial node changed one Spark direct, trial one Spark direct, baseline
8 144.7 tok/s 155.4 (+7%) 81.8 82.0
16 183.4 200.8 (+9%) 91.4 81.7
32 284.3 275.3 (−3%) — —
64 503 errors 310.9 (after queue fix) — 311.0 (after queue fix)

The 64-in-flight errors came from HAProxy, not the batch size: the backend had no timeout queue, so queued requests failed after 10 s. timeout queue 30m fixed it and stays; everything else was reverted.

Per-replica batch size, all four nodes (2026-09-23). Same change on all four, fresh prompt (15,678 bytes), so comparable only within this table:

in flight pool, all baseline all batch 16, run 1 all batch 16, run 2 batch 16 mean vs baseline
8 123.9 tok/s 130.3 (+5.2%) 135.1 (+9.1%) +7.1%
16 225.8 207.8 (−8.0%) 211.1 (−6.5%) −7.2%
32 309.6 310.6 (+0.3%) 295.5 (−4.6%) −2.1%
64 313.0 294.9 (−5.8%) — −5.8%

Not kept: the bar was +5% at 16 and 32 in flight. Thirty-two baseline slots already cover those levels, and the Q8 cache is the likely cost. The rig is at baseline (batch 8, maxconn 8, timeout queue 30m), matching bench/sparks/tabby-config.yml.

Measured — real batches through the gateway #

Gateway at 127.0.0.1:18002 (tunnelled, bearer key NEV_MODEL_API_KEY), vLLM 0.27.1 for Qwen. Real bin/real_model_run.rs batches, MAX_COMPLETION_TOKENS 8000:

model prompt tokens output tokens generation time (s) output tok/s, batch output tok/s, per candidate hit 8000 cap
qwen3.8-27b 4,881 16,085 147.1 109 80-139 0 of 8
glm-5.3-flash 4,075 54,350 2,263.4 24.0 21-36 4 of 8
qwen3.8-27b-fast 5,006 17,563 80.9 217.0 80.3-114.7 1 of 8
qwen3.8-27b-fast (32 candidates, concurrency 8, 3 batches, specs/rig-batch-absolute.md) 626,240 501,235 ~687.7 ~728.7 60.4-123.0 19 of 96
qwen3.8-flash-next (32 candidates, concurrency 32, 5 batches, specs/rig-batch-absolute.md) not reported not reported 584.5-599.4 per batch (2,968.2 summed) not derivable not derivable not derivable
  • The first two rows drew sequentially; later rows concurrently (NEV_CONCURRENCY, default 8; NEV_CANDIDATES, default 8). A concurrent batch takes as long as its slowest reply: a Qwen batch of 8 drew in about 60 s against 168.0 s summed, a Claude batch in about 121 s against 755.2 s summed. Compiling and scoring still run one candidate at a time afterwards.
  • qwen3.8-27b-fast, 32 candidates: generation time is summed latency divided by 8 (251.6 + 201.2 + 234.9 s); full batch wall 414, 266 and 293 s.
  • qwen3.8-flash-next returns no usage block, so no token figures exist; full batch wall 1,158, 1,014, 1,152, 966 and 1,056 s; 75 of 160 compiled, against 10 of 96 at qwen3.8-27b-fast.
  • Null-content replies (a gateway quirk) are excluded from per-candidate ranges.

Sampling tier — 24 GB consumer cards (modelled, not measured). Data-parallel replicas scale near-linearly (~98.5% per card); tensor parallelism over PCIe helps only to two cards. per_card_candidates_per_s ≈ 0.47 (range 0.1–2.2) for a 30B-total/3B-active mixture-of-experts model; at batch ≥ 24 most experts are touched and the advantage over a dense model mostly disappears. Dense 14–27B models run 0.7–1.5 candidates/s per card; the choice between them is about pass rate, not throughput.

Capacity tier — large unified-memory machines (modelled). per_machine_escalations_per_s ≈ 0.02 (about 70/hour) for a ~100B/~12B-active model at an 8k prompt and 1.5k output; prefill dominates.

Power #

tier_power_w   = count × watts
total_power_w  = Σ tier_power_w
power_binds    = total_power_w > power_budget_w

bench/capacity.py computes this from the local inventory. A model, not a control: nothing enforces the budget. Which hardware the project may hold is decided by docs/infrastructure.md → "Sharing the hardware".

Combined schedule #

Pending: a schedule that keeps every tier busy needs the sampling tier's real candidate arrival rate first.