Skip to content

cuda: GGML_CUDA_FA_PREFILL_F16=N, f16 prefill for quantized K/V from pool memory (decode stays in place) - #330

Open
professorpalmer wants to merge 1 commit into
PrismML-Eng:prismfrom
professorpalmer:claude/fa-prefill-f16-pool
Open

professorpalmer wants to merge 1 commit into
PrismML-Eng:prismfrom
professorpalmer:claude/fa-prefill-f16-pool

Conversation

@professorpalmer

Copy link
Copy Markdown

Opt-in, off by default: GGML_CUDA_FA_PREFILL_F16=N lets prefill-sized flash-attention batches (more than 8 queries) over a quantized K/V cache of at most N cells convert K/V to f16 in pool memory sized by the actual KV length and run the f16 MMA kernel. Decode keeps the in-place quantized read from #221. Single commit on current prism (6bfcd79), applies without conflicts.

Why

The in-place q4_0/q8_0 read (#221) is the right default: it saves the f16 copy that ggml_cuda_flash_attn_ext_get_alloc_size would otherwise reserve behind dst for the full context at load (512 MiB at a 131k window for Bonsai 2 27B), and it wins decode. On Turing it costs prefill at depth: the dequantization inside the kernel is slower there than one bulk conversion per chunk. Taking the copy from the pool for prefill only gets both: nothing reserved at load, the f16 kernel for prefill, and the cap keeps the transient copy within the free memory the deployment has.

What it does

  • ggml_cuda_fattn_prefill_f16(dst): true when the variable is set, Q->ne[1] > 8, K->ne[1] <= N, and K/V are the same quantized type.
  • ggml_cuda_fattn_mma_kv_native_supported returns false for those batches, so dispatch takes the f16 MMA path.
  • ggml_cuda_flash_attn_ext_get_alloc_size reserves nothing for them; launch_fattn allocates the f16 copies from the pool (ggml_cuda_pool_alloc<half>) instead of the space behind dst.

Receipts

RTX 2060 SUPER 8 GB (sm_75, PCIe 3.0, stock clocks), Windows, CUDA 13.4, Bonsai 2 27B PTQ1_0, q4_0 K/V, 131,072 window with 58,112 cells on the device, greedy, bench/quick_tps.py-style requests at 4k -> 16k -> 32k -> 60k (each extending the previous prompt), two runs:

depth prefill, in-place (tok/s) prefill, GGML_CUDA_FA_PREFILL_F16=65536 decode, both
4k 415.0 423.8 / 422.7 43.7
16k 350.4 380.3 / 379.8 39.0
32k 271.0 318.5 / 318.4 34.2
60k 199.9 252.7 / 252.5 25.4
  • test-backend-ops -o FLASH_ATTN_EXT -b CUDA0 on sm_75 with the variable set to 1048576 (path forced): 2994/2994.
  • Greedy output at 32k byte-identical to the in-place path (both read the same f16 values; only where they are formed differs).
  • With MTP drafting (draft 1) and the cap at 32768, prefill at 32k 262.5 -> 308.9 tok/s, decode and draft acceptance unchanged.

Not measured on Ampere or Ada, where the in-place read may already win prefill; the variable keeps the current behaviour unless set.

… to N cells to f16 in pool memory for prefill-sized FA batches

Decode keeps the in-place quantized read. The f16 copies are sized by the actual KV length and nothing is reserved at load. Off by default.
@bri-prism

Copy link
Copy Markdown
Collaborator

Tested this PR on an H200: the prefill gain transfers to Hopper, including with MTP depth 1.

Same Release binary, GGML_CUDA_FA_PREFILL_F16=0 versus 65536, three paired runs in AB/BA/AB order. Full CUDA offload, flash attention enabled, context 65536, batch/ubatch 512, one slot, greedy 64-token responses. The fixed model used PQ2_0 target weights, rather than the PTQ1_0 weights in the original measurements. MTP used a q4_0 target cache and the default f16 draft cache.

Total context depth q4_0 AR prefill gain q4_0 + MTP1 prefill gain q8_0 AR prefill gain
4,096 +0.7% +0.8% +1.7%
16,384 +4.1% +3.7% +6.5%
32,768 +9.6% +7.9% +15.6%
60,000 +16.4% +13.9% +26.4%

Gains above compare median prompt throughput. At 60k, q4 AR improved 1987.7 → 2314.1 tok/s, q4/MTP1 1752.7 → 1996.1, and q8 AR 1843.4 → 2330.9. Every individual quantized-cache pair showed a positive prefill gain; q4 AR's 60k paired range was +11.9% to +16.5%. Full request latency at 60k fell 13.2%, 11.6%, and 19.8%, respectively.

Validation:

  • All 84 measured requests / 5,376 returned token IDs matched off/on within each configuration. q4 AR and q4/MTP also matched. MTP accepted/drafted counts were identical in every arm.
  • Median generation-rate changes stayed within 1%; the f16-cache negative control's median prefill changes stayed within 0.11%.
  • Forced FLASH_ATTN_EXT correctness subset passed 73/73 with cap 1048576. Filter: kv=113,nb=(1|32),.*type_K=q(4_0|8_0),type_V=q(4_0|8_0). This was a subset: the initial full suite hit a 600-second timeout before serving measurements, with no numerical failures reported in its partial log.
  • Separate Nsight profiles through 32k confirmed wider attention switches from native q4 reads to f16: 1184 wider attention launches totaled 2026 ms off versus 1055 ms on, plus 89 ms for bulk dequantization. Narrow decode variants remained q4. Profiled kernel activity is separate from the serving measurements.

Memory caveat: load-time process memory was identical off/on, but the enabled pool retained an additional 46 MiB at 16k, 110 MiB at 32k, and 232 MiB at 60k. Logical scratch frees do not return the VMM pool's physical high-water allocation. The cap limits padded cells per operation, rather than aggregate bytes across contexts.

These requests extended a synthetic repeated-code prompt; actual evaluated token counts were 4096 / 12800 / 16896 / 27744 after prefix reuse and hybrid checkpoint replay. Output parity is scoped to this workload. MTP1 verification has only two queries, so it stays below the new >8 threshold; wider speculative verification can also take the conversion path. Server generation timing includes the final MTP draft-prefill hook, which is why full HTTP latency was retained.

This looks useful as an opt-in on H200, especially at depth. It does not establish Ampere/Ada performance or reproduce the Windows/Turing setup.

@professorpalmer

Copy link
Copy Markdown
Author

@bri-prism, thank you for the H200 run. The paired AB/BA order, the token-ID parity and the Nsight split make the result easy to trust.

Memory caveat: correct. The VMM pool keeps its physical high-water allocation, so the f16 copy stays reserved after the largest prefill batch. That is why the switch is opt-in, and why N is a cap per operation, not a total. Where we use it (the 8 GB preset), the launcher's VRAM margin was measured with the switch on, so the high-water allocation is inside that margin. If you want it to be cheaper for other setups, I can add a follow-up that returns the pool's physical pages above a threshold after a prefill batch. That would cost one map/unmap per prefill batch, not per token.

Ada and Ampere: I will add Ada numbers here. I will run test-backend-ops perf -o FLASH_ATTN_EXT on an RTX 4070 at a served shape (head 256, 4 KV heads x 6, 512 queries, KV 4k to 128k, q8_0 against f16), plus one serving A/B with the switch off and on. The first measurement on this PR was Turing (RTX 2060 SUPER).

@professorpalmer

Copy link
Copy Markdown
Author

Ada data, as promised (RTX 4070 12 GB, Windows, sm_89 build of this PR's code):

1. Kernel, test-backend-ops perf -o FLASH_ATTN_EXT at a served shape (head 256, 4 KV heads x 6, 512 queries):

KV length q8_0 K/V (in-kernel reads) f16 K/V f16 faster by
4,096 1,276 us 1,129 us +13.0%
32,768 11,157 us 10,811 us +3.2%
65,536 22,605 us 22,358 us +1.1%
131,072 45,576 us 46,464 us -1.9%

On Ada the in-kernel q8_0 reads cost almost nothing at depth, so the f16 kernel is no faster there. This does not include the bulk conversion.

2. Serving A/B, Bonsai 2 27B PTQ1_0, q8_0 K/V, all-VRAM cache (64k window), one 60k prompt, switch off against 65536: prefill 888.7 -> 898.2 tok/s (+1.1%); decode at 60k 87.6 against 87.5 tok/s; greedy text identical on 7 of 7 prompts. So on Ada the switch is neutral. Turing (+26% at 60k) and your Hopper result are where it pays.

3. One interaction to fix: speculative verify batches wider than 8 queries. With a lookup drafter (ngram-mod in front of MTP, --spec-lookup-n-max 32), a verify batch has up to 33 queries. That passes Q->ne[1] > 8, so every verify step converts the whole cache again. At 30k context, the switch made file-rewrite decode 9 to 10% slower: csv.py 269.2 -> 243.6 tok/s, fnmatch.py 324.1 -> 290.7, shlex.py 299.3 -> 270.9, textwrap.py 289.5 -> 263.2. Plain text: -0.1 to -1.6%. Text identical. Suggestion: a minimum of 64 queries instead of 8. That is above any speculative verify batch and below every prefill micro-batch, so the prefill gain stays. I can push that as one commit with an A/B on the Turing card.

4. Memory, as in your caveat. On our 12 GB recipe, the tiered KV cache fills VRAM up to a 1000 MiB margin. With the switch on, the server stopped at 8k context when the pool grew: cuMemSetAccess failed with "device not ready" (WDDM). The switch needs free VRAM for its high-water copy. A fallback to the quantized path when pool growth fails would be safer than an abort. I can include that in the same follow-up if you want it.

@professorpalmer

Copy link
Copy Markdown
Author

Ada result for the 64-query minimum (point 3 above). Same RTX 4070 and recipe: all-VRAM cache (64k window), one 60k prompt, then 7 greedy decode jobs at 30k (4 file rewrites with lookup drafting, 3 plain-text answers). One run of each arm, in this order:

Arm Prefill at 60k Decode, mean of the 7 jobs Greedy text against switch off
Switch off 887.6 tok/s 212.3 tok/s (reference)
Switch on, minimum 64 queries 898.0 tok/s 212.4 tok/s 7 of 7 identical
Switch on, current rule (Q->ne[1] > 8) 896.3 tok/s 195.4 tok/s 7 of 7 identical

The minimum keeps the prefill result and removes the decode loss. The change is one condition in fattn-common.cuh, with an override for tests (GGML_CUDA_FA_PREFILL_F16_MIN_Q, default 64):

    static const int64_t min_q = [] {
        const char * e = getenv("GGML_CUDA_FA_PREFILL_F16_MIN_Q");
        return e ? std::max<int64_t>(1, atoll(e)) : (int64_t) 64;
    }();
    ...
    return max_kv > 0 && Q->ne[1] >= min_q && K->ne[1] <= max_kv && K->type == V->type && ggml_is_quantized(K->type);

If you agree, I will push it to this PR as one commit. The Turing A/B follows when the RTX 2060 is free.

@professorpalmer

Copy link
Copy Markdown
Author

@bri-prism, an update, in one comment for both cards.

1. A bug we found on our 8 GB preset is not in this PR. On an RTX 2060 SUPER, fresh prompts longer than about 30k sometimes got a 1-token answer. The 8 GB preset sets this PR's switch (GGML_CUDA_FA_PREFILL_F16=32768), so we held the Turing data until we knew the cause. The cause is a different, opt-in switch in our own stack, GGML_CUDA_SHARED_POOL (one CUDA pool for the target and the MTP draft context; not part of this PR):

RTX 2060 SUPER, fresh 60k prompt 1-token answers
shared pool on, f16 prefill on 3 of 3
shared pool on, f16 prefill off 3 of 3
shared pool off, f16 prefill on (cap 32768) 0 of 3

Every run with the shared pool off since then: 0 of 53 requests, fresh prompts from 4k to 126k. Cause: the two contexts run on two CUDA streams that got the same LIFO pool, so a block freed by one stream could be reused by the other before the first was done with it. We fixed it on our side (an event orders the two streams at graph boundaries: 0 of 18 against 18 of 18 on the 2060). Nothing changes in this PR.

2. Turing data for the 64-query minimum (RTX 2060 SUPER, lookup drafting on, the switch at 32768, current rule 8a6d2ea12 against the minimum 2096db283, 2 alternated rounds, no 1-token item in these runs):

Depth Kind Current rule (more than 8 queries) Minimum 64 queries
0 file rewrite 125.7 tok/s 124.1 tok/s
0 edit call 59.7 60.2
0 plain text 52.8 52.6
16k file rewrite 112.2 110.6
16k edit call 79.2 79.5
16k plain text 45.9 46.1

Fresh prefill, 2 runs each: 30k 345.2 / 345.2 against 344.8 / 344.5 tok/s; 60k 255.0 against 254.4. Text identical between the two engines in each round.

So on Turing the minimum is neutral (within 1.5%), and prefill keeps the gain this PR measured there. On Ada (my earlier comment) the minimum removes an 8% decode loss on copy-heavy work (195.4 -> 212.4 tok/s, prefill unchanged). A 30k file-rewrite row for Turing follows when a clean rerun finishes.

The minimum is still one commit; I will push it here if you agree.

@Powerful-Plantain

Powerful-Plantain commented Oct 10, 2026 •

Copy link
Copy Markdown
llama-server log — prism-b10770, 30K prompt, CUDA_LAUNCH_BLOCKING=1

0.01.556.460 I cmn common_param: common_params_print_info: verbosity = 3 (adjust with the -lv N CLI arg)
0.01.556.698 W srv llama_server: -----------------
0.01.556.699 W srv llama_server: CORS is set to allow all origins ('*') and no API key is set
0.01.556.699 W srv llama_server: this can be a security risk (cross-origin attacks)
0.01.556.699 W srv llama_server: more info: ggml-org#25655
0.01.556.699 W srv llama_server: -----------------
0.01.557.943 I srv load_model: loading model '/srv/models/bonsai2-27b/Ternary-Bonsai-2-27B-PTQ1_0.gguf'
0.01.981.219 W common_fit_params: failed to fit params to free device memory: n_gpu_layers already set by user to 99, abort
0.03.288.518 I cmn init: llama threadpool init, n_threads = 8
0.03.404.839 I srv load_model: initializing, n_slots = 1, n_ctx_slot = 65536, kv_unified = 'false'
0.03.408.238 I srv init: chat template supports preserving reasoning, consider enabling it via --reasoning-preserve
0.03.408.245 I srv llama_server: model loaded
0.03.408.247 I srv llama_server: listening on http://127.0.0.1:8081
0.04.038.511 I slot get_availabl: id 0 | task -1 | selected slot by LRU, t_last = -1
0.04.038.566 I slot launch_slot_: id 0 | task 0 | processing task, is_child = 0
0.07.811.803 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 2090, progress = 0.07, t = 3.77 s / 553.90 tokens per second
0.11.470.420 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 4138, progress = 0.14, t = 7.43 s / 556.79 tokens per second
0.15.232.956 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 6186, progress = 0.20, t = 11.19 s / 552.60 tokens per second
0.19.104.219 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 8234, progress = 0.27, t = 15.07 s / 546.54 tokens per second
0.23.078.968 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 10282, progress = 0.34, t = 19.04 s / 540.01 tokens per second
0.27.157.551 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 12330, progress = 0.41, t = 23.12 s / 533.33 tokens per second
0.31.342.018 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 14378, progress = 0.48, t = 27.30 s / 526.60 tokens per second
0.35.627.988 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 16426, progress = 0.54, t = 31.59 s / 519.98 tokens per second
0.40.020.194 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 18474, progress = 0.61, t = 35.98 s / 513.43 tokens per second
0.44.516.624 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 20522, progress = 0.68, t = 40.48 s / 506.99 tokens per second
0.49.111.772 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 22570, progress = 0.75, t = 45.07 s / 500.74 tokens per second
0.53.810.091 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 24618, progress = 0.82, t = 49.77 s / 494.62 tokens per second
0.58.612.051 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 26666, progress = 0.88, t = 54.57 s / 488.63 tokens per second
1.03.518.469 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 28714, progress = 0.95, t = 59.48 s / 482.75 tokens per second
1.05.834.579 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 29668, progress = 0.98, t = 61.80 s / 480.10 tokens per second
1.07.154.954 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 30180, progress = 1.00, t = 63.12 s / 478.16 tokens per second
/opt/llama/prism-src/ggml/src/ggml-cuda/ggml-cuda.cu:108: CUDA error
1.07.250.129 E CUDA error: an illegal memory access was encountered
1.07.250.131 E current device: 0, in function launch_fattn at /opt/llama/prism-src/ggml/src/ggml-cuda/template-instances/../fattn-common.cuh:1113
1.07.250.132 E cudaOccupancyMaxActiveBlocksPerMultiprocessor(&max_blocks_per_sm, fattn_kernel, block_dim.x * block_dim.y * block_dim.z, nbytes_shared)
/opt/llama/prism-src/build/bin/llama-server(+0x1973356) [0x5dcfb5d68356]
/opt/llama/prism-src/build/bin/llama-server(+0x197394d) [0x5dcfb5d6894d]
/opt/llama/prism-src/build/bin/llama-server(+0x1973b30) [0x5dcfb5d68b30]
/opt/llama/prism-src/build/bin/llama-server(+0x9ec587) [0x5dcfb4de1587]
/opt/llama/prism-src/build/bin/llama-server(+0x17bcc30) [0x5dcfb5bb1c30]
/opt/llama/prism-src/build/bin/llama-server(+0x17bcfda) [0x5dcfb5bb1fda]
/opt/llama/prism-src/build/bin/llama-server(+0x9fc226) [0x5dcfb4df1226]
/opt/llama/prism-src/build/bin/llama-server(+0xa05305) [0x5dcfb4dfa305]
/opt/llama/prism-src/build/bin/llama-server(+0xa07ece) [0x5dcfb4dfcece]
/opt/llama/prism-src/build/bin/llama-server(+0x199725f) [0x5dcfb5d8c25f]
/opt/llama/prism-src/build/bin/llama-server(+0x689140) [0x5dcfb4a7e140]
/opt/llama/prism-src/build/bin/llama-server(+0x690e39) [0x5dcfb4a85e39]
/opt/llama/prism-src/build/bin/llama-server(+0x696516) [0x5dcfb4a8b516]
/opt/llama/prism-src/build/bin/llama-server(+0x697d52) [0x5dcfb4a8cd52]
/opt/llama/prism-src/build/bin/llama-server(+0x269739) [0x5dcfb465e739]
/opt/llama/prism-src/build/bin/llama-server(+0x21c159) [0x5dcfb4611159]
/opt/llama/prism-src/build/bin/llama-server(+0x27aabe) [0x5dcfb466fabe]
/opt/llama/prism-src/build/bin/llama-server(+0x27ce55) [0x5dcfb4671e55]
/opt/llama/prism-src/build/bin/llama-server(+0x21b316) [0x5dcfb4610316]
/opt/llama/prism-src/build/bin/llama-server(+0x1d1829) [0x5dcfb45c6829]
/opt/llama/prism-src/build/bin/llama-server(+0x1d37d7) [0x5dcfb45c87d7]
/usr/lib/x86_64-linux-gnu/libc.so.6(+0x2a601) [0x7fe26be2a601]
/usr/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0x88) [0x7fe26be2a718]
/opt/llama/prism-src/build/bin/llama-server(+0x1c94d5) [0x5dcfb45be4d5]

@professorpalmer

Copy link
Copy Markdown
Author

Thank you for the log. The error is reported in launch_fattn, but CUDA errors are asynchronous, so the kernel that made the illegal access can be an earlier one. The crash comes right after the 30,180-token prompt finished, at the first attention op after it. To find it, please send:

  1. The GPU (nvidia-smi name and driver), the CUDA version, and the commit of /opt/llama/prism-src (git log -1 --oneline). Also say whether this PR's change is in it.
  2. The full llama-server command line, and any GGML_CUDA_* / LLAMA_ARG_* environment variables. Especially GGML_CUDA_FA_PREFILL_F16 and its value, the K/V cache types (-ctk / -ctv), -ub / -b, and --spec-* (drafting) options.
  3. One run with CUDA_LAUNCH_BLOCKING=1. The error is then reported at the kernel that fails, not at a later launch.
  4. If you can: the same prompt with GGML_CUDA_FA_PREFILL_F16 unset, which shows whether the f16 prefill path is involved.

Two values from your log may matter. The prompt is 30,180 tokens with a 65,536-token slot, so the K/V view is about 30k cells at that point. For Bonsai 2 27B (4 KV heads x 256), the f16 copy of K and V at 30k cells is about 120 MiB of pool memory per attention op. That is close to the default limit of the f16 path (32,768 cells), so a failed or short pool allocation there is one candidate.

@Powerful-Plantain

Copy link
Copy Markdown

Thanks for the quick reply — and apologies, this should have been a separate issue rather than a comment on your PR. Full report moved to #346. Answers to your questions:

  • This PR is not involved. Build is 6684606ac (tag prism-b10770-6684606), clean checkout; GGML_CUDA_FA_PREFILL_F16 doesn't exist in that tree and was not set.
  • GPU: NVIDIA GeForce RTX 3070 Laptop GPU (sm_86, 8 GB), driver 595.91.07 (supports CUDA 13.2), built with CUDA toolkit 12.4 (V12.4.131), Ubuntu Server, GCC 15.2.
  • Build: -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=86 -DBUILD_SHARED_LIBS=OFF -DGGML_CUDA_FA_QUANTS=q4_0-q4_0,q8_0-q8_0,f16-f16,bf16-bf16
  • Command: llama-server -m Ternary-Bonsai-2-27B-PTQ1_0.gguf --host 127.0.0.1 --port 8081 -ngl 99 -c 65536 -np 1 -fa on -ctk q4_0 -ctv q4_0 --jinja --reasoning-budget 2048 --temp 1.0 --top-p 0.95 --top-k 20 --min-p 0.05 --threads 8 (default -b/-ub, no --spec-*, no drafter/MTP)
  • Env: only CUDA_LAUNCH_BLOCKING=1; no other GGML_CUDA_* / LLAMA_ARG_*.
  • The log above was already captured with CUDA_LAUNCH_BLOCKING=1. It still surfaces at the cudaOccupancyMaxActiveBlocksPerMultiprocessor query before the FA launch, so the faulting kernel looks like one whose launch isn't error-checked.

Same request crashes at 30K and 45K; 45K succeeds with --ctx-checkpoints 0 (one run); b10709 handles the 45K prompt correctly. Happy to run compute-sanitizer or test a branch if useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants