I blamed OpenCode; a 2-bit model passed Zig in it
Contents
Assumes you've driven a coding agent against a local llama.cpp server and know what MoE and a Q2 quant are.
OpenCode has two features that look bad in a Zig agent run at 64k context.
The first is auto-compaction. When the conversation fills the window, OpenCode summarizes it and carries on from the summary. OpenCode's config docs describe the default as compacting "when context is full". On a Zig task, that window fills with compiler errors, test files and stress-test output, and a summary can only keep so much of it.
The second is the sandbox. The bench runs OpenCode with external directories denied, and the Zig standard library source, the one reliable reference for Zig 0.16's churned APIs, lives outside the workspace. A model that goes looking for it gets refusals.
The charge sheet was long. Four 27B dense models, a Zig ledger task (add idempotent, atomic, concurrency-safe transfers to an existing service, then pass 15 hidden core tests), 64k context, one hour per run. Across regular and test-first prompts, 4 of 24 runs came out correct. Thirteen hit the one-hour wall. Meanwhile a much larger model, Qwen3.8-Flash-Next, passed the same Zig task under a different client, pi.
On the morning of 11 October that went into my notes as a finding. The 27Bs might be weak at Zig, but OpenCode was making it worse: the summaries lost the thread, and the sandbox kept the reference manual out of reach. Flash-Next's OpenCode runs were queued for later that day, and they would settle it.
They settled it against me.
The case against OpenCode: compaction and a locked standard library#
Both exhibits are real. OpenCode records every compaction in its own session database, and the refusals are printed in each run log.
- Compaction: the 24 Zig runs of the 27Bs at 64k compacted between 4 and 12 times each.
- Sandbox: across those 24 runs, OpenCode refused 25 attempts on the std path. In one run, qwen38pi was refused three times, and its later compaction summaries carry the conclusion forward as a standing fact: external directory access is blocked, rely on compiler output.
Neither of those is imaginary, and OpenCode's permission system has form. On an earlier box, OpenCode's read permission auto-rejected every file read for a whole benchmark history before anyone noticed. Suspecting the client again was not paranoia. It was pattern-matching.
Same harness, same sandbox, different model#
The midday arms ran Flash-Next through OpenCode with nothing else changed on the client side: OpenCode 1.18.4, the same per-run config with external directories denied, 64k context, one-hour limit, the same task prompts. Four lines from that arm's run.log:
== run-strata-bench q2_0 key=flashnext-q2_0-strata tag=strataoc1 scen=[t14-ledger-go t15-ledger-zig] reps=3 harnesses=[opencode] ctx=65536 mtp=1 2026-10-11T12:12:37+03:00
/props: model_alias=flashnext-q2_0-strata n_ctx=65536 build=Strata 0.1.34
[2026-10-11 12:13:35] ===== queue start: flashnext-q2_0-strata@strataoc1@EXTERNAL_SERVER=1,EXTERNAL_CTX=65536,EXTERNAL_ENGINE=strata,HARNESSES=opencode,SCEN=t14-ledger-go t15-ledger-zig,REPS=3
[2026-10-11 13:06:08] ===== queue done
Six runs, Go and Zig, between 12:13 and 13:06. The Zig rows, next to everything else at the same budget:
| Model, client, prompt | Context / limit | Correct | Wall per run | Compactions per run |
|---|---|---|---|---|
| Flash-Next, OpenCode, regular | 64k / 1 h | 3/3, all PASS 100 | 614–639 s | 2–3 |
| Flash-Next, OpenCode, test-first | 64k / 1 h | 3/3 (99, 99, 100) | 775–992 s | 3–4 |
| Flash-Next, pi, regular | 64k / 1 h | 3/3, all PASS 100 | 843–1006 s | n/a |
| Flash-Next, pi, test-first | 64k / 1 h | 2/3 | 1258–3602 s | n/a |
| Four 27Bs, OpenCode, regular | 64k / 1 h | 2/12 | 1086–3601 s | 4–11 |
| Four 27Bs, OpenCode, test-first | 64k / 1 h | 2/12 | 2036–3601 s | 6–12 |
"Correct" is the bench's PASS or PARTIAL: every hidden core test green, with points lost only on detail checks such as docs. Two 27B runs also passed all 15 core tests but ran out the hour and scored FAIL. Every Flash-Next OpenCode Zig run passed all 15 hidden core tests, in a little over ten minutes for the regular prompt.
The one Flash-Next Zig miss came in pi, not OpenCode. Its own stress test deadlocked, the run hit the hour, and the scorer skipped the mutation check because the implementation deadlocked under the hidden concurrency tests too. If a client deserved suspicion on these numbers, it was the other one. With three runs per cell, neither deserves it.
The locked door was mostly open#
Flash-Next was not spared the sandbox. In its third regular OpenCode run it went for the standard library too (log lines cut at 150 columns):
✗ cd /var/home/linuxbrew/.linuxbrew/Cellar/zig/0.16.0_1/lib/zig/std && rg -n "pub const StringHashMapUnmanaged|pub const StringHashMap\b" hash.zig | h
Error: The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules [{"permission":"*","ac
✗ cd /var/home/linuxbrew/.linuxbrew/Cellar/zig/0.16.0_1/lib/zig/std && rg -n "pub const Mutex|pub fn lockUncancelable|pub fn unlock|pub const init" io
Error: The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules [{"permission":"*","ac
$ mkdir -p /tmp/opencode/scratch && cd /tmp/opencode/scratch && cat > probe.zig <<'EOF'
Two refusals, then a scratch file in /tmp, which the per-run config allows. The compiler became the documentation. The first probe failed because std.testing.allocator refuses to exist outside a test; the second, rewritten as a zig test, taught it that Zig won't compare structs with ==. Then more probes. Then the transfer code, and a PASS 100.
That run is the exception, though. Counting every std access in the logs gave the second surprise of the day: the sandbox barely locked anything. OpenCode refused its own Read and Grep tools on the std path, and shell commands that cd into it. A plain grep or sed with the std path as an argument went straight through.
| Runs (OpenCode, 64k) | std accesses executed | refused |
|---|---|---|
| Flash-Next, 6 Zig runs | 0 to 14 per run (35 total) | 0 to 2 per run (5 total) |
| Four 27Bs, 24 Zig runs | 0 to 39 per run (397 total) | 0 to 4 per run (25 total) |
Twenty-three of the 24 27B runs read the standard library, between 7 and 39 times each. qwen38pi, the run that wrote "access is blocked" into its summaries, had already read hash_map.zig through the shell several hundred log lines earlier. The one Flash-Next run that never got through is the one shown above, and it passed anyway.
It compacted too, two to four times per run, and still finished. Compaction and the sandbox were the weather. Both models were out in it, and the 27Bs had the better umbrella.
How a 2-bit MoE fits a 3090 and 32 GB of RAM#
Flash-Next on this box is a GSQ-RCO GGUF from ISTA-DASLab, Q2_0 quant. Its model card describes "512 routed experts per layer across 48 layers, of which 10 are active per token". The box has a 24 GB RTX 3090 on PCIe 3.0, a Ryzen 5 5600G without AVX-512, and 31.2 GiB of RAM. Stock llama.cpp runs it with --n-cpu-moe 22, which leaves the experts of 22 layers in system RAM.
The engine that made it usable is Strata, which keeps an expert cache on the GPU and the remaining experts in RAM. Same Q2_0 weights (Strata runs a pack built from the GGUF), 128k context, same measurement client, both runs on 8 October at the card's old 320 W cap. llama.cpp first:
=== llamacpp-q2_0-c131072-q8_0-n22: summary (prefill/decode = server timings when present, else client-side) ===
size ok ret cTTFT cPre cDec wTTFT wPre wDec RAMmax AvMin Swap PSIm VRAM majf/s
4000 3/3 3/3 54.02 73.8 9.4 11.96 333.5 10.2 3.5 27.8 1.2 21.0 23.5 5321.7
32000 3/3 3/3 94.95 336.4 9.8 89.64 357.6 9.7 3.2 28.0 1.1 0.3 23.5 408.0
100000 3/3 3/3 297.50 336.5 8.6 295.16 339.2 8.5 3.3 28.0 1.1 0.0 23.5 111.4
(c = cold run 1, w = median of warm runs; RAM/Avail/Swap/VRAM in GiB; PSIm = max memory full avg10)
Strata:
=== strata-q2_0-ctx131072-int8-mtp1-auto: summary (prefill/decode = server timings when present, else client-side) ===
size ok ret cTTFT cPre cDec wTTFT wPre wDec RAMmax AvMin Swap PSIm VRAM majf/s
4000 3/3 3/3 3.03 1321.5 90.0 2.37 1686.0 99.8 23.3 7.9 1.4 5.0 23.3 1204.4
32000 3/3 3/3 13.61 2356.9 93.2 13.63 2362.3 96.0 23.5 7.8 1.4 0.2 23.3 84.4
100000 3/3 3/3 43.93 2287.8 92.6 43.88 2289.9 90.2 24.0 7.2 1.4 0.0 23.3 6.4
(c = cold run 1, w = median of warm runs; RAM/Avail/Swap/VRAM in GiB; PSIm = max memory full avg10)
Decode goes from 10.2 to 99.8 tok/s at 4k and from 8.5 to 90.2 at 100k. Time to first token on a 100k prompt drops from 295 s to 44 s.
That decode ratio needs its asterisk attached. Strata decodes with an MTP draft layer it builds from the original checkpoint. These GGUFs carry no MTP tensors, so llama.cpp decodes without a draft. The fairness arm, Strata with MTP off, never ran. The engine refused the Coder pack without a draft:
strata generate: /data/packs/coder-iq1_m is a native (IQ) pack: it needs --native SHARD1, --spec T (T >= 2) and --prefill CHUNK
So there is no no-draft Strata number on this box. The prefill gap, about 6.8× at 100k, has nothing to do with drafting.
Strata's own README says a 3090 "should write roughly 100-140 tokens per second". The table behind that, in its DETAILS.md, is headed "Other GPUs (estimated)", assumes 64 GB of RAM and says to treat it as ±20%. Measured here with 32 GB: 99.8 at 4k against an estimate of ~140, outside the band; 90.2 at 100k against ~100 at 128K, inside it. The prompt estimates predate a release that, per the same page, about doubled prompt speed on the measured card, with the gain depending on each card's PCIe link. They come in at 22 to 29 percent of what this box measured.
The bill: nearly all the RAM, page-locked#
Strata picks a low-RAM resident mode on this box: setup's formula gives the GPU about 17.7 of the 34 GB of Q2_0 experts, and the rest gets copied once into pinned host memory. The launcher had a complaint about that before the engine even started:
WARNING: hard RLIMIT_MEMLOCK is 2147484 KiB; rootless podman cannot grant memlock=-1:-1, using memlock=2199023616:2199023616.
The user's memlock hard limit is 2 GiB, and rootless podman cannot raise it. The engine pinned the experts anyway. Three lines from its log, for the 128k start:
FileExpertSource: allocating 18.91 GiB page-locked cache complement
FileExpertSource: mapped pinned cache complement ready: resident 18.91 GiB, pinned 18.91 GiB; page-locked and mapped
strata generate: resident RAM mode: 18.91 GiB of experts in RAM (page-locked), 12674 in the GPU cache; adaptive swaps exchange them with the GPU cache (no file reads)
That's 18.91 GiB page-locked under a 2 GiB lock limit. The kit's README had pointed at the likely route while calling the impact unknown: the large buffers go through cudaHostAlloc and cudaHostRegister in the NVIDIA driver, and mlock is only a fallback. On the 64k agent runs the figure was 17.95 GiB.
What it costs the rest of the machine shows in the AvMin column above: 7.2 GiB of RAM left at 100k. The container's memory peak was 29,694 MiB on a 31.2 GiB box. It is experimental here for that reason. The weights aren't even kept on disk between runs: they get fetched and packed when needed.
What Flash-Next clears, and what it doesn't#
The experiment clears the client. Same OpenCode version, same config, same sandbox, same context, same hour. Swap the model (and the engine under it) and Zig goes from 4 of 24 to 6 of 6.
It does not make a fair model comparison, and four things stay attached to the result:
- Flash-Next is not a peer of the 27Bs. It is a far larger mixture-of-experts model, and in the agent runs it decoded at a median of about 103 tok/s per request (with its MTP draft, at 280 W) against roughly 35 for the 27Bs on llama.cpp, without one. A model three times faster gets more turns out of the same hour.
- One model, three runs per cell. That is evidence, not a ranking.
- OpenCode carried my own config. Found after these runs: OpenCode's Claude Code compatibility put my global
~/.claude/CLAUDE.mdand my Claude skills into every bench session; pi never had them. OpenCode's session database counts each request in real tokens. Flash-Next's first request, system prompt plus tool definitions plus the task, came to 11,884 tokens on the regular prompt and 12,045 on test-first; the 27B 64k arms in the table ran from 11,898 to 12,105. So every 64k OpenCode arm in the table carried about the same prompt. Zig runs without my config, from 15 October on, come to 8,422 to 8,585 tokens, which puts my share at roughly 3k to 4k. My~/.claudegrew after Flash-Next ran: a capture on 14 October measured 53,483 characters of system prompt with it and 10,785 without (OPENCODE_DISABLE_CLAUDE_CODE=1). That doesn't move the verdict, but it means Flash-Next passed in OpenCode with less room than pi gave it. - The 27Bs aren't incapable either. The next queue reran their test-first Zig prompt at 128k context with a four-hour safety limit. All 12 runs came out correct, 8 of them inside the old hour, compacting zero to three times each.
The client was the one variable that never moved. The model moved, and Zig passed. The context moved, and the same 27Bs passed, most of them inside the old hour. The suspicion lasted from just before 9 to a little after 14:00 that day. The explanation left standing is less satisfying and more useful: on the test-first prompt at least, 64k was too small for the 27Bs, and OpenCode was only the place they ran out of room.
Since then: greedy decoding, and a sampled rerun (23 October)#
One condition in the Flash-Next rows above was not what I assumed. Strata takes its sampler from a sampling block in its server config, and the config these runs used had none. Without one, the engine decodes greedily, and every Flash-Next row before 19 October decoded that way, including the OpenCode and pi rows in the table. The 27Bs on llama.cpp sampled with their model cards' settings on the server, except that OpenCode 1.18.4 overrode top_p to 1 for the two ids containing "qwen" (the correction in the CLAUDE.md post has that one).
So the Flash-Next quality numbers in this post are not comparable with the sampled models'. The decode speed was measured greedily too, and MTP acceptance depends on the sampler; sampled, the reruns below decoded at medians of about 93 to 98 tok/s. The client verdict survives it: under the same OpenCode, one model passed Zig, whatever it was sampling with.
On 22 and 23 October Flash-Next ran again on Strata with the model card's thinking-mode sampler (temperature 1.0, top_p 0.95, top_k 20) and MTP on, with my ~/.claude kept out of OpenCode:
- Short dev and scripting tasks (t7–t12), OpenCode: 18 of 18.
- Hard tier (Java, Go and Spring Boot ledger tasks), OpenCode at 64k: 7 of 9.
- This post's Zig task, OpenCode at 128k with a four-hour limit: 2 of 3 passed outright, and the third was PARTIAL: all 15 hidden core tests green, one detail check on blank keys missed. By this post's definition of correct, 3 of 3. Different context and limit, so not the cell in the table above.
- Operator tasks in pi, one run each at 128k: 6 of 7.
How that compares with the 27B tunes and the fast 30B-class MoEs on the hard tier is in The hard tier sorted by a rollback, not by speed.
Filed under: local-llm, llm, ai-agents, benchmarks, zig
Sources#
- Strata — the expert-offload engine, commit 1678de3 used here; its README has the 3090 speed claim, and docs/DETAILS.md has the "Other GPUs (estimated)" table.
- Qwen3.8-Flash-Next GSQ-RCO GGUF — model card: expert counts, the Q2_0 files, its note on Strata.
- OpenCode config: Compaction — the
compaction.autodefault. - OpenCode — the client, version 1.18.4 in these runs.