Repository navigation
perf(gemma4): read the source layer's kv slab on kv-shared layers - #898
ischencheng wants to merge 4 commits into
Conversation
|
@ischencheng Looks good to me. Every KV write goes through Two small asks. First, Second, the fallback in source = max((i for i in layer_ids if i < self.first_kv_shared_layer), default=None)
if source is None:
raise NotImplementedError("gemma4 KV-shared layers with no earlier layer of their attention type are not supported") |
… and the draft view Gemma4Attention takes its source and shared flags from config.kv_shared_layers, the list the pool maps, and keys shared_kv by the source layer. The draft's read-only view routes through the same list. A group whose shared layers have no earlier layer of their type is refused at parse time.
|
Thx, both are in 7796a0d: |
|
Thanks, both are in. On an RTX PRO 6000, E2B now gets 2.33x the KV pages of main and the greedy outputs match main (8/8). One last ask: please drop |
Fixes #882.
The KV-shared layers of Gemma-4 E2B / E4B still stored their own copy of the source layer's k, v, which is 57% of E2B's KV per token and 43% of E4B's. Now they read the source layer's slab and store nothing, so the same KV memory holds about 7/3x the tokens on E2B and 7/4x on E4B. Outputs are unchanged: on main the copies hold the same rows as the source's slab.
ModelConfig.kv_shared_layerslists(layer, source)pairs: each shared layer and the last earlier layer of its attention type (14 / 13 on E2B, 23 / 22 on E4B; llama.cpp'skv_source_implgives the same). The HF and GGUF parsers fill it fromGemma4EArgs.kv_shared_layers, which raisesNotImplementedErrorfor a group whose shared layers have no earlier layer of their type; no release has that shape.Gemma4Attentiontakes its shared and source flags from that list and keysshared_kvby the source layer, so the module and the pool read one list.kv_cache_group_specs()moves those layers out of each group'slayer_idsintoshared_layers, so the pool allocates slabs andkv_costprices bytes only for layers that own KV. The budget code itself does not change.HybridSWAKVCachemaps a shared layer onto its source's slab, andstore_kvreturns early for it: the source stored the same rows earlier in the forward. The attention module still passes k, v to the backend, since the extend kernel takes the current chunk fromk_extend/v_extend.--kv-cache-dtype fp8_per_token_head/int8_per_token_head(feat(kvcache): add fp8 and int8 per-token-head KV cache formats #894) the scales are looked up through the samelayers_mapping, so a shared layer reads its source's scales, and the early return skips the quantized store as well. Nothing else is needed for those formats.full_to_swa_index_mapping, so a shared SWA layer reads the same slots as its source and the cache bookkeeping does not change.create_kvcache_poolraises on shared layers. Only E2B / E4B have them, and they always have a window group.ReadOnlyTargetKVView, which routes a shared target layer to its source through the same list. The draft's own layers have no source in its config, so they stay marked shared by itsfirst_kv_shared_layer = 0.End to end
A10 (sm_86, 24 GB), driver 610.57.04, CUDA 13.0, in the test container. Both arms run in the same container: the main arm copies the changed files back from main. One server start per arm and case.
KV capacity from
/v1/cache/status(num_pages, page size 1; window pool in brackets), same cache budget in both arms, on7796a0dwith main66190a9(this head adds only a test fix on top). The last three rows run the assistant draft from #895 (--text-model-only --spec-algo mtp --spec-draft-model mtp-gemma-4-E{2,4}B-it-BF16.gguffrom unsloth'sMTP/):--kv-cache-dtype int8_per_token_headOn the previous head (
3496477, on27740fe), with 7.5 GiB free (a side process holding the rest of the A10, about what an 8 GB card leaves) and--embed-weights host: E2B 86,205 -> 209,954 (2.44x), E4B GGUF Q4_K_M 77,436 -> 141,342 (1.83x), E2B withfp8_per_token_head177,136 -> 421,830 (2.38x); and on the whole GPU, E2B with--cache-type naive696,444 -> 1,740,158 (2.50x), E4B withint8_per_token_head251,286 -> 445,506 (1.77x). Per token,unit_bytesgoes from 14336 / 28672 (full / window) to 6144 / 12288 on E2B, and from 28672 / 71680 to 16384 / 40960 on E4B; with the 8-bit formats from 7224 / 14560 to 3096 / 6240 on E2B and from 14448 / 36400 to 8256 / 20800 on E4B. The ratios come out a bit above 7/3 and 7/4 at small budgets because the window pool's fixed floor shrinks too.Greedy outputs, compared as text: 11 sequential requests (4 raw completions of 128 tokens with
ignore_eos, for the decode rate; a 4391-token prompt with one odd record in the middle and a question about it; a second question on the same prompt; the first one again, served from the prefix cache under radix; 4 chat requests), then 4 concurrent requests (both long ones and two chat ones). Every case matches main 11/11 and 4/4, the assistant-draft cases included, and so does a second E4B GGUF main start. The long prompt is well past the 512-token window. On the previous head the five cases above, and a runtime/v1/cache/rebuildto 60,000 pages on E2B followed by the same 15 requests, also matched main.Decode at bs = 1,
predicted_per_second, median of the 4 raw completions, main -> this PR: E2B 80.48 -> 81.11, E4B 43.39 -> 43.62, E2B int8 80.07 -> 80.79; with the assistant draft, E2B GGUF 158.0 -> 163.3, E4B GGUF 133.9 / 133.3 (two starts) -> 134.0, E4B 116.4 -> 117.3. Likely the 20 / 18 skipped stores per step, I did not profile it. On the previous head, prefill on the 4391-token prompt was the same within noise (E2B 374.5 / 379.1 -> 373.4 ms, E4B 657.4 -> 665.4 ms).ft serve --model google/gemma-4-E2B-it --port 1919 --enable-timings-report --max-running-requests 4 [--embed-weights host] [--cache-type naive] [--kv-cache-dtype int8_per_token_head|fp8_per_token_head] ft serve --model gemma-4-E4B-it-Q4_K_M.gguf --port 1919 --enable-timings-report --max-running-requests 4 --embed-weights host ft serve --model gemma-4-E4B-it-Q4_K_M.gguf --port 1919 --enable-timings-report --max-running-requests 4 --text-model-only --spec-algo mtp --spec-draft-model mtp-gemma-4-E4B-it-BF16.ggufTest
test_shared_layers_read_their_source_slab_and_own_noneintests/models/gemma4/test_e_series.pybuilds the pool from the E2B and E4B layer layouts, with the default format and withint8_per_token_head, and checks the slab counts and that each shared layer reads its source's buffers (and its scales for int8). It is red on main (7 / 28 and 7 / 35 slabs). It goes red when the copies come back, a shared layer gets the wrong source, or its scales stop following the source, and none of those crashes. The GGUF parser fills the field through the sameGemma4EArgs.kv_shared_layers; that line is covered by the GGUF runs above, not by this test.test_attention_reads_the_pairs_the_pool_mapschecks that every E2B / E4B attention module's shared and source flags are the list's,test_draft_layers_project_q_only_and_hand_over_nothingthat the assistant draft's layers still build q only, andtest_kv_shared_layers_with_no_earlier_source_are_refusedthe refusal. The two loader tests that build a 3-layer config now give the shared layer an earlier layer of its type, the shape every release has.tests/models tests/kvcache tests/scheduler tests/engine tests/attention tests/spec tests/checkpointon the A10, this head with main66190a9: main 1003 passed, 234 skipped, 5 failed; this PR 1011 passed, 234 skipped, 5 failed. The same 5 fail in both (qwen4_exp/test_qsa_backend.py::test_chunked_prefill_matches_one_shotx3,dflash/test_draft.py::test_block_forward_matches_referencefp32 x2).Not tested: TP, ROCm, a real 8 GB card (the A10 with memory held is a stand-in), RTX 40/50.