Lessons · what went wrong

Everything that lied to us

Six ways this machine quietly reported the wrong number, what each one cost us, and the discipline we ended up building so it could not happen again. If you are benchmarking anything on your own hardware, this is the tab worth reading.

Every finding on this site came from a machine where a benchmark can be wrong in at least six ways without anything failing. No exception was thrown, no HTTP error returned, no log line printed. In each case the number looked entirely reasonable. That is the hazard: a broken measurement does not announce itself, it just quietly reads a bit high or a bit low, and you publish it.

Twelve ways to measure the wrong thing

Each of these cost real time here. They are ordered by how much damage they did.

1. Reasoning enabled by default. Hit five separate times across four different harnesses. Recent models emit reasoning tokens unless told otherwise, inflating token counts severalfold. The severe form: if the server runs a reasoning parser, that content is extracted into a separate response field, so a client expecting inline tags receives completely empty content with HTTP 200 and a full token count. One 225-case benchmark ran to completion scoring a model whose output it could not read. Client-side flags such as --thinking off are frequently silent no-ops. What works is sending chat_template_kwargs: {"enable_thinking": false} and then asserting the content is non-empty.
2. Your second GPU is not isolated. Running two benchmarks on two cards looks like free parallelism. The cards share PCIe, host memory bandwidth and CPU, and batch-1 decode is launch-latency sensitive. The same task measured 95.8 tok/s idle and 58.0 tok/s with the other card busy — a 39% error. It invalidated a full day of context-scaling results, and the correction is recorded in section 11 of the log rather than quietly patched.
3. Comparing across prompt lengths. Subtle, and it produced a wrong correction to a correct table. We “found” a 20% contention error that did not exist, by comparing a 46-token prompt against 1.1K-context runs. Speculative acceptance rises with context (0.42 → 0.66 for one drafter), so speed rises with it. The control settles it: with speculation off, prompt length changes decode speed by 0.6% — that is, not at all. Any speed comparison across prompt lengths is meaningless unless acceptance is reported too.
4. Reverse proxies truncate long prompts. nginx defaults client_max_body_size to 1 MB and returns 413 before the request reaches the server. A 64K-token prompt exceeds that. The failures present as model errors.
5. Generated code hangs the harness. One benchmark case stalled 68 minutes on a node process spinning at 99.9% CPU — model-written JavaScript with an infinite loop. Aider's benchmark has no per-test wall-clock kill. Anything running generated code unattended needs its own timeout.
6. Server-restart drift. Repeated measurements against one running server agree closely. Restart it and the whole set shifts — the same card and configuration gave 165.4, then 159.6 tok/s. Measurements taken either side of a restart are not the same experiment.
7. Benchmarking through a load balancer. A determinism harness pointed at the balancer instead of a specific replica, so two “identical” calls landed on different servers. bf16 duly appeared non-deterministic at 32K. It was not; the harness was asking two machines the same question. Pin comparisons to one replica — a load balancer is the correct production entry point and the wrong measurement entry point.
8. Running a probe alongside a benchmark. A divergence probe run concurrently with a benchmark on the same server produced a false self_consistent: false. Batch composition varies with what else is in flight, reduction order varies with it, and near-tie tokens flip. Nothing was broken and nothing was non-deterministic — the two jobs were simply sharing a batch. Serial execution fixed it. This is trap 2’s cousin: it is not only other GPUs that contaminate a measurement, but other requests on the same one.
9. Benchmarking a machine in a different shape than you deploy it. Our headline ladder — 167.8 tok/s for MTP, 212.1 for DFlash2 — was measured with the second replica stopped, because that isolates the configuration under test. The deployed stack runs two replicas. Running the same configuration with the neighbour resident measures ~149.6 tok/s: an 11% tax simply for the other model being loaded, before it serves a single request. We found it by accident, while checking whether raising the context default had cost speed — a control at the original settings reproduced 149.6 and cleared the context change of blame. Both figures are correct; they describe different machines. Publishing only the flattering one would have overstated what the deployment delivers by a ninth.
10. A diagnostic request that kills the server. Asking a production replica for prompt_logprobs — the per-token probabilities the whole long-context fidelity study depends on — OOM-killed the GPU 0 engine on the first request, and Docker restarted it under us. vLLM log-softmaxes the full 248K-entry vocabulary in fp32 for every scheduled prompt token; at gpu_util 0.96 there is roughly 250 MiB of slack, and the tensor is far larger than that. Nothing about the API says this. The fix is not a flag but a second machine shape: fidelity runs use a dedicated one-off container at gpu_util 0.92 with --max-num-batched-tokens 256, prefix caching off, one request in flight. The cost of that headroom is a lower ceiling — the bf16 reference tops out at 76.8K instead of 85K — which is itself a number you have to publish, because it defines the band in which a reference exists at all.
11. docker compose up -d <service> recreates its dependencies. The load balancer depends_on both replicas. Bringing up just the balancer while a one-off fidelity container held port 18021 failed on GPU 1 as expected — and silently recreated the healthy GPU 0 replica on the way. Three minutes of outage and every in-flight request lost, from a command that named one service and touched three. Use --no-deps for any single service whenever a test container is occupying a card.
12. A scorer that records its own exception as a zero. The depth probes against ExLlamaV3 came back 0 out of 8 on every retrieval, 0 characters copied, 0 cross-references, at every depth — a total collapse, exactly the shape you would expect if 3-bit weights had destroyed long-context ability. The model was fine. The scorer read a usage field that this server returns as null, raised an AttributeError on every row, and wrote the failure down as a score. The server’s own log showed HTTP 200 and sensible generations the whole time. This is the first trap in a new costume: a harness that cannot read its own results will confidently report a model that cannot answer. Score zero and score “could not score” must be different values, and any zero should be checked against the server’s log before it is believed.

Know your noise floor — it is three different numbers

“Noise” here is not one quantity, and conflating the three leads to both false positives and false confidence.

Source of variationMagnitudeWhat it means for you
Repeats against one live server0.1–2%Five repeats give tight error bars; more adds little
Across a server restart~4%Re-measure both arms after any restart, or the comparison is void
Between two identical GPUs4–6%Never compare a result on card 0 against one on card 1
Treat single-stream differences under about 5% as noise — including ours. Correctness is noisier still: two runs of the same 225-case suite at temperature 0, nominally deterministic, differed by 2 points in aggregate and 13 points on one language. Batch composition varies under concurrency, which changes reduction order and flips near-tie tokens. Aggregate scores resolve to roughly ±2 points; per-language scores only to about ±10.

The instrument can be the error, not the reading

Every trap above is a way of measuring the right quantity badly. This one is worse: measuring a quantity that was never the one in question, carefully, and publishing it.

We wanted to know whether a quantized KV cache changes what the model believes at depth. What we measured for a week was free-running greedy divergence: generate from the same prompt under bf16 and under fp8, count characters until the two outputs differ. It is a natural thing to measure and it is almost useless, for two reasons.

The replacement has three properties the old one lacked. Teacher-forced per-token NLL over a long document needs no reference model — the ground truth is the document, so the metric is defined at 200K exactly as at 8K. It does not compound, because teacher forcing pins the context and an error at one position cannot propagate. And where a reference does exist, the same run yields excess NLL against it, so the old band and the new one are on the same scale. Repeat runs came out bit-identical: a determinism floor of exactly zero, which means any excess at all is signal.

Above the reference ceiling the trick is to stop looking for ground truth and compare independent quantizations against each other. Two schemes that fail differently will disagree; two that are both faithful will agree. fp8 and KVarN agree to 0.005 nats on the model’s own text at 150K, which is the strongest statement available about a depth where nothing can be checked directly.

And then one of our own pre-registered criteria turned out to be confounded. We wrote the pass/fail thresholds down before running, which is the right discipline and is not sufficient. Criterion B said: no 8K bucket past 80K may exceed the 57–76K mean by more than 0.05 nats. On one document, fp8 and KVarN both breached it by +0.075 — while agreeing with each other to 0.005 nats. The document simply got harder at that point. An absolute threshold on a metric that also tracks content will fire on the content. We retired the criterion rather than reporting two false failures, and kept the reference-free comparison, which is immune to it because both arms read the same words.

What a 27B model does when you ask it for 200,000 tokens

The fidelity study needed documents the model could not have memorised, so we had it write three of them — a novel, an engineering design document and an essay sequence, at 200K tokens each, by chaining continuation requests. That turned into a study of its own, because a model asked for that much text does not fail by producing nonsense. It fails by winding down into one of four modes, each of which reads fine for a paragraph.

ModeWhat it looks likeWhy the obvious check misses it
ChantThe section ends in an accelerating run of one-line paragraphs — “A bridge to the stars. / A bridge to the unknown.”Every line is novel text; nothing repeats
StaccatoSentences collapse to four to six words and stay thereVocabulary stays rich; only the mean sentence length moves
AnaphoraEvery sentence in a paragraph opens the same way — “He thought about the X. He thought about the Y.”Lexically varied, so n-gram and distinct-word tests pass it
ReplayThe model reproduces the excerpt it was handed, at length, before carrying onThe replay starts thousands of tokens back, so “does the chunk start where the document ended” is false

All four are self-seeding: leave one in the document and the next continuation, which is handed that tail as context, does more of it. So detection alone is not enough — the trailing run has to be trimmed off before the text is kept, or the mode propagates. The detector that resulted rejects a chunk on internal n-gram repetition, distinct-word ratio, mean sentence length, one-line paragraph fraction, and overlap with earlier text, and separately trims chant and anaphora off the tail.

The rejection rate is a finding in itself. At the same temperature and top_p, the novel kept 167 of 225 rounds and the essay sequence 128 of 170 — roughly a quarter of all generations discarded — while the technical design document kept 47 of 48. Long-form fiction and free-form argument wind down; a document with real structure to fill in does not. If you are generating long text with this model, giving it a concrete next thing to specify is worth more than any sampling parameter.

The instrument

These lessons were consolidated into a benchmark framework, so that testing the next model is a config file rather than a re-derivation of method. Six axes, three tiers, one YAML per model.

AxisWhat it measuresTierTime
SpeedMean and peak decode tok/s across 11 task cells, plus draft acceptance, tokens per verify step, and per-draft-position acceptancecore~25 min
ThroughputAggregate and per-request tok/s at C = 1, 4, 16, 32, 64core~20 min
ContextMaximum prompt actually served; decode and TTFT slopes with correlationcore~1 h
CorrectnessAider polyglot: pass rates before and after test feedback, well-formed edit rate, per languagecore~2 h
AgenticAcceptance and decode rate against accumulated context across a real multi-turn buildfull~2 h
EfficiencyTokens per watt, peak VRAM, sampled during the other axescorefree

Acceptance sits on the speed axis deliberately: it is the causal variable behind most of the tok/s spread. The gap between 144 tok/s on coding and 105 on prose was an acceptance difference, not a hardware one. And correctness is the one axis a speculator change cannot move — speculative decoding is mathematically exact — which makes an unchanged score across speculators a useful self-test of the harness.

The fairness contract

Eleven conditions are hashed into a fingerprint attached to every result. The comparison tool refuses to place two cards side by side when their fingerprints differ, naming every field that diverged.

conditions.power_cap_w        250
conditions.single_tenant      true
conditions.thinking_disabled  true
conditions.prefix_caching     0
conditions.temperature        0
conditions.max_tokens         256
conditions.corpus_sha         eb13204dad3d3188
conditions.edit_format        diff
conditions.concurrency_levels [1, 4, 16, 32, 64]
conditions.endpoint_mode      pinned-replica
harness.framework             1.0.0
                              -> fingerprint cdf5261ea5eaca65
Why this is enforced in code rather than documented. When a run was deliberately mis-conditioned as a test, it reported 171 tok/s against the correct 144. Bad conditions look better. Nobody remembers three days later which flags a number came from, and the incorrect number is the more attractive one to keep.

The gate itself needed testing. An early version printed “untrustworthy cards”, then printed “comparable — identical fingerprint”, then exited zero — because the fingerprint covers conditions, not validity. A card measured against an endpoint returning empty content passed the strict gate cleanly. Testing a defence against the actual failure it defends against is not optional.

Silent failures, encoded as preflight checks

The six hazards above are executable checks rather than prose. preflight.sh fails on: reasoning detected active (probing for empty content or a stray reasoning field), client_max_body_size unset, a power cap above expectation, or missing measurement scripts. It warns on least_conn load balancing, which biases traffic toward the faster replica, and on foreign processes holding a GPU. A blocked run cannot produce a card unless forced, and a forced card is stamped untrustworthy.

Why none of this is comparable to a leaderboard

The figures on this site measure one deployment, not a model's capability. Four things differ from any published number, each moving results independently:

If a genuinely comparable number is ever wanted, the recipe is: the unquantized checkpoint, the reference harness at its exact commit, that harness's own default sampling parameters, speculation off, and the result reported separately as a reproduction attempt rather than mixed into this table. We did not do this, because the question here was how this deployment behaves — which is the question that determines what to actually run.

The card died twice, and the second one broke our explanation

GPU 0 fell off the PCIe bus twice, seven days apart, with an identical signature both times:

Aug 22 16:24:03  NVRM: Xid (PCI:0000:01:00): 79, GPU has fallen off the bus.
Aug 29 21:49:55  NVRM: Xid (PCI:0000:01:00): 79, GPU has fallen off the bus.
                 NVRM: Xid 154, GPU recovery action ... 0x1 (GPU Reset Required)
Correction (31 August). We attributed the first fault to a 12 V rail brownout and reported the 250 W cap as the fix, having re-run the trigger eight times without recurrence. The second fault happened with both cards verified at 250 W. The power explanation does not cover it, and we should not have presented it as settled.

There were no AER errors in either boot — no correctable or uncorrectable PCIe errors, nothing preceding the fault. It fails clean, without warning. And Xid 79 with no AER is consistent with either a power transient or a link fault, so these logs cannot separate the two, and we are not going to pretend otherwise.

What can be said precisely: a power cap bounds average board draw and does nothing about microsecond transients. And the workload present at the second fault, tensor parallelism, is uniquely bad in that respect — synchronised allreduce barriers make both cards peak simultaneously and repeatedly, where every other workload in this study has one card working while the other idles. So the cap being in place rules out sustained over-draw; it does not exonerate power.

The topology is the more useful finding

CardAttached toNegotiated width
GPU 0 01:00.0CPU root portx8
GPU 1 08:00.0PCH / chipsetx4

The two cards are wired completely differently. GPU 1 sits behind the chipset, so all cross-card traffic crosses the DMI link — and with P2P unavailable, NCCL was staging through host memory. Every allreduce ran GPU0 → RAM → DMI → PCH → GPU1 and back, over x8 and x4 links.

That independently re-explains the tensor-parallel performance result. The −29% single-stream and −62% throughput penalty is not a generic “PCIe without NVLink” finding, as we first assumed — it is an x4-behind-the-chipset finding, specific to this board.

Verdict: do not retry tensor parallelism on this machine. Three independent reasons. The topology makes it a poor fit on the merits. It is the only workload twice associated with a dead GPU, and the only one that both saturates the link and synchronises peak draw across both cards. And its payoff is now weakly motivated, since context can be pursued through the ~90K single-card ceiling instead. The real fix is hardware — moving GPU 1 to a CPU-attached slot — not configuration. Note that NCCL_P2P_DISABLE buys nothing here, because P2P was already unavailable.

A fix that worked, on a checkpoint not worth deploying

A community checkpoint (cyankiwi/Qwen3.8-27B-AWQ-INT4, group-32 and asymmetric) had failed to load, and we spent a while believing the group size was a vLLM limitation. It was not: vLLM’s Marlin kernel advertises MARLIN_SUPPORTED_GROUP_SIZES = [-1, 32, 64, 128]. The failure was our own preparation scripts, three of which hard-coded GROUP = 128 while vLLM sized the expected scale tensor from what the checkpoint declaredsize of tensor a (160) must match tensor b (40), which is 5120/32 against 5120/128.

The scripts now derive the group size from the checkpoint’s own config.json, and the fix is verified end to end: all three prep stages derived group 32, passed their round-trip gates, and the checkpoint served through the fast stack at 139.5 ±0.2 tok/s with 72.8% draft acceptance and coherent output. A second checkpoint assumption fell out on the way — the draft-vocabulary builder expected a model_extra_tensors.safetensors shard that only our own checkpoint ships, and now creates it when absent.

And the checkpoint is still the wrong one for this card. Its ignore list leaves all 48 GDN layers’ in_proj_a/in_proj_b in bf16, so the weights take 16.46 GiB against our AutoRound build’s 13.97 GiB, and group-32 scales are four times the size of group-128 scales. It could not even start at the 64K default — “4.68 GiB KV cache is needed, 2.9 GiB available, estimated maximum model length 38016”. A finer group size does not make a smaller file. The value of the exercise was not a new checkpoint; it was proving that the preparation pipeline is checkpoint-agnostic, which is what makes the next one cheap to try.

A benchmark that looped for ten hours, and a score that flattered the winner

Testing the Swift fine-tune against the base model with thinking on produced two traps worth writing down, one in the harness and one in the result.

The harness. Aider’s benchmark does not stream, and its request timeout is a module constant: 600 seconds. With thinking on, the base model writes 17,000–28,000 tokens for one reply on a hard exercise. At llama.cpp’s ~25 tok/s per slot that is twelve to nineteen minutes. Every such reply timed out client-side after the server had finished generating it, and Aider re-sent the identical prompt with exponential backoff: 0.2 s, 0.5 s … 4,096 s. Seventeen hours in, nine exercises were done, two of them had consumed ten hours each, and the server had generated three million tokens that nobody read. Nothing errored. The signature is in the usage log, not the harness output: the same prompt_tokens value sixty times in a row. The fix is one line (timeout: 7200 under extra_params), and the general rule is older than this project: when a slow engine meets a client default, the default wins silently.

The result. Swift scored 71.7% on LiveCodeBench against the base model’s 63.3%, and it would have been easy to report that as a better model. It is not. Of 44 answers across all arms that hit the 32,768-token cap, none passed; on the 40 problems where neither model was cut off the score is 37 to 37. The fine-tune changes how often the model finishes, not how well it solves. That is still worth having — an unfinished answer is worth nothing — but it is a different claim, and it predicts something the headline does not: raise the cap far enough and the accuracy gap should close while the token gap stays. Any benchmark of a thinking model under an output cap is partly a benchmark of the cap. Report truncations next to every pass rate.

Provenance

Conditions changed across five days as we learned what mattered. Two tables in the log can look directly comparable without being so; this is the key.

ConditionChanged whenEffectAffects
Cross-GPU contentionCampaigns run in parallel on both cardsup to −39%§11 (corrected), §17 long-context rows, ~6% of §10
250 W power capApplied after the Xid 79 fault~−14%Pre-cap: §10 depth sweep. §§12–20 all post-cap
Reasoning enabledOn by default until found in §15~5× tokensEvery token count before §15
Prompt corpusRandom filler → real prose, mid-§11largeAcceptance; never compare across the change
Prompt lengthVaries by campaign (46 tok → 192K)0.42→0.66 acceptThe error that produced a wrong correction
Pinned vs balancedPer campaign~4–6%§§13–14, 17 pinned; §§15, 18 balanced
KV cache dtypebf16 throughout until 30 Augchanges outputEvery quality figure before the KV-quant arms
PCIe topologyFixed property of the boardx8 vs x4All TP figures; not generalisable off this host
Which sections are clean. Sections 12, 13, 14, 15, 16, 18 and 20 are provably single-tenant. Section 11 is contended and carries a correction banner. Section 17's beyond-64K table inherits contended rows and is annotated per row. Section 10 is marginal — about 6% on one line, inside the noise floor, and needed no correction despite an earlier claim that it did.

The framework, its specification and the fairness contract live alongside the deployment configs. See Setup to reproduce the stack, Results for the measurements, and the Log for the primary record including every correction.