[Bugfix][GLM-5.3-Flash] kpool corruption with speculative decoding (#58454)

Signed-off-by: Matt Mastracci <matthew@mastracci.com> Signed-off-by: Robert Shaw <114415538+robertgshaw2-redhat@users.noreply.github.com> Co-authored-by: Yifan Qiao <yifanqiao@inferact.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Robert Shaw <114415538+robertgshaw2-redhat@users.noreply.github.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Matt Mastracci committed Sep 25, 2026 at 10:51 UTC 2617fe938355594c48d4512a2ef6b470962aac1a
5 files changed +208 -49
tests/kernels/test_kpool_decode_update_batched.py
+90 -4
@@ -293,8 +293,9 @@ def _run_kernel(kv, tail, tail_slot, key, score, ape, slot_map, pos):
293
294
295 @pytest.mark.parametrize("pool_size", [4, 16])
296 -def test_decode_writer_matches_prefill_writer(pool_size):
297 - """Compare production decode and prefill writers for pool sizes 4 and 16."""
296 +@pytest.mark.parametrize("ring_pools", [1, 2])
297 +def test_decode_writer_matches_prefill_writer(pool_size, ring_pools):
298 + ring = ring_pools * pool_size
299 n_pools, page, nblk = 8, 64, 4
300 n_tok = n_pools * pool_size
301 dev = "cuda"
@@ -317,14 +318,14 @@ def test_decode_writer_matches_prefill_writer(pool_size):
318
319 # One request owning tail block 0, fed one token per decode step.
320 kv_decode = torch.zeros_like(kv_prefill)
320 - tail = torch.zeros(nblk, 2, pool_size, HEAD_DIM, dtype=torch.bfloat16, device=dev)
321 + tail = torch.zeros(nblk, 2, ring, HEAD_DIM, dtype=torch.bfloat16, device=dev)
322 for t in range(n_tok):
323 completes = t % pool_size == pool_size - 1
324 kpool_decode_update_and_maybe_write_cache_batched(
325 kv_decode,
326 tail,
327 # token-granular: every token has a valid tail slot
327 - torch.tensor([[t % pool_size]], dtype=torch.int32, device=dev),
328 + torch.tensor([[t % ring]], dtype=torch.int32, device=dev),
329 k[t].view(1, 1, HEAD_DIM),
330 score[t].view(1, 1, HEAD_DIM),
331 ape,
@@ -351,6 +352,91 @@ def test_decode_writer_matches_prefill_writer(pool_size):
352 )
353
354
355 +@pytest.mark.parametrize("ring_pools", [1, 2])
356 +def test_rejected_draft_redo_needs_ring_slots(ring_pools):
357 + """With a one-pool ring, the drafts behind a rejected pool-completing draft
358 + overwrote the pool's earlier keys, so its redo compressed wrong keys."""
359 + pool, spec, page, nblk = 4, 3, 64, 2
360 + ring = ring_pools * pool
361 + dev = "cuda"
362 + torch.manual_seed(1)
363 + n_tok = 3 * pool
364 + k = torch.randn(n_tok, HEAD_DIM, dtype=torch.bfloat16, device=dev)
365 + score = torch.randn(n_tok, HEAD_DIM, dtype=torch.bfloat16, device=dev)
366 + ape = torch.randn(pool, HEAD_DIM, dtype=torch.float32, device=dev)
367 + kv_ref = torch.zeros(nblk, page, HEAD_DIM + 4, dtype=torch.uint8, device=dev)
368 + kpool_compress_and_write_cache(
369 + kv_ref,
370 + k.view(3, pool, HEAD_DIM),
371 + score.view(3, pool, HEAD_DIM),
372 + ape,
373 + torch.arange(3, dtype=torch.int64, device=dev),
374 + pool_size=pool,
375 + head_dim=HEAD_DIM,
376 + round_scale=ROUND_SCALE,
377 + )
378 +
379 + kv = torch.zeros_like(kv_ref)
380 + tail = torch.zeros(nblk, 2, ring, HEAD_DIM, dtype=torch.bfloat16, device=dev)
381 +
382 + def pool_bytes(cache, p):
383 + # Page layout: [page * HEAD_DIM bytes of K rows | page * 4 bytes of scales].
384 + flat = cache[0].reshape(-1)
385 + k_bytes = flat[p * HEAD_DIM : (p + 1) * HEAD_DIM]
386 + s_bytes = flat[page * HEAD_DIM + 4 * p : page * HEAD_DIM + 4 * (p + 1)]
387 + return torch.cat([k_bytes, s_bytes])
388 +
389 + def step(positions, keys, scores):
390 + pos = torch.tensor([positions], dtype=torch.int32, device=dev)
391 + slots = [(p // pool) if p % pool == pool - 1 else -1 for p in positions]
392 + kpool_decode_update_and_maybe_write_cache_batched(
393 + kv,
394 + tail,
395 + pos % ring,
396 + keys.view(1, -1, HEAD_DIM),
397 + scores.view(1, -1, HEAD_DIM),
398 + ape,
399 + torch.tensor([slots], dtype=torch.int32, device=dev),
400 + pos,
401 + pool,
402 + HEAD_DIM,
403 + round_scale=ROUND_SCALE,
404 + )
405 +
406 + for t in range(7):
407 + step([t], k[t], score[t])
408 + # Control: verified token 7 completes pool 1 before drafts 8..10 are stashed.
409 + drafts = torch.randn(spec, HEAD_DIM, dtype=torch.bfloat16, device=dev)
410 + draft_scores = torch.randn(spec, HEAD_DIM, dtype=torch.bfloat16, device=dev)
411 + step(
412 + [7, 8, 9, 10],
413 + torch.cat([k[7:8], drafts]),
414 + torch.cat([score[7:8], draft_scores]),
415 + )
416 + step([8, 9, 10, 11], k[8:12], score[8:12]) # all drafts rejected
417 + for p in (1, 2):
418 + assert torch.equal(pool_bytes(kv, p), pool_bytes(kv_ref, p)), p
419 +
420 + # Draft 7 completes pool 1 and is rejected. With a one-pool ring, drafts
421 + # 8 and 9 overwrite the slots of positions 4 and 5, which are read by the
422 + # redo of 7.
423 + kv.zero_()
424 + tail.zero_()
425 + for t in range(6):
426 + step([t], k[t], score[t])
427 + step(
428 + [6, 7, 8, 9],
429 + torch.cat([k[6:7], drafts]),
430 + torch.cat([score[6:7], draft_scores]),
431 + )
432 + step([7, 8, 9, 10], k[7:11], score[7:11])
433 + pool1_ok = torch.equal(pool_bytes(kv, 1), pool_bytes(kv_ref, 1))
434 + if ring_pools == 1:
435 + assert not pool1_ok, "expected a one-pool ring to corrupt pool 1"
436 + else:
437 + assert pool1_ok
438 +
439 +
440 def test_leading_invalid_tail_slot():
441 """A request whose FIRST token carries an invalid (-1) tail slot while a
442 later token is a real pool completion.
tests/v1/attention/test_kpool_tail_slot_mapping.py
+68 -11
@@ -24,6 +24,8 @@ from types import SimpleNamespace
24 import pytest
25 import torch
26
27 +from vllm.config import VllmConfig, set_current_vllm_config
28 +from vllm.models.glm5next.common.attention import Glm5NextTailCache
29 from vllm.v1.attention.backend import CommonAttentionMetadata
30 from vllm.v1.attention.backends.mla.indexer import (
31 KpoolTailBackend,
@@ -56,6 +58,30 @@ def test_tail_backend_layout_matches_kernel_pointer_arithmetic():
58 assert content_stride == 1
59
60
61 +@pytest.mark.parametrize(
62 + "num_speculative_tokens, ring", [(0, 4), (1, 8), (4, 8), (7, 16), (13, 32)]
63 +)
64 +def test_tail_ring_divides_the_attention_block(num_speculative_tokens, ring):
65 + """With 7 draft tokens the ring was 12 slots. 12 does not divide the
66 + 640-token KDA attention block, so the scheduler block grew to their lcm
67 + and prefix-cache hits were cut down to multiples of 1920."""
68 + with set_current_vllm_config(VllmConfig()):
69 + cache = Glm5NextTailCache(
70 + head_dim=128,
71 + dtype=torch.bfloat16,
72 + prefix="tail",
73 + cache_config=SimpleNamespace(block_size=640),
74 + index_kpool=KPOOL,
75 + )
76 + spec = cache.get_kv_cache_spec(
77 + SimpleNamespace(num_speculative_tokens=num_speculative_tokens)
78 + )
79 +
80 + assert spec.block_size == spec.sliding_window == ring
81 + assert ring >= KPOOL + num_speculative_tokens
82 + assert 640 % ring == 0
83 +
84 +
85 def test_tail_spec_opts_out_of_generic_slot_mapping():
86 """The tail row is one block wide (padded to the block-table alignment), so
87 the generic kernel's ``pos // kpool`` column index runs off the end of the
@@ -296,31 +322,30 @@ def test_builder_reuses_slot_mapping_storage():
322
323
324 class TailRingMirror:
299 - """Mirror of _kpool_tail_seed_kernel / _kpool_decode_update_batched_kernel
300 - addressing: block = tail_slot // kpool, ring offset = pos % kpool; a pool
301 - completing at pos reads ring slots (pool_start + s) % kpool and uses the
302 - current token's own K/score for the last member."""
325 + """Mirror of the tail-ring addressing in _kpool_tail_seed_kernel and
326 + _kpool_decode_update_batched_kernel."""
327
304 - def __init__(self, num_blocks, kpool=KPOOL):
328 + def __init__(self, num_blocks, kpool=KPOOL, ring=None):
329 self.kpool = kpool
306 - self.k = torch.full((num_blocks, kpool, 3), float("nan"))
307 - self.s = torch.full((num_blocks, kpool, 3), float("nan"))
330 + self.ring = ring or kpool
331 + self.k = torch.full((num_blocks, self.ring, 3), float("nan"))
332 + self.s = torch.full((num_blocks, self.ring, 3), float("nan"))
333
334 def stash(self, tail_slot, pos, k, s):
310 - blk, off = tail_slot // self.kpool, pos % self.kpool
335 + blk, off = tail_slot // self.ring, pos % self.ring
336 self.k[blk, off] = k
337 self.s[blk, off] = s
338
339 seed = stash # the seed kernel writes with the same addressing
340
341 def complete(self, tail_slot, pos, k, s):
317 - blk = tail_slot // self.kpool
342 + blk = tail_slot // self.ring
343 start = pos - (self.kpool - 1)
344 kk = torch.stack(
320 - [self.k[blk, (start + i) % self.kpool] for i in range(self.kpool)]
345 + [self.k[blk, (start + i) % self.ring] for i in range(self.kpool)]
346 )
347 ss = torch.stack(
323 - [self.s[blk, (start + i) % self.kpool] for i in range(self.kpool)]
348 + [self.s[blk, (start + i) % self.ring] for i in range(self.kpool)]
349 )
350 kk[-1], ss[-1] = k, s # is_current for the completing token
351 w = torch.softmax(ss, dim=0)
@@ -454,3 +479,35 @@ def test_triton_mapping_reads_strided_block_table():
479 for req, blk in enumerate(own_blocks):
480 start, end = int(qsl[req]), int(qsl[req + 1])
481 assert (got[start:end] // KPOOL == blk).all()
482 +
483 +
484 +@pytest.mark.parametrize("ring_pools", [1, 2])
485 +def test_rejected_completing_draft_needs_ring_slots(ring_pools):
486 + """With a one-pool ring, the drafts behind a rejected pool-completing draft
487 + overwrote the pool's earlier keys, so its redo compressed wrong keys."""
488 + ring_size = ring_pools * KPOOL
489 + truth = TailRingMirror(num_blocks=2, ring=ring_size)
490 + ring = TailRingMirror(num_blocks=2, ring=ring_size)
491 + block = 1
492 +
493 + def slot(pos):
494 + return block * ring_size + pos % ring_size
495 +
496 + for pos in range(4, 7):
497 + truth.stash(slot(pos), pos, *token_kv(0, pos))
498 + ring.stash(slot(pos), pos, *token_kv(0, pos))
499 + expected = truth.complete(slot(7), 7, *token_kv(0, 7))
500 +
501 + # Draft 7 completes the pool and is rejected. With a one-pool ring, drafts
502 + # 8..10 overwrite the slots of positions 4..6, which are read by the redo
503 + # of 7.
504 + for pos in range(7, 11):
505 + k, s = token_kv(9, pos) # draft values
506 + if pos % KPOOL == KPOOL - 1:
507 + ring.complete(slot(pos), pos, k, s)
508 + ring.stash(slot(pos), pos, k, s)
509 + redo = ring.complete(slot(7), 7, *token_kv(0, 7))
510 + if ring_pools == 1:
511 + assert not torch.allclose(redo, expected)
512 + else:
513 + torch.testing.assert_close(redo, expected)
vllm/models/glm5next/amd/ops/kpool_compress.py
+18 -16
@@ -327,37 +327,38 @@ def _kpool_tail_seed_kernel(
327 KPOOL_HEAD: tl.constexpr,
328 HEAD_DIM: tl.constexpr,
329 KPOOL: tl.constexpr,
330 + RING: tl.constexpr,
331 BLOCK_D: tl.constexpr,
332 ):
332 - """Copy token ``i``'s raw K + gate into its request's tail block.
333 + """Copy token ``i``'s raw K + gate into its request's tail ring.
334
335 Token ``i`` is among its request's last KPOOL tokens iff the token KPOOL
336 ahead belongs to a different tail block (or is past the batch / padding,
336 - slot < 0). ``tslot = block * KPOOL + pos % KPOOL``; the destination is
337 - ``tail[block, {0:K, 1:score}, pos % KPOOL, :]``.
337 + slot < 0). ``tslot = block * RING + pos % RING``; the destination is
338 + ``tail[block, {0:K, 1:score}, pos % RING, :]``.
339 """
340 i = tl.program_id(0)
341 t = tl.load(tslot_ptr + i).to(tl.int64)
342 if t < 0:
343 return
343 - blk = t // KPOOL # t >= 0 here, so trunc == floor
344 + blk = t // RING # t >= 0 here, so trunc == floor
345 ahead = tl.load(tslot_ptr + i + KPOOL, mask=i + KPOOL < n_tokens, other=-1).to(
346 tl.int64
347 )
348 # Match the torch semantics exactly: a negative ahead slot floors to a
349 # block id that differs from every real block -> token is in the tail.
350 # Only divide non-negative slots (Triton int div truncates, torch floors).
350 - if ahead >= 0 and ahead // KPOOL == blk:
351 + if ahead >= 0 and ahead // RING == blk:
352 return
353 offs = tl.arange(0, BLOCK_D)
354 m = offs < HEAD_DIM
355 block_base = blk * TAIL_BLOCK_ELEMS
355 - base = block_base + (t % KPOOL) * HEAD_DIM
356 + base = block_base + (t % RING) * HEAD_DIM
357 k = tl.load(key_ptr + i * HEAD_DIM + offs, mask=m)
358 s = tl.load(score_ptr + i * HEAD_DIM + offs, mask=m)
359 tl.store(tail_ptr + base + offs, k, mask=m)
360 tl.store(
360 - tail_ptr + block_base + KPOOL_HEAD + (t % KPOOL) * HEAD_DIM + offs, s, mask=m
361 + tail_ptr + block_base + KPOOL_HEAD + (t % RING) * HEAD_DIM + offs, s, mask=m
362 )
363
364
@@ -385,6 +386,7 @@ def kpool_seed_tail_cache(
386 KPOOL_HEAD=tail_kv_cache.stride(1),
387 HEAD_DIM=head_dim,
388 KPOOL=kpool,
389 + RING=tail_kv_cache.shape[2],
390 BLOCK_D=triton.next_power_of_2(head_dim),
391 )
392
@@ -412,6 +414,7 @@ def _kpool_decode_update_batched_kernel(
414 PAGE_SIZE: tl.constexpr,
415 BUF_NUMEL_PER_PAGE: tl.constexpr,
416 POOL_SIZE: tl.constexpr,
417 + RING: tl.constexpr,
418 TAIL_BLOCK_ELEMS: tl.constexpr,
419 KPOOL_HEAD: tl.constexpr,
420 HEAD_DIM: tl.constexpr,
@@ -428,10 +431,7 @@ def _kpool_decode_update_batched_kernel(
431 the tail-ring slots that tokens t < t* (same request) just stashed in this
432 same invocation. ``tl.range`` iterates sequentially within the program, so
433 those stashes are visible to the later completion read. Cross-request
431 - programs are independent (distinct tail blocks). With NEXT_N < POOL_SIZE
432 - (the spec-verify case: NEXT_N ~= num_spec+1, POOL_SIZE=16) at most one
433 - completion can occur per request per call, but the ordered loop is correct
434 - for any NEXT_N.
434 + programs are independent (distinct tail blocks). RING >= POOL_SIZE.
435 """
436 req = tl.program_id(0)
437 offs = tl.arange(0, BLOCK_D)
@@ -445,7 +445,7 @@ def _kpool_decode_update_batched_kernel(
445 pos_valid = (cache_loc >= 0) & (pos >= 0)
446
447 slot = safe_pos % POOL_SIZE
448 - phys_slot = safe_pos % POOL_SIZE
448 + phys_slot = safe_pos % RING
449
450 # Derive the tail block from THIS token's tail_slot (the request's block
451 # is constant across a pool, but a padded / invalid entry carries a
@@ -453,7 +453,7 @@ def _kpool_decode_update_batched_kernel(
453 # token's base address). Clamp so an invalid entry can never form an
454 # out-of-bounds base; the accesses below are gated on pos_valid anyway.
455 tail_slot = tl.load(tail_slot_mapping_ptr + idx)
456 - block = tl.maximum(tail_slot, 0).to(tl.int64) // POOL_SIZE
456 + block = tl.maximum(tail_slot, 0).to(tl.int64) // RING
457 block_base = block * TAIL_BLOCK_ELEMS
458
459 # The tail-ring stash must run for EVERY real token, so it is gated on
@@ -481,7 +481,7 @@ def _kpool_decode_update_batched_kernel(
481 max_score = tl.full((BLOCK_D,), -float("inf"), tl.float32)
482 for pool_slot in tl.static_range(0, POOL_SIZE):
483 is_current = pool_slot == slot
484 - phys = (pool_logical_start + pool_slot) % POOL_SIZE
484 + phys = (pool_logical_start + pool_slot) % RING
485 score_buf = tl.load(
486 tail_kv_ptr + block_base + KPOOL_HEAD + phys * HEAD_DIM + offs,
487 mask=dim_mask,
@@ -499,7 +499,7 @@ def _kpool_decode_update_batched_kernel(
499 denom = tl.full((BLOCK_D,), 0.0, tl.float32)
500 for pool_slot in tl.static_range(0, POOL_SIZE):
501 is_current = pool_slot == slot
502 - phys = (pool_logical_start + pool_slot) % POOL_SIZE
502 + phys = (pool_logical_start + pool_slot) % RING
503 score_buf = tl.load(
504 tail_kv_ptr + block_base + KPOOL_HEAD + phys * HEAD_DIM + offs,
505 mask=dim_mask,
@@ -609,7 +609,8 @@ def kpool_decode_update_and_maybe_write_cache_batched(
609 return
610 assert tail_kv_cache.ndim == 4
611 assert tail_kv_cache.shape[1] == 2
612 - assert tail_kv_cache.shape[2] == pool_size
612 + ring = tail_kv_cache.shape[2]
613 + assert ring >= pool_size and ring % pool_size == 0, (ring, pool_size)
614 assert tail_kv_cache.shape[3] == head_dim
615 assert tail_kv_cache.dtype == torch.bfloat16
616 assert key.ndim == 3 and key.shape[2] == head_dim
@@ -658,6 +659,7 @@ def kpool_decode_update_and_maybe_write_cache_batched(
659 PAGE_SIZE=page_size,
660 BUF_NUMEL_PER_PAGE=buf.stride(0),
661 POOL_SIZE=pool_size,
662 + RING=ring,
663 TAIL_BLOCK_ELEMS=tail_kv_cache.stride(0),
664 KPOOL_HEAD=tail_kv_cache.stride(1),
665 HEAD_DIM=head_dim,
vllm/models/glm5next/common/attention.py
+15 -3
@@ -34,6 +34,7 @@ from vllm.models.glm5next.sparse_indexer import SparseAttnIndexerKpool
34 from vllm.platforms import current_platform
35 from vllm.transformers_utils.configs.glm5_next import Glm5NextConfig
36 from vllm.utils.deep_gemm import PAGED_MQA_PAGE_SIZES
37 +from vllm.utils.math_utils import cdiv, next_power_of_2
38 from vllm.v1.kv_cache_interface import KpoolTailSpec, MLAAttentionSpec
39
40 logger = init_logger(__name__)
@@ -160,7 +161,7 @@ class Glm5NextTailCache(DeepseekV32IndexerCache):
161 """Paged circular buffer for the kpool indexer's in-progress (tail) pool.
162
163 Holds the trailing incomplete pool's raw K + gate score: one block of
163 - ``index_kpool`` slots per request, overwritten in place by ``pos % kpool``
164 + ``ring`` slots per request, overwritten in place by ``pos % ring``
165 as decode/spec-decode advances. Prefill seeds it (instead of discarding the
166 tail raw K+gate); the connector transfers it across PD; decode reads it to
167 compress the boundary pool correctly. ``KpoolTailSpec`` /
@@ -190,13 +191,24 @@ class Glm5NextTailCache(DeepseekV32IndexerCache):
191 def get_kv_cache_spec(self, vllm_config: VllmConfig):
192 # The two head slots form [K, gate score] in the generic
193 # [block, head, state, content] cache view.
194 + # Drafts are stashed before acceptance. With a one-pool ring, the
195 + # drafts behind a rejected pool-completing draft overwrite the keys
196 + # read by its redo.
197 + span = self._index_kpool + vllm_config.num_speculative_tokens
198 + ring = self._index_kpool * next_power_of_2(cdiv(span, self._index_kpool))
199 + # ring must divide the attention block size (a multiple of 128).
200 + assert self.cache_config.block_size % ring == 0, (
201 + f"Glm5NextTailCache: cache_config.block_size "
202 + f"({self.cache_config.block_size}) must be a multiple of the "
203 + f"tail ring ({ring})"
204 + )
205 return KpoolTailSpec(
194 - block_size=self._index_kpool,
206 + block_size=ring,
207 num_kv_heads=2,
208 head_size=self.head_dim,
209 head_size_v=0,
210 dtype=torch.bfloat16,
199 - sliding_window=self._index_kpool,
211 + sliding_window=ring,
212 )
213
214 def get_attn_backend(self):
vllm/models/glm5next/nvidia/ops/kpool_compress.py
+17 -15
@@ -387,14 +387,15 @@ def _kpool_tail_seed_kernel(
387 KPOOL_HEAD: tl.constexpr,
388 HEAD_DIM: tl.constexpr,
389 KPOOL: tl.constexpr,
390 + RING: tl.constexpr,
391 BLOCK_D: tl.constexpr,
392 ):
392 - """Copy token ``i``'s raw K + gate into its request's tail block.
393 + """Copy token ``i``'s raw K + gate into its request's tail ring.
394
395 Token ``i`` is among its request's last KPOOL tokens iff the token KPOOL
396 ahead belongs to a different tail block (or is past the batch / padding,
396 - slot < 0). ``tslot = block * KPOOL + pos % KPOOL``; the destination is
397 - ``tail[block, {0:K, 1:score}, pos % KPOOL, :]``.
397 + slot < 0). ``tslot = block * RING + pos % RING``; the destination is
398 + ``tail[block, {0:K, 1:score}, pos % RING, :]``.
399
400 The tail cache aliases the indexer cache with the indexer's (padded) block
401 stride, so blocks are addressed through ``TAIL_BLOCK_ELEMS`` /
@@ -405,18 +406,18 @@ def _kpool_tail_seed_kernel(
406 t = tl.load(tslot_ptr + i).to(tl.int64)
407 if t < 0:
408 return
408 - blk = t // KPOOL # t >= 0 here, so trunc == floor
409 + blk = t // RING # t >= 0 here, so trunc == floor
410 ahead = tl.load(tslot_ptr + i + KPOOL, mask=i + KPOOL < n_tokens, other=-1).to(
411 tl.int64
412 )
413 # Match the torch semantics exactly: a negative ahead slot floors to a
414 # block id that differs from every real block -> token is in the tail.
415 # Only divide non-negative slots (Triton int div truncates, torch floors).
415 - if ahead >= 0 and ahead // KPOOL == blk:
416 + if ahead >= 0 and ahead // RING == blk:
417 return
418 offs = tl.arange(0, BLOCK_D)
419 m = offs < HEAD_DIM
419 - base = blk * TAIL_BLOCK_ELEMS + (t % KPOOL) * HEAD_DIM
420 + base = blk * TAIL_BLOCK_ELEMS + (t % RING) * HEAD_DIM
421 k = tl.load(key_ptr + i * HEAD_DIM + offs, mask=m)
422 s = tl.load(score_ptr + i * HEAD_DIM + offs, mask=m)
423 tl.store(tail_ptr + base + offs, k, mask=m)
@@ -449,6 +450,7 @@ def kpool_seed_tail_cache(
450 KPOOL_HEAD=tail_kv_cache.stride(1),
451 HEAD_DIM=head_dim,
452 KPOOL=kpool,
453 + RING=tail_kv_cache.shape[2],
454 BLOCK_D=triton.next_power_of_2(head_dim),
455 )
456
@@ -476,6 +478,7 @@ def _kpool_decode_update_batched_kernel(
478 PAGE_SIZE: tl.constexpr,
479 BUF_NUMEL_PER_PAGE: tl.constexpr,
480 POOL_SIZE: tl.constexpr,
481 + RING: tl.constexpr,
482 TAIL_BLOCK_ELEMS: tl.constexpr,
483 KPOOL_HEAD: tl.constexpr,
484 HEAD_DIM: tl.constexpr,
@@ -490,10 +493,7 @@ def _kpool_decode_update_batched_kernel(
493 the tail-ring slots that tokens t < t* (same request) just stashed in this
494 same invocation. ``tl.range`` iterates sequentially within the program, so
495 those stashes are visible to the later completion read. Cross-request
493 - programs are independent (distinct tail blocks). With NEXT_N < POOL_SIZE
494 - (the spec-verify case: NEXT_N ~= num_spec+1, POOL_SIZE=16) at most one
495 - completion can occur per request per call, but the ordered loop is correct
496 - for any NEXT_N.
496 + programs are independent (distinct tail blocks). RING >= POOL_SIZE.
497 """
498 req = tl.program_id(0)
499 offs = tl.arange(0, BLOCK_D)
@@ -507,7 +507,7 @@ def _kpool_decode_update_batched_kernel(
507 pos_valid = (cache_loc >= 0) & (pos >= 0)
508
509 slot = safe_pos % POOL_SIZE
510 - phys_slot = safe_pos % POOL_SIZE
510 + phys_slot = safe_pos % RING
511
512 # Derive the tail block from THIS token's tail_slot (the request's block
513 # is constant across a pool, but a padded / invalid entry carries a
@@ -515,7 +515,7 @@ def _kpool_decode_update_batched_kernel(
515 # token's base address). Clamp so an invalid entry can never form an
516 # out-of-bounds base; the accesses below are gated on pos_valid anyway.
517 tail_slot = tl.load(tail_slot_mapping_ptr + idx)
518 - block = tl.maximum(tail_slot, 0).to(tl.int64) // POOL_SIZE
518 + block = tl.maximum(tail_slot, 0).to(tl.int64) // RING
519 block_base = block * TAIL_BLOCK_ELEMS
520
521 # The tail-ring stash must run for EVERY real token, so it is gated on
@@ -543,7 +543,7 @@ def _kpool_decode_update_batched_kernel(
543 max_score = tl.full((BLOCK_D,), -float("inf"), tl.float32)
544 for pool_slot in tl.static_range(0, POOL_SIZE):
545 is_current = pool_slot == slot
546 - phys = (pool_logical_start + pool_slot) % POOL_SIZE
546 + phys = (pool_logical_start + pool_slot) % RING
547 score_buf = tl.load(
548 tail_kv_ptr + block_base + KPOOL_HEAD + phys * HEAD_DIM + offs,
549 mask=dim_mask,
@@ -561,7 +561,7 @@ def _kpool_decode_update_batched_kernel(
561 denom = tl.full((BLOCK_D,), 0.0, tl.float32)
562 for pool_slot in tl.static_range(0, POOL_SIZE):
563 is_current = pool_slot == slot
564 - phys = (pool_logical_start + pool_slot) % POOL_SIZE
564 + phys = (pool_logical_start + pool_slot) % RING
565 score_buf = tl.load(
566 tail_kv_ptr + block_base + KPOOL_HEAD + phys * HEAD_DIM + offs,
567 mask=dim_mask,
@@ -671,7 +671,8 @@ def kpool_decode_update_and_maybe_write_cache_batched(
671 return
672 assert tail_kv_cache.ndim == 4
673 assert tail_kv_cache.shape[1] == 2
674 - assert tail_kv_cache.shape[2] == pool_size
674 + ring = tail_kv_cache.shape[2]
675 + assert ring >= pool_size and ring % pool_size == 0, (ring, pool_size)
676 assert tail_kv_cache.shape[3] == head_dim
677 assert tail_kv_cache.dtype == torch.bfloat16
678 assert key.ndim == 3 and key.shape[2] == head_dim
@@ -717,6 +718,7 @@ def kpool_decode_update_and_maybe_write_cache_batched(
718 PAGE_SIZE=page_size,
719 BUF_NUMEL_PER_PAGE=buf.stride(0),
720 POOL_SIZE=pool_size,
721 + RING=ring,
722 TAIL_BLOCK_ELEMS=tail_kv_cache.stride(0),
723 KPOOL_HEAD=tail_kv_cache.stride(1),
724 HEAD_DIM=head_dim,