feat: raise the Gemma 4 server context ceiling to 256K, with measured admission limits - #156
feat: raise the Gemma 4 server context ceiling to 256K, with measured admission limits#156ulises-c wants to merge 63 commits into
Conversation
- ArchConfig: ModelFamily (gemma4/qwen36), LinearAttentionConfig, qwen36_35B_A3B baseline, layer-mask value 2 for gated-DeltaNet linear attention layers - ManifestReader: optional family-extension arch fields, family peek for auto-detection, arch-registry production sniff - Model: family-aware tensor accessors (router, shared expert, scalar gate, linear_attn.*), untied lm_head, auto-detecting load overload - KVCacheManager: LayerKind.linear (no per-token KV); GDNStateManager for fp32 delta state + conv tails - Sampler/logit path: finalLogitSoftcap plumbed through, softcap <= 0 disables - New gdn.metal module: causal depthwise conv + SiLU (decode/prefill/tail), per-head q/k norm with folded delta scales, gated delta rule recurrence (decode + chunked prefill), gated output norm; validated against a CPU reference incl. prefill == sequential decode equivalence - rope_neox_subdim (decode + prefill): Qwen partial-rotary convention - silu activation function constants for routed MoE, prefill MoE, INT8 shared expert; silu_mul_fp16; sigmoid gate/scalar and residual-add elementwise kernels; MoE/SharedExpert/PrefillGroupedRoutedMoE wrappers parameterized
…source)
- SupportedModelSource: selectable gemma4/qwen36 pinned sources with
per-source modelID, download/installed byte estimates
- SourceFingerprint: qwen index sha -> qwen3.6-35b-a3b-4bit
- CLI: --model {gemma4|qwen36} selector (default gemma4)
- ArchInfo: qwen3_5_moe parse path (layer mask 2=linear/1=full, GDN dims,
attn output gate, silu, untied head) + production baseline cross-check
- RepackPlanner: per-family tensor classification (.mlp.switch_mlp routed
experts) and resident ordering (embed first, lm_head last)
- GTurboJSON/RemoteStreamingRepacker: qwen36 family arch fields in
manifest.json (gemma output unchanged), bit-width sniffing for qwen names
- SyntheticSnapshot.buildQwen + planner/CLI/end-to-end install tests
…ecoder support
- Rename GemmaToolCallParserError to neutral ToolCallParserError with a
back-compat typealias
- Resolve ChatDialect (gemma/chatml) at GFTokenizer init by sniffing the
<|im_end|> special token; chatml requires the nine ChatML specials by
string, gemma keeps its ten-token contract
- ChatML: no BOS (encode addBOS is a no-op), stop tokens {im_end,
endoftext}, padded logits vocab 248320, hand-rolled ChatML chat template
with enable_thinking=false generation prompt, text continuation bridge
- encodeToolResultContinuation throws unsupportedForDialect for chatml so
the server prompt cache falls back to prefix matching
- New QwenToolCallParser for the <function=/<parameter= XML-ish body with
JSON/string value inference, fail-closed tool allow-list, 256 KiB guard
- StructuredAssistantDecoder chatml mode: <think> suppression and
<tool_call> buffering via special-token IDs
- Synthetic ChatML tokenizer fixture + template/parser/decoder test suites
Gemma's tokenizer maps unknown strings to <unk> instead of nil, so probing for <|im_end|> misclassified Gemma as ChatML. Verify resolution by round-tripping the ID back to the token string.
…t + model-id) - AppModelInstallDescriptor.qwen36 (pinned repo/revision/index sha, byte estimates from the range plan) + TURBO_FIELDFARE_MODEL=qwen36 selection; install dirs gemma4.gturbo / qwen36.gturbo; badge shows descriptor name - AppModelInstallationProbe validates against the manifest's own family - Server: ChatDialect threaded into request validation (Gemma DSL key/args checks relaxed for ChatML), --model-id default derived from the loaded model family, backend exposes chatDialect/defaultModelID - CLI usage banner covers both models
…refill Decode: three-way per-layer dispatch (gated-DeltaNet linear / gated full attention with NeoX sub-dim RoPE / existing Gemma path), plain pre-norm post-attention block with single moeX norm, sigmoid-gated shared expert, phase-2 residual fusing the shared branch, residual-add tail, untied lm_head. Prefill: chunked GDN path (conv + tail carry + delta recurrence), packed q_proj split, prefill_rope_neox_subdim_block Swift encode, no V norm, gate multiply, ones router scale buffers, residual-add tail; linear layers skip KV staging/blit/attention. Init: family-neutral kernel construction (SiLU MoE/shared/grouped, specialized dims from cfg), GDN + state manager + Elementwise + scalar-gate GEMV gated on data flags, ones effective/per-expert scale buffers when routerScaled is false, embed outScale and attention scale from config. Gemma path byte-identical; all 134 existing tests pass.
QwenToySynthetic writes a tiny runnable .gturbo with the qwen36 tensor contract (linear_attn.* GDN tensors, packed [query;gate] q_proj, mlp.gate router, gated shared expert, untied lm_head, arch-v2 manifest, no sandwich tensors). Tests: runner init without touching sandwich tensors, hybrid linear/full decode smoke with reset determinism, chunked prefill smoke + decode continuation, prefill-vs-decode argmax consistency (KV + GDN state carry), KV/GDN-state reset interplay, prefill scratch layout sizing for qwen vs unchanged gemma.
…phase counters Specialization measured on M5: unspecialized 4096x2048 INT4 GEMV runs at 101.6 GB/s, the constant-folded variant at 141.0 GB/s. Shapes now come from ArchConfig rather than a hardcoded Gemma list, and the LM head specializes on the loaded model's D/vocab instead of 2816/262144. Also adds --expert-cache-slots, --rdadvise, and TURBO_FIELDFARE_PHASES=1 decode phase reporting so the I/O-vs-GPU split is measurable.
The probe now derives the expected checkpoint from the family the manifest declares when the caller does not pin a descriptor, so a Gemma install is still recognized while Qwen is the selected model (and vice versa). The post-install verification still pins to the descriptor that was downloaded. Also covers the two new CLI options in the help-options test.
Decode issued four separate INT4 GEMVs per gated-DeltaNet layer, all reading the same hidden vector: in_proj_qkv (8192 rows), in_proj_z (4096), in_proj_a (32) and in_proj_b (32). The last two were four threadgroups apiece — near-empty launches. gdn_in_proj_gemv_simd dispatches over the concatenated 12352-row space (1544 threadgroups at 8 rows/TG) and routes each row to its own weight/scale/bias base and output buffer with a 4-way compare, reusing dequant_int4_gemv_simd_body verbatim. The per-row math and operand order are unchanged, so the result is bit-identical to the four dispatches; greedy decode output on scratch/qwen36.gturbo is byte-for-byte the same. The body's ushort-pair weight loads are kept: packed sub-tensor offsets are only 2-byte aligned. Qwen-only (the GDN path); Gemma 4 is untouched. No new buffers. Measured (M5, 30 layers = 1 decode token, median GPU time): separate x4 3.66 ms -> fused x1 3.42 ms (-6.6%) That stage is ~3.4 ms of a ~59 ms/token decode, so end-to-end tok/s does not move outside run-to-run noise (13.8-18.2 tok/s on an idle baseline). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…che residency + slot coverage)
CONTRIBUTING requires public runtime controls to appear in docs/RUNTIME_CONTROLS.md; --expert-cache-slots and --rdadvise now do, along with the TURBO_FIELDFARE_PHASES diagnostic. SYSTEM_DESIGN's scope section described a Gemma-only runtime while the branch also ships Qwen 3.6, and now states what that support covers and what it has not been validated against.
Pinning 16 GB of a 24 GB host to leave an ~8 GB working set changes neither throughput (19.7 vs 19.8 tok/s) nor footprint (1.52 vs 1.49 GB peak). The 18.1 GB expert pool never fit the page cache on either configuration, so decode already streams from SSD and constraining memory does not change what it reads. Every resident component is equal to or smaller than the Gemma path's: expert slots 1.13 GB vs 1.61 GB, KV at 4K 84 MB vs 320 MB. Only the on-disk install is larger (19.6 GB vs 14.3 GB). Replaces the earlier claim that the 8 GB configuration was unvalidated.
Ran the three frozen real-generation-v1 prompts with their fixed seeds, app sampling defaults, 4K context and 16 slots — one discarded warmup then one measured run per case in a fresh process. Every measured footer reported stop=endOfTurn. short-explanation 62/493 23.05 tok/s 1,447 MiB footprint medium-review 426/697 21.20 tok/s 1,448 MiB long-synthesis 2,940/700 18.84 tok/s 1,464 MiB Rerunning the short case with 16 GB pinned elsewhere (~8 GB working set) gave 23.36 tok/s at 1,448 MiB with byte-identical output. The CLI footer now also reports prefill seconds, which the protocol's report format asks for.
Qwen 3.6 is presented alongside Gemma 4 rather than as an experiment: the README at-a-glance covers both models' memory, storage and measured decode, and SYSTEM_DESIGN's scope section describes two explicitly enumerated architectures, each with its own pinned checkpoint, compile-time baseline and manifest contract. Completes the 8 GB validation across all three community benchmark cases with 16 GB pinned elsewhere. Every case reported stop=endOfTurn and produced output byte-identical to its unconstrained counterpart: short-explanation 23.05 -> 22.95 tok/s 1,447 -> 1,464 MiB medium-review 21.20 -> 21.35 tok/s 1,448 -> 1,448 MiB long-synthesis 18.84 -> 18.62 tok/s 1,464 -> 1,388 MiB Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The comparison quoted 41.5 tok/s for Gemma 4 from an app HUD reading rather than the repository's own published 31-35 tok/s protocol rows, and derived per-model read bandwidths from an assumed 100% expert cache-miss rate that was never instrumented. Replaced with what is measured — Qwen reads fewer expert bytes per token than Gemma, has half the slot coverage, and its throughput is page-cache independent (repeated runs do not warm up: 21.4, 22.1, 20.8 tok/s; an ~8 GB working set does not slow it down) — and labels the page-cache explanation for the gap as an untested hypothesis, since Gemma was never run under the same protocol on this host. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ulises-c
left a comment
There was a problem hiding this comment.
Verdict: Changes Requested — 3 blocking, 5 warnings.
(Posted as a COMMENT review: GitHub does not allow requesting changes on one's own PR.)
Adversarial review — not ready to merge
Reviewed at merged HEAD 40428a5. Rebuilt release (Build complete!), reproduced both cited suites (ServerArgumentTests 11 passed, ServerLogTests 2 passed), independently re-derived the KV memory model from KVCacheManager.swift, and ran a needle-in-a-haystack retrieval probe.
The engineering is clean and Scope and limitations is honestly written. But the title claims a capability the evidence does not establish, and the headline numbers now predate the code this PR ships.
Blocking
1. The ladder proves admission, not comprehension.
ArchConfig.gemma4_26B_A4B sets slidingWindow: 1024 with fullAttentionLayerMask = layers 5/11/17/23/29. 25 of 30 layers see only 1024 tokens; 5 full-attention layers carry all long-range signal. A "word " * N prompt with max_completion_tokens=1 cannot distinguish a working 256K context from one where those 5 layers degrade into noise — both return HTTP 200 finish=length.
I verified retrieval genuinely works below the ladder with a needle probe:
| Context | Prompt tokens | Depths | Result |
|---|---|---|---|
| 4K | 1,543 | 0.1 / 0.5 / 0.9 | 3/3 recalled |
| 16K | 14,043 | 0.1 / 0.5 / 0.9 | 3/3 recalled |
| 57K–254K | — | — | untested |
14K already clears the window by 13x, so the full-attention path works. The untested range is exactly the range this PR adds.
2. Every timing number predates the merged code.
Rungs were measured 08-24/08-25 from c4f9442/4b3bd87. 40428a5 then merged #159 "Speed up prefill on pre-Apple10 Macs", which rewrote PrefillAttention.swift pipeline selection (new layerKind == .full + windowNeverClips guards) and RealForwardRunner prefill command buffers. The table's pp, pp_tok_s, the 242.983 -> 132.276 decline and the R^2=0.996 fit all describe a different prefill implementation. #159's own message states the prior TensorOps path "produced wrong output" for clipping windows.
3. The 64K baseline is not a matched control.
Run order: 96K/128K/192K (08-24 16:00-16:33) -> 256K (08-25 13:18) -> 64K last (08-25 14:55), spanning at least two source commits. A baseline run after the experiment on a different build is not a baseline.
Warnings
4. --prefill off --max-context 262144 is an unguarded OOM. Derived from KVCacheManager.swift:79-101, KV = 25 x 1152 x 4096 x 2 + 5 x ctx x 2048 x 2. This predicts every observed Metal peak to a constant +32 MB across a 4.4x span:
| Context | Predicted | Observed | delta |
|---|---|---|---|
| 64K | 1,505 MB | 1,537 MB | +32 |
| 96K | 2,145 MB | 2,177 MB | +32 |
| 128K | 2,785 MB | 2,817 MB | +32 |
| 192K | 4,065 MB | 4,097 MB | +32 |
| 256K | 5,345 MB | 5,377 MB | +32 |
256K only fits because the FP16 ring caps SWA layers at 1,152 tokens each. --prefill off disables that ring -> all 30 layers allocate at maxContext -> ~55 GB at 256K on a 36 GB machine, accepted with zero validation.
5. The harness cannot be run by any reviewer. MODEL is hardcoded to a different checkout, PORT is fixed, and benchmark-results/ is gitignored — so none of the cited raw artifacts are in the PR.
6. "Server-side context-capability validation" is overstated. ServerArguments only widens an integer allowlist; nothing validates the model supports 256K (no RoPE-scaling or manifest check).
7. Prompt cache untested at long context. Every rung reports cached=0, yet single-prefix is the default and is the real agent workload.
8. Docs recommend 262144 as the routine client budget — measured cost ~32 min of prefill per request. A routine budget should sit materially below the measured edge.
Good
- Memory instrumentation is well designed — it is what let me validate the KV model independently.
- Zero swap and no OOM across five rungs is a real, credible result.
completedMessageextracted as a pure function for testability is the right call.- Uniform 8,192-token reserve across rungs is a correct control.
To make this mergeable
- Run needle retrieval at 64K/128K/256K, depths 0.1/0.5/0.9 — this is the missing evidence.
- Re-run the ladder on merged HEAD, all rungs same binary, baseline first.
- Guard
--prefill offabove ~64K, with a test. - Retitle toward what is proven, or hold the title until (1) lands.
- Make the harness portable; commit or un-ignore the artifacts.
- Walk doc examples back to a conservative budget; fix the CLI validation claim; drop unused
os/selectimports.
Adversarial review by Hermes Agent. Build/test/memory-model claims above were reproduced locally; the 57K+ retrieval gap is stated as untested rather than assumed.
| from pathlib import Path | ||
|
|
||
| ROOT = Path(__file__).resolve().parents[1] | ||
| MODEL = Path("/Users/ulises/github/turbo-fieldfare/scratch/gemma4.gturbo") |
There was a problem hiding this comment.
Blocking (harness portability). MODEL is hardcoded to /Users/ulises/github/turbo-fieldfare/scratch/gemma4.gturbo — a different checkout from the one this PR lives in. No reviewer can run this, and it silently couples the harness to one machine's layout.
Combined with benchmark-results/ being gitignored (.gitignore:43), a reviewer gets a script that cannot run and cannot see the data it produced.
Suggest argparse with --model / --port, defaulting to ROOT / "scratch/gemma4.gturbo" and falling back to an env var.
| "model": MODEL_ID, | ||
| "messages": [{"role": "user", "content": text}], | ||
| "temperature": 0, | ||
| "max_completion_tokens": 1, |
There was a problem hiding this comment.
Blocking (this is the core issue). max_completion_tokens=1 combined with the "word " * words prompt at line 134 means a level passes if the server admits and prefills the prompt — never that the model can use it.
Gemma 4 runs slidingWindow: 1024 on 25 of 30 layers; only layers 5/11/17/23/29 are full-attention. So a 254K context where those 5 layers degrade into noise returns HTTP 200 finish=length — identical to a healthy run.
I confirmed retrieval works at 1,543 and 14,043 prompt tokens (needle recalled at depths 0.1/0.5/0.9), so the mechanism is sound below the ladder. The untested band is 57K-254K, which is precisely what this PR adds.
Suggest a second pass: plant a fact at fractional depth in the filler and ask for it back with a real multi-token completion.
| guard let parsed = Int(value), | ||
| [4_096, 8_192, 16_384, 32_768, 65_536].contains(parsed) else { | ||
| [4_096, 8_192, 16_384, 32_768, 65_536, 98_304, 131_072, | ||
| 196_608, 262_144] |
There was a problem hiding this comment.
Warning (unguarded OOM introduced by this change). This widens the allowlist to 262144, but --prefill off remains independently settable.
From KVCacheManager.swift:79-101, the ring only caps SWA layers when fp16RingEnabled; with prefill off, all 30 layers allocate at maxContext:
- ring on @ 256K: ~5.2 GB (matches the observed 5,377 MB peak to +32 MB)
- ring off @ 256K: ~55 GB — unallocatable on the 36 GB test machine
Before this PR the cap was 65536, which bounded the damage. Now the combination is reachable and accepted with no validation. Suggest rejecting --prefill off above ~64K in validate(), with a test.
| duration: .seconds(676), | ||
| completion: completion) | ||
|
|
||
| #expect(line.contains("pp=675.595s")) |
There was a problem hiding this comment.
Fine as a formatting unit test, but worth noting it asserts values no real run produces: decodeSeconds: 0.250 -> tg_tok_s=4.000, whereas every actual ladder rung logs tg=0.001s with tg_tok_s of 785-1094.
The PR describes this as covering "prefill/decode timing" behavior; it covers string formatting of hypothetical values. The pp_tok_s cached-token subtraction path (ServerLog.swift:32-35) is never exercised with cached > 0 — and every ladder rung reports cached=0, so prompt-cache behavior at long context is untested in both the suite and the benchmark.
| --model scratch/gemma4.gturbo \ | ||
| --port 8080 \ | ||
| --max-context 16384 | ||
| --max-context 262144 |
There was a problem hiding this comment.
Warning. This flips the primary launch example from 16384 to 262144, and lines 115/153 do the same for the OpenCode and Pi client configs — making 256K the recommended default for every reader.
Measured cost of one such request on the test machine is 1,920s (~32 min) of prefill. Per the project's own methodology, a routine budget should sit materially below the measured edge, not at it.
Suggest keeping the example at a practical value and documenting 262144 as the tested maximum with its latency cost stated inline.
| | --- | --- | --- | --- | --- | | ||
| | Maximum response | Automatic | `--max-new` | App: remaining context; CLI: 1,024 tokens | The app can use the context space left after formatting the prompt. The CLI uses its explicit or default `--max-new` limit. | | ||
| | Maximum context | 4K, 8K, 16K, 32K, 64K | `--max-context` | CLI and app: 8K; server: 16K | Sets prompt plus response capacity, and 8K is what leaves room for an image and its prompt. The app shows the FP16 KV-memory delta. The server defaults higher still because agent clients routinely send prompts near 8K on their own. | | ||
| | Maximum context | App: 4K, 8K, 16K, 32K, 64K; CLI/server: 4K through 256K, including 96K and 192K ladder points | `--max-context` | CLI and app: 8K; server: 16K | Sets prompt plus response capacity, and 8K is what leaves room for an image and its prompt. The app shows the FP16 KV-memory delta. The server defaults higher still because agent clients routinely send prompts near 8K on their own. | |
There was a problem hiding this comment.
Inaccurate. This says CLI supports "4K through 256K, including 96K and 192K ladder points", implying enumerated CLI validation.
Sources/TurboFieldfareCLI/Args.swift:241-246 accepts any positive integer for --max-context — there is no allowlist on the CLI at all. Only the server enumerates. As written this documents validation that does not exist, in the same row that describes server behavior which does.
Widening --max-context to the Gemma 4 ladder rungs made an unguarded out-of-memory combination reachable. Chunked prefill is what enables the FP16 sliding-window ring in KVCacheManager, and the ring is the only reason a long context fits: it caps each of the 25 sliding-window layers at slidingWindow + chunkTokens tokens instead of maxContext. With --prefill off the ring is disabled and every layer allocates KV at the full context. Measured against the five recorded ladder rungs, the ring-backed footprint matches observed peak Metal allocation to a constant 32 MB offset; without the ring the same 256K cap needs about 55 GiB of KV alone, which no supported machine can satisfy. Reject the combination during argument resolution, where it produces an actionable message, rather than at model load. The bound is 65536, the largest context reachable before the ladder rungs were added, so every previously valid invocation still resolves. Adds 13 cases: rejection at each ladder rung, and matched controls proving both pre-ladder --prefill off and ladder --prefill on still pass.
completedMessage divides by prompt tokens minus cached tokens, but every context-ladder rung ran against an empty cache and reported cached=0, so the subtraction was never exercised by either the suite or the benchmark. Prompt reuse is the server default, which makes a partial cache hit the common case rather than an edge case. Adds three cases: a partial hit where the rate must reflect only the computed tokens, a fully cached prompt that must read as zero rather than dividing by zero elapsed time, and a cached count exceeding the prompt, which the existing max(..., 0) clamps but nothing asserted.
The ladder hardcoded a model path pointing at a different checkout, a fixed port, and a fixed rung list, so no reviewer could run it and a single rung could not be re-measured without repeating the hours-long rungs beside it. It now takes --model/--server/--port/--levels/--out, defaults the model to this checkout's scratch directory, validates the binary and rung labels up front, records the source commit in every result, and merges a partial run into the existing aggregate instead of overwriting it. The 64K baseline joins LEVEL_CONTEXTS so it is produced by the same protocol as the rungs it is compared against. Drops the unused os and select imports. Adds Scripts/context_retrieval.py for the question the ladder cannot answer. A one-token completion on repeated filler proves admission, not comprehension: Gemma 4 runs sliding-window attention on 25 of 30 layers, so long-range signal rests on 5 full-attention layers and a degraded long context still returns HTTP 200. The new harness plants a distinctive fact at a fractional depth, asks for it back with a real multi-token completion, and sweeps depth because sliding-window degradation is position dependent. It exits non-zero on a miss so a sweep can be scripted, and its docstring states that repetitive filler makes recall an upper bound rather than a guarantee.
The launch example and both client configs advertised 262144, making a 256K window the recommended default for every reader. One such request measured about 32 minutes of prefill on the test machine, and agent clients size their history to the advertised window, so that setting commits every request to that cost. The examples move to practical caps and the reasoning is stated inline. Corrects three claims. Context prose now records the --prefill on requirement above 64K and why the sliding-window ring makes it necessary. It states plainly that the capability check is a supported-value list rather than a probe of the weights, and that the ladder measures admission rather than comprehension, pointing at the retrieval harness for the latter. The runtime-controls row claimed the CLI validates an enumerated set including the ladder points. Args.swift accepts any positive integer for --max-context; only the server enumerates. The row now describes each binary's real behavior.
The pull request cited raw artifacts under benchmark-results/, which is gitignored, so none of the data backing the table was actually in the change. This moves the measurements into a durable experiment note alongside the conditions needed to read them correctly. Records both questions separately. The admission ladder is reported with its recorded run, and the note states plainly that the timing columns predate the merge of drumih#159, which rewrote prefill attention selection and the prefill command buffers, so those columns describe a superseded implementation and are marked for re-measurement. It also notes the rungs were captured across more than one commit with the baseline last, so they are not a matched set. Adds the KV memory model derived from KVCacheManager and shows it predicts observed peak Metal allocation at all five rungs to a constant 32 MB offset. That is the structural reason the memory columns should survive re-measurement, and the same formula gives the ~55 GiB figure behind the new --prefill guard. Records the retrieval results that do exist, at 1,543 and 14,043 prompt tokens, and states that nothing is measured between 57K and 254K, which is the range the 256K cap adds.
Review items addressedPushed five commits ( Fixed
Partially addressed#1 (admission vs comprehension) — #2 / #3 (stale timings, unmatched baseline) — cannot be fixed by editing; they need a re-run on one post-merge binary. Now blocking question 1, and the table and experiment note both carry a pre-#159 marker. The 64K baseline joined Note on the KV modelThe VerificationFull The title has been changed from "extend Gemma 4 server context to native 256K" to one that claims the measured result rather than the aspirational one. |
chore: sync fork-main with upstream through drumih#159
Merges upstream PR drumih#29 (NeelM0906, "Add Qwen 3.6 35B-A3B as a second supported architecture") onto fork-main synced through upstream drumih#159. Stage 1 of the MoE generalization: correctness only, no optimization work. Perf cherry-picks land separately in Stage 2, one lever at a time. PR drumih#29 branched from a stale base (add22ff / drumih#49) and predates several upstream changes that touch the exact seams it rewires: fd3c0b8 optional Gemma 4 image support (drumih#144) ec8ad21 prefill speedup on pre-Apple10 Macs (drumih#159) 417f389 CLI runtime controls / RuntimeConfiguration (drumih#100) acefaf1 incremental lossless detokenization (drumih#118) d56c808 typed Codable manifest contract (drumih#87) Both sides generalize the same seams in different directions: upstream adds a MODALITY (text|image), drumih#29 adds a FAMILY (gemma4|qwen36). These are treated as two independent axes rather than one flat enum. 20 files conflicted (~1,090 lines); the rule applied throughout is that upstream wins on pre-existing machinery, while drumih#29's genuinely new work is preserved. Resolutions a mechanical -X ours/-X theirs would have silently broken: * RealForwardRunner prefill. drumih#29 wraps attention in a linear/full family dispatch; upstream added the FP16-KV guard and bidirectional-block params inside that same region. drumih#29's call had dropped layerKind:, bidirectionalBlockStart/End: and fp16KV: — a Gemma image regression. Kept drumih#29's outer dispatch with upstream's guards restored in the else branch. * Logit softcap. RawCompletionScratch defaults logitSoftcap to Gemma's 30.0. Now threaded from model.config.finalLogitSoftcap, so Qwen (which disables softcap with 0) is not silently softcapped into wrong logits. * Manifest wire contract. Extended upstream's typed Codable struct with 13 optional family fields instead of taking drumih#29's hand-rolled dictionary. JSONEncoder omits nil optionals and .sortedKeys fixes ordering, so Gemma manifests still encode byte-identically. The per-field emit is gated by one writesFamilyExtensions flag, so adding a third family is a one-line change. * Gemma-specific tool encodings. GemmaToolSchema.adapted() and gemmaToolArgumentBody() are Gemma prompt encodings and are now skipped for ChatML, which emits <tool_call> JSON directly. * ByteLevel detokenization. Upstream drumih#118 replaced drumih#29's library-delegating detokenizer with a Gemma-pinned one, leaving ChatML's ByteLevel (GPT-2 byte<->unicode) decoding with no path. Added ByteLevelDecoding.swift; neither side carried this alone. Fail-closed image path, per AGENTS.md: * visionCompanion(for: .qwen36) returns nil rather than falling back to Gemma's pack, which would pair one family's vision tower with another family's text model. * TurboFieldfareRepack now rejects --model combined with vision install flags (previously silently ignored) and pins the image pack to SupportedModelSource.gemma4 explicitly. Runtime/server-side rejection of image input for Qwen is not yet implemented and lands in a follow-up commit. Docs state the two-family reality rather than either side's stale claim: drumih#29's "TurboFieldfare omits both vision towers" is false now that Gemma image support shipped. Qwen's published figures are labeled as upstream's, measured on an M5 24 GB host at 4K, not reproduced on this fork. Verified on this checkout: swift build -c release green Scripts/test.sh 1309 tests in 201 suites passed, exit 0 Gemma 4 byte-identical output non-regression at fixed seeds is NOT yet run; it gates the Stage 1 PR, not this commit.
The image tower is Gemma 4's. `VisionRuntime.open` is the only door into
it, and it loads the *text* model's manifest with
`expecting: .gemma4_26B_A4B`, so `ManifestReader.validateArch` refuses a
Qwen 3.6 text model on the `family` field before any pack is opened.
That invariant held already — this commit adds no production change, only
the tests that keep it holding. Investigating it traced every image entry
point and found no bypass:
- streaming parser gates on `visionCapability == "ready"` before staging
- `multimodalMessages` is only produced when `imageFiles` is non-empty
- a forged `turbofieldfare-attachment:` token without a matching lease
is rejected as `invalid_image`
- `imageSoftTokenCounts`, `renderMultimodal`, and `generate` each throw
`vision_unavailable` when `visionRuntime == nil`
- all three `VisionRuntime.open` call sites (server, CLI, app) funnel
through the same family check
No path accepts an image and then answers as though none was sent, which
is the failure AGENTS.md names explicitly.
The cases assert `ModelError.archMismatch` on `family` specifically, not
merely that something threw. A bare "it threw" assertion would keep
passing if the family check were dropped and the throw came from an
incidental shape mismatch instead, and would also keep passing if a
future Qwen model happened to share Gemma's dimensions.
Verified non-vacuous by mutation: deleting the `try check("family", ...)`
line makes the guard tests fail (one falls through to
`VisionPackError.packNotFound`); the line was then restored byte-for-byte
and `git diff Sources/` is empty.
The fixtures write `manifest.json` only, into a temporary `.gturbo`
directory — no weights, no vision pack, no network — so they run on a
checkout with no model installed. The two `VisionRuntime.open` cases are
gated on device support per the existing convention.
Suite: 1309 -> 1318 tests in 202 suites, exit 0.
Stage 1, correctness only. Gate A: 1318 tests / 202 suites, exit 0. Gate B: Gemma 4 output byte-identical vs fork-main at fixed seeds across all three real-generation-v1 cases (7b95132718b7f376 / 6d0c753cb242b15a / 9e924af0585350df).
The Gemma 4 KV formula was inlined at three sites: the server's --prefill off guard, the Mac app's context menu, and the long-context experiment notes. Each computed a Gemma-shaped number for any model, which is wrong for Qwen 3.6 -- its 30 gated-DeltaNet layers hold a fixed recurrent state instead of per-token K/V rows, so the estimate overstated its unringed 256K cost by 11x (55 GiB predicted vs 5 GiB actual). Replace all three with ArchConfig.kvFootprint, which mirrors KVCacheManager.init exactly and is cross-checked against the real allocator by test at 4K/16K/64K for both families. The --prefill off bound becomes a 16 GiB KV budget rather than a 65536 context constant. The budget reproduces the previous Gemma behavior exactly (64K = 13.75 GiB allowed, 96K = 20.62 GiB rejected) while letting Qwen reach every ladder rung, which it can serve unringed. The server reads the family from the manifest before resolving, and falls back to Gemma -- the stricter bound -- when it cannot. The app's context menu gains the 96K/128K/192K/256K options and computes its labels instead of carrying hand-rounded literals. 1,339 tests in 203 suites pass.
Scripts/memory_matrix.py projects resident memory from the same KV model the runtime uses, with the non-KV terms calibrated against the five measured Gemma rungs. --validate re-checks the projection and fails if it ever under-predicts. Two corrections that the obvious formulation gets wrong: The installed pack is not resident. Routed experts stream through a fixed slot cache, so Gemma holds 1,322 MiB + 51 MiB at 16 slots, not 13.31 GiB. Assuming otherwise overstates it by ~12 GiB and wrongly reports that nothing fits in 16 GB. KV is allocated at the context cap, not the prompt length. Measured on the M5 Max: --max-context 262144 with a 14-token prompt reached 7,268 MB, within 52 MB of the 253,952-token rung. --max-context is the memory dial. Result: every Gemma rung through 256K fits on both a 16 GB M4 and a 36 GB M5 Max. Memory is not the long-context limit; prefill time is. Qwen's KV column is exact but its total is left blank on purpose -- the pack is not installed here, so its resident split and runtime overhead are unmeasured.
The --prefill off rejection is a KV budget against the installed architecture, not a fixed context limit, and Qwen 3.6 is unaffected by the ring. Also state that KV is allocated at the cap rather than the prompt length, and link the per-device memory table.
The CLI, the server, and the app each decided independently whether a --max-context was legal, and they disagreed. The CLI accepted any positive integer, so --max-context 1000000 was refused by the server and accepted by the CLI, failing later inside the allocator. The server's parser carried a fourth hardcoded copy of the ladder and rejected an out-of-range value with '--max-context is not supported', naming neither the value nor the legal set. ContextAdmission holds the rule once: the native maximum, the ladder, and the 16 GiB unchunked-KV budget, all derived from the loaded ArchConfig. Every surface calls it and renders the same rejection in its own vocabulary. A nil family means 'not yet identified', which admits whatever any supported model could run. Argument parsing happens before the manifest is read, so assuming Gemma there would reject Qwen-legal commands before looking at the model; the strict per-family check runs afterwards in the run path. The CLI now reads the family from the manifest the way the server already did. Tests pin the surfaces to each other through their real parsers, including a matched control at 256K prefill off where Qwen is admissible and Gemma is not. The app menu test now derives from ContextAdmission.ladder instead of repeating it, so a rung added to one and not the other fails.
The recorded ladder measured admission but probed recall only at 1,543 and 14,043 tokens, so 57K-254K was unmeasured -- and a degraded long context returns HTTP 200 exactly like a healthy one. The recorded prefill columns also predate the merge of drumih#159, which rewrote the path they measure, and were taken across several commits with the 64K baseline last. One sweep answers both: every probe runs on a single binary and records pp_seconds and pp_tokens_per_second alongside the needle result, so the matched timing set falls out of the recall run instead of costing a second full prefill of the ladder. Each rung starts and stops its own server, and results append to JSONL after every probe so an interrupted sweep keeps what it measured.
Retrieval is measured across the ladder for the first time: 15/15 at depths 0.1/0.5/0.9 from 57,043 to 253,143 tokens, every probe with cached_tokens 0. Depth 0.5 at 256K puts the needle ~126,000 tokens from either end, outside the 1,024-token window of all 25 sliding-window layers, so it is reachable only through the 5 full-attention layers -- and it was recovered verbatim. The cap extends what the model can retrieve, not just what it will accept. Filler is repetitive by construction, so this is an upper bound on recall and evidence against catastrophic sliding-window failure, not a guarantee for arbitrary content. Recorded as such. The prefill columns are re-measured on one binary, three probes per rung, replacing numbers taken across several commits before drumih#159. Every rung is slower, median -6.5%. That is NOT called a regression: the old column is not an internally matched set, drumih#159 targeted pre-Apple10 Macs while this host is an M5 Max, and no A/B against the pre-drumih#159 commit has been run. The new table is a matched baseline for future comparison; the old one is kept as a dated lab record. Raw sweep output is committed under docs/experiments/data/ since benchmark-results/ is gitignored.
GTurboLayoutValidator kept its own hardcoded 16 MiB cap after the verifier and the runtime both moved to 64 MiB for Qwen 3.6, whose 40 layers x 256 experts produce a 22,493,846-byte layout.json. Installing Qwen downloaded all 18.8 GiB, verified every weight file, and then failed at the last step: install failed: install state at .../packed_experts/layout.json is corrupt: size 22493846 exceeds 16777216-byte cap The validator now uses VerifiedInstallTool.layoutMaxBytes, the same constant the verifier applies. Nothing was re-downloaded; --resume finished from the preserved .partial. The verifier had cap tests and the validator had none, which is why the drift went unnoticed. LayoutBoundParityTests now asserts the installer and runtime bounds are the same number, that a Qwen-sized layout fits both, and that the layout bound stays above the generic metadata bound so the three cannot be collapsed into one.
Installed scratch/qwen36.gturbo and measured it at a 256K cap: a 17-token prompt reaches 6,641 MB phys_footprint against a projected 8,347 MB. The projection stays conservative, as required, but by 1,706 MB (20%) rather than Gemma's 0-46 MB -- the borrowed overhead constant and on-demand expert slots account for the slack. --validate now fails if it ever inverts. Also confirmed on the real binary that Qwen starts at a 256K cap with --prefill off, which Gemma is refused at identical arguments, and that Qwen allocates KV at the cap exactly as Gemma does. Records the measurement instrument. phys_footprint and RSS differ by 6x on this workload (6,641 MB vs 1,075 MB) because the Metal heaps backing KV are not counted in RSS; an RSS reading against these tables shows a large fake overestimate.
context_sweep.py hardcoded scratch/gemma4.gturbo, so the Qwen ladder could not run through it. Adds --model and writes per-model JSONL (sweep-<pack>.jsonl) so a Gemma sweep and a Qwen sweep cannot land in one file and be averaged across two architectures by a later aggregate.
context_retrieval.py hardcoded --model-id gemma-4-26b-a4b-it. The server does validate that field, so every Qwen probe returned HTTP 404 model_not_found in 8ms and was recorded as found=false. The sweep summary rendered that as 'RECALL 0/3' at every rung -- a total-retrieval-failure shape for what was really a wrong-name error, and the kind of result that would have been alarming to read as a finding. The id now defaults to whatever the server prints in its ready line, so it follows the pack. A 404 aborts the run with an explicit naming error instead of emitting rows that are indistinguishable from genuine recall misses. Verified both families at 16K: Qwen 1/1 (id qwen3.6-35b-a3b, auto-detected), Gemma 1/1 (id gemma-4-26b-a4b-it, same value that was previously hardcoded). Also stops context_sweep.py crashing in its own summary when a rung produced no usable rows -- it formatted None with ',' and lost the whole table.
Both families were fully wired for install -- descriptor, repo, revision, sizes, and a qwen36.gturbo directory -- but nothing in the app could select one. AppModelInstallDescriptor.selected read only TURBO_FIELDFARE_MODEL or a defaults key, so a GUI user could reach Gemma and nothing else. Adds a picker in two places: the Model menu, and the install screen itself, where the decision actually gets made. In the menu alone the choice would be invisible at the one moment it matters -- before committing to a 14.6 GB or 19.5 GB transfer. Selecting a family persists the choice, swaps the installer's descriptor, and moves the model directory to that family's pack, reusing setModelURL for the teardown (cancel loads and transfers, clear staged images, unload the runtime). Switching is refused while a transfer is in flight, which would otherwise strand a partial download owned by a descriptor no longer selected. The environment variable still wins on read, so a launch with TURBO_FIELDFARE_MODEL set is not silently overridden by a click. selectableFamilies sits on the descriptor rather than AppModel so it is reachable without main-actor isolation. Backfills the selection path, which had no coverage at all: nothing referenced TURBO_FIELDFARE_MODEL, .selected, or installDirectoryName. Eight tests cover the descriptor/family round-trip, per-family install directories, Gemma-only vision companions, the switch, the no-op re-select, and the in-flight refusal. 1,358 tests pass.
The picker went into the Model menu and the install screen, and a user with Gemma already installed saw neither: the menu bar is not where anyone looks for a model setting, and the install screen only renders when a model is missing. The reported symptom was exactly that -- no dropdown anywhere, so only Gemma is reachable. The inspector is a permanent 320pt pane with a Model section already at the top of it, next to Context and Slots. That is where a model setting belongs, so the picker goes there, styled to match the pickers beside it. The menu and install-screen entries stay; this adds the one placement that is visible during normal use.
The .railguard/ session-state files are Hermes agent tooling, not project content; they were committed by accident. Untrack them and add .railguard/ and .hermes/ to .gitignore so they stay out.
context_session.py grows context in --step-tokens increments to a target, replaying each turn's answer so the server KV cache keys on it -- this exposes the real per-turn prefill cost (new tokens only) that the cold ladder hides. Needle-in-haystack recall is checked at --checkpoints. The final step is clamped in token-space against --max-context (approx_depth is real tokens but step is a filler WORD count; filler_block + chat template emit more tokens than words) so the last turn can't overshoot the cap and draw an HTTP 400, as it did at the 256K rung on the first run. ladder_table.py renders the cold-ladder results.json as a markdown table. context_sweep.py takes the model id from the server, not a Gemma constant.
Growing context in ~16K steps with a warm KV cache reprefills only the new tokens, but the per-turn cost of that constant delta scales with session depth (13.6 -> 106.4 ms/new-token from 33K to 246K), so reaching 246K in steps cost 4.03 h -- essentially the cold 256K prefill. Incremental context amortizes the O(n^2) work into per-turn responses; it does not reduce it. Needle recall HIT at 64K/96K/128K/192K; memory flat from turn 1 because the full max-context KV ring is pre-allocated. Notes the missing 256K agentic checkpoint (a now-fixed harness clamp off-by-one) and why it is not re-run.
Summary
--max-contextallowlist from a 64K maximum to the Gemma 4 26B-A4B native 256K.--prefill offabove 64K, which this widening would otherwise have made an unguarded out-of-memory combination.What is and is not established
Established on the test machine: prompts up to 253,952 tokens are admitted, prefilled to completion, and memory stable, with no swap, OOM, rejection, or cancellation at any rung.
Not established: that the model still attends across that span. The ladder sends repeated filler and takes a one-token completion, so a level passes when the server accepts and prefills the prompt. Gemma 4 sets
slidingWindow: 1024and marks only 5 of 30 layers as full attention, so all long-range signal rests on those 5 layers, and a degraded long context would return HTTP 200 here exactly like a healthy one.Retrieval is verified at 1,543 and 14,043 prompt tokens (needle recalled at depths 0.1/0.5/0.9). 14,043 clears the 1024-token window by more than 13x, so the full-attention path demonstrably carries information well past the local window — but nothing is measured between 57K and 254K, which is the range this cap adds. See blocking question 2.
Blocking questions
These need a decision or a measurement before merge. They are recorded here rather than resolved silently in code.
1. The timing columns predate the code this branch now ships. Every rung was measured before this branch merged
main, which brought in #159 ("Speed up prefill on pre-Apple10 Macs"). That change rewrote prefill attention pipeline selection and the prefill command-buffer structure — precisely the path the timing columns measure. The rungs were also captured across more than one commit with the 64K baseline recorded last, so they are not a matched set even among themselves. Re-run the full ladder on a single post-merge binary, baseline first? The memory columns are structural and are expected to survive (see the KV cross-check below); the timing columns are not.2. Retrieval is unmeasured across the range this PR adds.
Scripts/context_retrieval.pyis committed and validated, but the model was unavailable when this branch was finished. Run--max-context 262144 --filler-words 250000 --depths 0.1,0.5,0.9before merge, or merge with the scope explicitly limited to admission and open a follow-up? A miss would be a more valuable finding than the timing table.3. Should the ladder rungs be exposed to clients at all? The client examples now advertise 32K rather than 262144, because agent clients size their history to the advertised window and one 254K request cost ~32 minutes of prefill. The 96K/128K/192K/256K caps remain available deliberately, as diagnostics. Confirm that split is what you want.
Measured findings
Apple M5 Max MacBook Pro, 18 CPU cores, 32-core GPU, 36 GB unified memory, macOS 26.5.2 (25F84), Apple Swift 6.3.3. Uniform 8,192-token reserve at every rung. Timing columns are pre-#159 — see blocking question 1.
Prompt length grows 4.43x while end-to-end time grows 8.13x. Time per prompt token rises from 4.118 ms to 7.563 ms and prefill throughput falls from 242.983 to 132.276 tok/s, so cost is superlinear in length even though a least-squares fit over these five points is near-linear (R² = 0.996).
Retrieval
KV memory cross-check
FP16 KV allocation follows from
KVCacheManager: with the ring enabled each of the 25 sliding-window layers holdsslidingWindow + prefillChunkTokens= 1,152 tokens, while each of the 5 full-attention layers holdsmaxContext, doubled for K and V.A constant 32 MB offset across a 4.43x span means the model accounts for the scaling term and the residual is fixed overhead. This is the structural reason the memory columns should survive re-measurement, and it is where the
--prefill offguard comes from: without the ring every layer allocates atmaxContext, giving ~55 GiB at 256K on a 36 GB machine.Implementation
ServerArgumentsaccepts the 96K/128K/192K/256K caps, and rejects--prefill offabove 64K with a message naming the resulting KV requirement. The 64K bound is the largest previously reachable context, so every previously valid invocation still resolves.ServerInferenceexposes prefill and decode durations;ServerLogreportspp,pp_tok_s,tg,tg_tok_salongside prompt/cached/completion counts.Scripts/context_ladder.pytakes--model/--server/--port/--levels/--out, defaults to this checkout, validates inputs up front, records the source commit in every result, and merges a single rung into the existing aggregate instead of overwriting it.Scripts/context_retrieval.pyruns needle-in-a-haystack with a real multi-token completion, sweeping depth because sliding-window degradation is position dependent. Exits non-zero on a miss.docs/experiments/summaries/10-long-context.mdholds the measurements and their caveats, sincebenchmark-results/is gitignored and the raw artifacts cannot travel with the PR.Testing
swift build -c release: clean, only pre-existing warnings.Scripts/test.sh: 1,262 tests in 195 suites passed (152s). The 149 tokenizer.authorizationRequiredfailures reported earlier were environmental and do not reproduce.ServerArgumentTestsandServerLogTests: 19 passed, including 13 new argument cases and 3 new log cases.--prefill off, plus matched controls proving pre-ladder--prefill offand ladder--prefill onstill resolve.pp_tok_scached-token subtraction, which every ladder rung left untested atcached=0despite prompt reuse being the server default.--max-context 262144 --prefill offis refused with the 55 GiB message;--prefill onpasses validation.git diff --check: clean.Scope and limitations
This validates native 256K admission, full prefill completion, timing behavior, and memory stability on the stated machine. It does not establish long-context answer quality, semantic robustness, or useful multi-token decode throughput — the one-token completion makes
tg/tg_tok_sunusable as general decode measurements. Repetitive filler is also the easy case for retrieval, so recall figures are an upper bound relative to real agent history.Related issue