Status: PARITY/BEAT ACHIEVED (2026-06-30) with three changes — the CUDA-graph rewrite (below) was NOT needed. E4B end-to-end vs llama went 0.78–0.91x → 8k 0.98x, 16k 1.06x, 32k 1.09x, 64k 0.98x, 128k 1.01x (beats at 16k/32k/128k; 0.98x parity at 8k/64k). What shipped: (1) in-kernel PLE, (2) KV pre-grow, (3) on-GPU causal mask (below).
-
In-kernel PLE gather (the big win). Phase-0's "priority-1 memcpy" turned out to be the per-layer-embedding (PLE) device→host→device round-trip (~88 MB×2/chunk at 2k tail, ~352 MB×2 at 8k), NOT KV residency (residency is fine — a red herring). The verify graph now reproduces the FULL PLE on-device —
(sqrt(ple_dim)·get_rows(per_layer_token_embd, ids) + rmsnorm((hidden@per_layer_model_proj)/sqrt(hidden), per_layer_proj_norm)) / sqrt(2)— from the resident quantized table; only the token-id list is uploaded.TS_G4_PLE_IN_KERNEL(default on), text-only, get_rows-safe quant table. Byte-identical (PrefillBench MATCH). This cut the 64k busy 88%→94.7% and the kernel-floor 1.35x→1.08x. -
KV pre-grow (
IModelArchitecture.PrepareForPrefill): pre-size the grow-on-demand global KV cache to the prompt length at the first prefill chunk (free at start_pos 0, no committed K/V to copy) — kills the incremental doubling grows that re-copied+round-tripped the cache (~+5–7% at 16k/64k). -
On-GPU causal mask (Option A below, implemented):
ggml_ops_mask.cufills the verify's[kvLen,N]F16 causal(+windowed) masks straight into their device buffers (fill on stream 0, one sync before graph compute) instead of the O(N·kvLen) host fill + H2D upload. Bit-identical,TS_G4_GPU_MASK(default on), CUDA text-only. Isolated +4% @8k, +8.7% @64k, +4.4% @128k (mask ∝ kvLen, so it helps most at long context — pushed 128k 0.97x→1.01x).
Remaining 8k/64k at 0.98x (~2%) is fixed per-request overhead (HTTP, first-token, cold graph) + inter-kernel idle. Option B (CUDA-graph persistent verify) NOT pursued — see §6 (huge effort, tiny headroom; 64k kernel-floor barely clears llama even ideal). The rest of this doc is the original investigation + the (now-moot) CUDA-graph plan, kept for reference.
Original notes from a profile-driven investigation (2026-06-30). This documents why our GGML-CUDA prefill trailed llama.cpp on long prompts and a phased, de-risked plan to close it. It deliberately front-loads a measurement gate (Phase 0) so we do not commit to the large rewrite before confirming it will pay off.
Goal: make Gemma-4 E4B (and the 12B/26B siblings) long-prompt prefill on ggml_cuda
match or beat llama.cpp.
Baseline (Gemma-4 E4B Q8_0, 16 GB, sm_86), prompt-tokens / TTFT:
| length | TensorSharp | llama.cpp | ratio |
|---|---|---|---|
| 2k | 2083 | 2365 | 1.14x |
| 8k | 2174 | 2793 | 1.28x |
| 64k | 1653 (→1670 after tail-chunk fix) | 2118 | 1.28x |
Success: ≥ parity at 8k–128k (stretch: beat), byte-identical greedy output vs the current path on the dense/E-series/MoE + multimodal + MTP prefill test matrix.
All measured on the numbers above; details in the kv-pool-longprompt-hang-fix memory note.
- Not a slow path. The tail is already the fused whole-model verify
(
TSGgml_Gemma4ModelVerify) on the fast MMA tensor-core flash kernel for head_dim 512 (flash_attn_ext_f16<512,512,16,4>); GQA opt applies; KV padded to%256.GGML_PREC_F32is a no-op for the MMA path (the kernel doesn't read precision). - Not chunk sizing. Solo-tail sweet spot is 2048 (shipped); 1024/512/8192 are all worse; making the fresh chunk 2048 too gives nothing at 64k.
- Not the matmul path.
GGML_CUDA_FORCE_CUBLAS=1→ no change (MMQ Q8_0 is fine). - Not KV-cache growth.
MAX_CONTEXTpre-alloc (no doubling grows) → +1%. - Not per-chunk host overhead in steady state. Instrumented per-phase timing on a late
2048-token chunk (start_pos≈60k):
compute≈1225 ms, hostbuild+maskfill+upload≈80 ms(6%). - nsys kernel mix (steady state): MMQ matmuls ~55%, flash ~18%, small kernels (rms_norm / rope / quantize_mmq_q8_1 / cpy / concat / scale) ~25% — the same ggml kernels llama runs.
Cleanest signal: even a single 8k fresh chunk (one graph, no tail / swaPrev / multi-chunk overhead) is 1.28x slower. So the gap is per-forward execution efficiency, not multi-chunk or host bookkeeping.
Working hypothesis: the residual is GPU-idle from CPU kernel-submission latency between the ~1000 kernels of a forward. llama eliminates it with CUDA-graph capture + replay across its fixed-shape 2048-token ubatches ("graphs reused = 13" in its logs). We never capture a CUDA graph. This hypothesis is strong but not yet proven for the compute-bound late chunks — see Phase 0.
From ExternalProjects/ggml/src/ggml-cuda/ggml-cuda.cu:
ggml_cuda_graph_get_key(cgraph)returnscgraph->nodes[0](a tensor pointer).- Replay requires the key's cached graph to reach
warmup_complete: ≥ 2 consecutive computes with the same key and no "property change". ggml_cuda_graph_update_requiredreports a change if node count, or any node's op / shape (ne) / strides (nb) / data pointer differ from the last capture.
TSGgml_Gemma4ModelVerify today (ggml_ops_gemma4_verify.cpp; before the 2026-07 file split, ggml_ops_transformer.cpp ~6144):
- Builds a fresh
ggml_context+ggml_new_graph_customevery chunk. →nodes[0]is a new pointer each call → new (empty) cache entry → warmup never completes. (Primary blocker.) kvLengrows every chunk (flash_attn_kv_length(totalSeqLen,…), padded only to%256). → mask[kvLen,N]and the K/V read viewnechange → property change even if (1) were fixed.- KV write is
ggml_cpyinto a view at a per-chunk offset (writePart→ggml_view_3datdstSlot/cacheBase→ggml_cpy). → the write-node data pointer changes every chunk → property change. (Second structural blocker; this is the one llama avoids by writing the KV cache withggml_set_rows(cache, values, idx)whereidxis an input tensor.) - KV cache doubling grows re-allocate the cache tensors → base pointers change → resets warmup.
Net: four independent things force properties_changed = true on every chunk, so ggml runs
the graph in "direct eval" mode (per-kernel launches) forever.
To let ggml's built-in CUDA-graph path engage for prefill, a chunk-to-chunk sequence must be structurally identical:
R1. Persistent graph + context reused across chunks (stable nodes[0], stable tensor
pointers); rebuild only when the bucket changes.
R2. Fixed-bucket KV padding: round the attended kvLen up to a bucket (e.g. next 8192, or a
small ladder 8k/16k/…/131k) so mask/KV-read shapes are constant within a bucket. Extra
padded keys are -inf-masked (already how we pad to %256, just coarser).
R3. Pre-allocated KV cache to the run's max (no doubling grows → stable base pointers). We
already have MAX_CONTEXT; make solo long-prefill pre-grow automatically.
R4. Offset-free KV writes: replace the per-chunk view-offset ggml_cpy with
ggml_set_rows(cache, k_write, pos_idx) (write positions as an input tensor), so the write
node topology/pointers are constant. Circular-SWA layers write pos % window indices; global
layers write start_pos + i. This is the biggest single kernel change.
R5. In-place input updates: hidden, pos, and the causal mask keep the same tensor
pointer/shape and are refreshed via ggml_backend_tensor_set each chunk (content changes are
fine — the update-check compares pointers, not contents; replay re-reads the buffers).
R1–R3 + R5 are mechanical. R4 is the hard, correctness-critical part.
Keep the per-chunk rebuild but hide the ~80 ms host phase behind GPU compute:
- On-GPU causal mask: a tiny kernel that fills the
[kvLen,N]F16 causal(+window) mask directly in device memory, removing the hostget_causal_maskfill (~42 ms) and the H2D upload (~33 ms). The pattern is a per-row contiguous[lo,hi]band — trivial to generate on device from(start_pos, N, window). - Double-buffer chunk N+1's host prep (descriptor marshalling, pos/mask) while chunk N computes.
Ceiling ≈ the 6% steady-state host overhead + early-chunk host-bound share. Will not reach parity by itself, but it is safe, independently shippable, and reduces the surface Phase 0 has to explain.
Implement R1–R5. New internal entry, e.g. TSGgml_Gemma4ModelVerifyPersistent, that:
- Owns a persistent
ggml_context/cgraph/gallocr per (bucket, model-fingerprint), cached like the decode graph (g_g4dc) is today. - Uses bucketed
kvLen(R2), pre-grown KV (R3),set_rowsKV writes (R4), in-place input refresh (R5). - Lets ggml's
USE_CUDA_GRAPHmachinery capture on the 2nd chunk of a bucket and replay after. - Falls back to the current per-chunk path when: multimodal
is_exceptspans present, MoE verify, first/last partial chunk, or any capture-incompatibility (ggml_cuda_graph_check_compabilityreturns false, e.g. split buffers).
This mirrors what already works for decode (persistent captured graph) — the novelty is doing it for a multi-token graph with a moving KV write and bucketed masks.
Phase 0 — DONE (2026-06-30). Measured, hypothesis partly revised.
Method: env-gated per-phase timers in the verify kernel + nsys on PrefillBench
(TS_PREFILL_ENGINE_ONLY=1 / TS_PREFILL_LEGACY_ONLY=1, MAX_CONTEXT=65536 to remove cache
grows, --delay/--duration to capture only a late steady-state window), then interval-union of
CUPTI kernel+memcpy activity vs wall span.
Steady-state late window (start_pos≈20–40k, N=2048), both engine and legacy paths agree:
| metric | value |
|---|---|
| GPU busy (kernel∪memcpy) | ~88% |
| pure idle (no GPU op) | ~12% |
| memcpy (serialized, ~0 overlap with kernels) | ~11% (≈1.1 s / 10 s window) |
| kernel-only floor speedup if all non-kernel removed | ~1.30–1.35x |
1653 × 1.30 ≈ 2149 ⇒ recovering almost all overhead would just beat llama's 2118. The
margin is thin — a partial recovery (~70%) lands ~1980, i.e. still short. Proceed only with
that expectation set.
Two recoverable components, in priority order:
-
Per-chunk PLE (and hidden) device→host→device round-trip (~11% memcpy) — ROOT-CAUSED (2026-06-30), the concrete bounded win; lower risk than CUDA graphs.
Correction to an earlier mis-read: this is NOT a KV-cache residency failure. Env-gated residency counters (
TS_DBG_KV_RESIDENCY) confirm the KV cache is fully resident on chunks 2+ (48 non-shared K/V tensorsresident, 0upload, 0stream). The 88 MB copies are something else.The real source (CUPTI: copies are on a separate copy stream, device↔pinned-host, no graphNodeId, and serialized — 0 kernel overlap, i.e. they do block the GPU; they sit next to
get_rows/convert_unary/scale_f32): the per-layer-embedding (PLE) input. E4B hasembedding_length_per_layer_input = 256, sople = N(2048)·num_layers(42)·ple_dim(256)·4B = 88.1 MB— exactly the copy size.ComputePLEgathers the PLE on-device (GetRowsQuantinto a fresh device tensor), butNativeGemma4ModelVerifypasses it as a hostfloat*viaGetFloatPtr(perLayerInputs)→TensorComputePrimitives.GetFloatPointerforces a device→host sync (the 88 MB D2H), and the kernel then re-uploads it H2D viaggml_backend_tensor_set(ple_input, ple_data, …). Net: the already-on-device PLE is bounced to host and straight back, 176 MB/chunk of pure waste. Thehiddeninput round-trips the same way (GetFloatPtrD2H +ggml_backend_tensor_set(current,…)H2D), but it's only ~21 MB (embedding_length 2560·N·4B). Present on both engine and legacy paths (both call the verify withGetFloatPtr), which is why block-capture was a red herring.Fix (well-scoped, ~5–6% for PLE + a bit for hidden): keep the PLE (and hidden) on-device and hand the verify the device buffer directly instead of a host pointer — either (a) a persistent device PLE/hidden buffer that
ComputePLE/the caller writes into on-device each chunk and the verify binds resident (skip theggml_backend_tensor_setupload), or (b) plumb the ggml device buffer ofperLayerInputs/hiddenacross the C#↔native boundary andggml_backend_tensor_allocthe graph input onto it (zero-copy). (a) is simpler and mirrors the KV residency pattern. The same round-trip exists on the decode path (NativeGemma4ModelDecodealso takesGetFloatPtr(perLayerInputs)), so the fix helps decode too. Validate byte-identical + re-nsys (the 88 MB D2H+H2D pair must vanish). -
Inter-kernel idle (~12%) — needs CUDA graphs (Option B). Confirmed: even the single-graph 8k chunk is 1.28x slower and busy is only ~88%, i.e. ~12% of wall the GPU has no op running — CPU kernel-submission gaps across the ~1000 ops/forward. This is the part the persistent-graph
- bucketed-KV + CUDA-graph-capture work (Option B / Phase 2) targets.
Not attributable to: chunk size, cuBLAS vs MMQ, precision, cache-grow doubling (all ≤ few %, already tested), or block capture (engine≈legacy: 1650 vs 1664 t/s).
(Original Phase-0 cross-check via GGML_CUDA_DISABLE_GRAPHS was not runnable — this vendored ggml
has no such env; graphs only auto-disable on MoE MUL_MAT_ID / split buffers.)
Phase 1 — ship Option A (safe, ~5–8%) regardless of Phase 0 outcome. On-GPU mask + host
overlap. Gate behind TS_G4_PREFILL_GPU_MASK / TS_G4_PREFILL_PIPELINE.
Phase 2 — Option B, only if Phase 0 is green. Land incrementally, each step behind an env flag and validated byte-identical before the next:
- R3 auto pre-grow KV for solo long prefill (no behavior change, removes grow-resets).
- R2 bucketed
kvLenon the existing per-chunk path (correctness-only; slightly more masked compute — measure the cost; must be < the replay win). - R4
set_rowsKV writes (dense first; the riskiest — validate SWA circular wrap + global append byte-identical in isolation). - R1+R5 persistent graph/ctx + in-place inputs; confirm ggml logs
CUDA graph … reusedand GPU-busy rises.
Ship Phase 2 default-on only after the full matrix passes; keep TS_G4_VERIFY_PERSISTENT=0
kill-switch.
- Byte-identical greedy vs current path on: E4B dense, 12B dense, 26B-A4B MoE, E-series
KV-donor (
swaFreshShared/swaPrev), multimodal bidi spans, MTP verify. Lengths spanning a bucket boundary and a mid-bucket chunk, and a >window SWA-wrap tail. - Existing suites:
Gemma4BatchedForwardTests,Gemma4MtpTests,EngineParallelInferenceTests(real-model, opt-in),PrefillChunkingTests. - SWA circular-cache wrap is the highest-risk correctness area for R4 — add a targeted test that prefills past the window in bucketed chunks and diffs the KV cache + logits against the per-op reference.
- R4 SWA-wrap miscompute (highest). Mitigation: land dense-global
set_rowsfirst; keep SWA on the existing write path until independently validated. - Bucket padding adds masked compute — a large bucket at low
start_poswastes attention. Mitigation: bucket ladder (start small), not a single 131k bucket; measure R2 in isolation. - VRAM: R3 pre-grow + persistent per-bucket graphs pin more memory. 128k already sits at ~14/16 GB; cap buckets and evict other-N cached graphs (as the decode path already does).
- Capture incompatibility: multimodal / MoE / partial chunks fall back to the per-chunk path (feature-gated), so those never regress.
- Every phase is env-gated and independently revertible; Phase 1 is orthogonal to Phase 2.
- Phase 0: 0.5–1 day (decisive; do this first).
- Phase 1: 1–2 days (on-GPU mask + pipeline).
- Phase 2: ~1–2 weeks incl. the
set_rowsrewrite, per-bucket graph cache, and the full byte-identical validation matrix — with a real chance Phase 0 redirects it.
TensorSharp.GGML.Native/ggml_ops_gemma4_verify.cpp—TSGgml_Gemma4ModelVerify(build/mask/ KV-write/compute),get_causal_mask;flash_attn_kv_lengthlives inggml_ops_transformer_common.h.TensorSharp.Models/Models/Gemma4/Gemma4Model.cs—CanUseWholeModelPrefillVerify,NativeGemma4ModelVerifymarshalling,EnsureCacheCapacity(R3), decode-graph cache pattern to mirror (Gemma4ResetDecodeCache).ExternalProjects/ggml/src/ggml-cuda/ggml-cuda.cu—ggml_cuda_graph_get_key,ggml_cuda_graph_update_required,ggml_cuda_graph_check_compability(the constraints R1–R5 must satisfy).- Bench/verify:
benchmarks/PrefillBench(add single-chunk-at-fixed-start_pos mode for Phase 0),benchmarks/engine_comparison(end-to-end vs llama).