_glm53_recoverssm_boundary_test.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. #!/usr/bin/env python3
  2. """KDA RecoverSSM align-mode commit with a block table filled only as far as
  3. align mode allocates it.
  4. Align mode allocates cdiv(num_computed + query_len, block_size) KDA blocks per
  5. request. The runner's gathered block table keeps whatever an earlier request
  6. left past that. A commit whose accepted tokens end exactly on a block boundary
  7. used to store the request's state in column n // block_size, one past the
  8. allocation, and the state was lost (2026-09-29).
  9. One commit over rows that each end in a different place relative to a
  10. boundary (block_size 16, up to 7 drafts):
  11. exact, all accepted nc 24 + 8 of 8 = 32 (the bug)
  12. exact, all accepted, k=2 nc 29 + 3 of 3 = 32 (the bug)
  13. exact, partial nc 26 + 6 of 8 = 32
  14. exact, partial, k=2 nc 30 + 2 of 3 = 32
  15. past a boundary nc 28 + 8 of 8 = 36
  16. mid-block nc 17 + 3 of 8 = 20
  17. from a boundary nc 32 + 8 of 8 = 40
  18. Each row's columns past cdiv(nc + q, 16) point at unallocated blocks, or at
  19. the null block (0) in the second pass. Checks, bit for bit against the stock
  20. fused_recurrent_kda:
  21. - the state after the last accepted token is in the allocated block holding
  22. that token, with the conv window at column 0
  23. - a row that reaches a boundary has the state after the boundary token in
  24. the boundary block
  25. - no other block changed
  26. - record_state_blocks points the running state column at that block
  27. Fails on the old column math (n // block_size) and passes on the new one.
  28. Needs a CUDA device and the glm53 image's vLLM, no weights. The module is
  29. found as in _glm53_recoverssm_test.py. On a GB10 that is not serving:
  30. mkdir -p /tmp/rs && cp dev/patch-tests/_glm53_recoverssm_boundary_test.py \\
  31. experimental/fixes/recoverssm.py /tmp/rs/
  32. sudo docker run --rm --gpus all --ipc=host -v /tmp/rs:/rs -w / \\
  33. --entrypoint python3 <glm53 image> /rs/_glm53_recoverssm_boundary_test.py
  34. It allocates about 250 MB of GPU memory.
  35. """
  36. import importlib.util
  37. import os
  38. import sys
  39. from pathlib import Path
  40. from types import SimpleNamespace
  41. import torch
  42. from vllm.model_executor.layers.mamba.mamba_utils import is_conv_state_dim_first
  43. from vllm.models.glm5next.nvidia.ops.third_party.kda import fused_recurrent_kda
  44. from vllm.v1.attention.backends.recoverssm_metadata import (
  45. RecoverSSMPostprocessMetadata,
  46. )
  47. def _load_module():
  48. name = "vllm.models.glm5next.common.recoverssm"
  49. try:
  50. return importlib.import_module(name)
  51. except ImportError:
  52. pass
  53. here = Path(__file__).resolve().parent
  54. candidates = [
  55. os.environ.get("RECOVERSSM_PY"),
  56. here / "recoverssm.py",
  57. here.parent.parent / "experimental" / "fixes" / "recoverssm.py",
  58. ]
  59. for path in candidates:
  60. if path and Path(path).is_file():
  61. spec = importlib.util.spec_from_file_location(name, path)
  62. module = importlib.util.module_from_spec(spec)
  63. sys.modules[name] = module
  64. spec.loader.exec_module(module)
  65. print(f"recoverssm module: {path}")
  66. return module
  67. raise SystemExit("recoverssm.py not found; set RECOVERSSM_PY")
  68. rs = _load_module()
  69. dev = torch.device("cuda")
  70. H, D = 16, 128 # per rank at TP=4
  71. PROJ = H * D
  72. CONV_DIM = 3 * PROJ
  73. WIDTH = 4
  74. HIST = WIDTH - 1
  75. LOWER_BOUND = -5.0
  76. DTYPE = torch.bfloat16
  77. K = 7
  78. Q = K + 1
  79. BS = 16 # mamba block size
  80. W = 6 # block table width
  81. NUM_LAYERS = 2
  82. DS = is_conv_state_dim_first()
  83. # (name, num_computed, query_len, num_sampled)
  84. CASES = [
  85. ("exact, all accepted", 24, 8, 8),
  86. ("exact, all accepted, k=2", 29, 3, 3),
  87. ("exact, partial", 26, 8, 6),
  88. ("exact, partial, k=2", 30, 3, 2),
  89. ("past a boundary", 28, 8, 8),
  90. ("mid-block", 17, 8, 3),
  91. ("from a boundary", 32, 8, 8),
  92. ]
  93. ROWS = len(CASES)
  94. LINES = 1 + ROWS * W
  95. def cdiv(a: int, b: int) -> int:
  96. return -(-a // b)
  97. def block_id(row: int, col: int) -> int:
  98. return 1 + row * W + col
  99. def conv_pool(lines: int) -> tuple[torch.Tensor, torch.Tensor]:
  100. """(raw kv_cache[0], its (lines, dim, state_len) view)."""
  101. state_len = HIST + K
  102. if DS:
  103. raw = torch.zeros(lines, CONV_DIM, state_len, device=dev, dtype=DTYPE)
  104. return raw, raw
  105. raw = torch.zeros(lines, state_len, CONV_DIM, device=dev, dtype=DTYPE)
  106. return raw, raw.transpose(-1, -2)
  107. def make_layer(g: torch.Generator):
  108. layer = SimpleNamespace()
  109. layer.A_log = torch.log(
  110. torch.rand(1, 1, H, 1, device=dev, generator=g) * 8 + 1
  111. ).float()
  112. layer.dt_bias = (torch.randn(PROJ, device=dev, generator=g) * 0.5).float()
  113. layer.kda_lower_bound = LOWER_BOUND
  114. conv_raw, layer.conv = conv_pool(LINES)
  115. layer.conv.copy_(torch.randn(layer.conv.shape, device=dev, generator=g).to(DTYPE))
  116. layer.rec = torch.randn(LINES, H, D, D, device=dev, generator=g) * 0.1
  117. layer.kv_cache = (conv_raw, layer.rec)
  118. layer.recoverssm_records = rs.allocate_records(ROWS, H, D, Q, DTYPE, dev)
  119. return layer
  120. def run(unallocated_fill: str, seed: int) -> list[str]:
  121. g = torch.Generator(device=dev).manual_seed(seed)
  122. layers = [make_layer(g) for _ in range(NUM_LAYERS)]
  123. ctx = rs.Glm5NextRecoverSSMCommitContext.create(
  124. layers, spec_query_len=Q, max_num_reqs=ROWS
  125. )
  126. block_table = torch.zeros(ROWS, W, dtype=torch.int32)
  127. source = []
  128. for i, (_, nc, q, _) in enumerate(CASES):
  129. allocated = cdiv(nc + q, BS)
  130. assert allocated < W
  131. for c in range(W):
  132. if c < allocated or unallocated_fill == "stale":
  133. block_table[i, c] = block_id(i, c)
  134. # The verify reads the block the runner's pre-step copy moved the
  135. # state to: (seq_len - 1) // block_size.
  136. source.append(block_id(i, (nc + q - 1) // BS))
  137. block_table = block_table.to(dev)
  138. idx = torch.tensor(source, dtype=torch.int32, device=dev)
  139. qlens = [q for _, _, q, _ in CASES]
  140. qsl_cpu = torch.zeros(ROWS + 1, dtype=torch.int32)
  141. qsl_cpu[1:] = torch.cumsum(torch.tensor(qlens), 0)
  142. qsl = qsl_cpu.to(dev)
  143. T = int(qsl_cpu[-1])
  144. num_computed = torch.tensor([nc for _, nc, _, _ in CASES], dtype=torch.int32, device=dev)
  145. num_sampled = torch.tensor([n for _, _, _, n in CASES], dtype=torch.int32, device=dev)
  146. # Stock reference: row i's state after token t goes to scratch slot
  147. # 1 + i * Q + t, starting from its checkpoint in slot 1 + i * Q.
  148. ref_cols = (
  149. 1 + torch.arange(ROWS * Q, dtype=torch.int32, device=dev)
  150. ).view(ROWS, Q)
  151. ones = torch.ones(ROWS, dtype=torch.int32, device=dev)
  152. refs, before = [], []
  153. for layer in layers:
  154. qkv = [torch.randn(1, T, H, D, device=dev, generator=g).to(DTYPE) for _ in range(3)]
  155. g1 = torch.randn(1, T, H, D, device=dev, generator=g).to(DTYPE)
  156. beta = torch.randn(1, T, H, device=dev, generator=g).to(DTYPE)
  157. scratch = torch.zeros(1 + ROWS * Q, H, D, D, device=dev, dtype=torch.float32)
  158. scratch[ref_cols[:, 0].long()] = layer.rec[idx.long()]
  159. fused_recurrent_kda(
  160. q=qkv[0], k=qkv[1], v=qkv[2], g=g1, beta=beta, initial_state=scratch,
  161. use_qk_l2norm_in_kernel=True, cu_seqlens=qsl, ssm_state_indices=ref_cols,
  162. num_accepted_tokens=ones, sigmoid_beta=True, a_log=layer.A_log,
  163. g_bias=layer.dt_bias, compute_gate=True, lower_bound=LOWER_BOUND,
  164. )
  165. refs.append(scratch)
  166. rs.kda_recoverssm_verify(
  167. q=qkv[0], k=qkv[1], v=qkv[2], g=g1, beta=beta, a_log=layer.A_log,
  168. g_bias=layer.dt_bias, lower_bound=LOWER_BOUND,
  169. checkpoint_state=layer.rec, records=layer.recoverssm_records,
  170. query_start_loc=qsl, state_indices=idx,
  171. )
  172. # The conv state as the verify's conv update leaves it is random here:
  173. # the commit only moves windows, and the checks read them from this copy.
  174. before.append((layer.rec.clone(), layer.conv.clone()))
  175. ctx.commit(
  176. num_sampled, idx, qsl,
  177. block_table=block_table, num_computed_tokens=num_computed, mamba_block_size=BS,
  178. )
  179. state_idx = torch.zeros(ROWS, dtype=torch.int32, device=dev)
  180. num_accepted = torch.full((ROWS,), 9, dtype=torch.int32, device=dev)
  181. rs.record_state_blocks(
  182. RecoverSSMPostprocessMetadata(
  183. num_spec_decodes=ROWS,
  184. request_indices=None,
  185. block_table=block_table,
  186. num_computed_tokens=num_computed,
  187. block_size=BS,
  188. ),
  189. torch.arange(ROWS, dtype=torch.int32, device=dev),
  190. num_sampled,
  191. state_idx,
  192. num_accepted,
  193. )
  194. torch.cuda.synchronize()
  195. failures = []
  196. def fail(msg: str) -> None:
  197. failures.append(f"[{unallocated_fill} seed={seed}] {msg}")
  198. written = set()
  199. for i, (name, nc, q, n) in enumerate(CASES):
  200. final_col = (nc + n - 1) // BS
  201. if final_col >= cdiv(nc + q, BS):
  202. fail(f"{name}: final column {final_col} is not allocated")
  203. continue
  204. final_b = block_id(i, final_col)
  205. written.add(final_b)
  206. next_boundary = (nc // BS + 1) * BS
  207. boundary_b = None
  208. if nc + n >= next_boundary:
  209. boundary_b = block_id(i, next_boundary // BS - 1)
  210. written.add(boundary_b)
  211. src = source[i]
  212. for li, layer in enumerate(layers):
  213. ref = refs[li]
  214. _, conv0 = before[li]
  215. if not torch.equal(layer.rec[final_b], ref[int(ref_cols[i, n - 1])]):
  216. fail(f"{name} layer {li}: state after token {nc + n} not in block "
  217. f"{final_b} (column {final_col})")
  218. if not torch.equal(layer.conv[final_b, :, :HIST], conv0[src, :, n - 1 : n - 1 + HIST]):
  219. fail(f"{name} layer {li}: conv window not in block {final_b}")
  220. if boundary_b is not None:
  221. blen = next_boundary - nc
  222. if not torch.equal(layer.rec[boundary_b], ref[int(ref_cols[i, blen - 1])]):
  223. fail(f"{name} layer {li}: boundary state after token "
  224. f"{next_boundary} not in block {boundary_b}")
  225. if not torch.equal(
  226. layer.conv[boundary_b, :, :HIST], conv0[src, :, blen - 1 : blen - 1 + HIST]
  227. ):
  228. fail(f"{name} layer {li}: boundary conv window not in block {boundary_b}")
  229. if int(state_idx[i]) != final_col:
  230. fail(f"{name}: recorded state column {int(state_idx[i])}, want {final_col}")
  231. if int(num_accepted[i]) != 1:
  232. fail(f"{name}: num_accepted {int(num_accepted[i])}, want 1")
  233. for li, layer in enumerate(layers):
  234. rec0, conv0 = before[li]
  235. for b in range(1, LINES):
  236. if b in written:
  237. continue
  238. if not torch.equal(layer.rec[b], rec0[b]):
  239. fail(f"layer {li}: recurrent state of block {b} changed")
  240. if not torch.equal(layer.conv[b], conv0[b]):
  241. fail(f"layer {li}: conv state of block {b} changed")
  242. return failures
  243. failures = []
  244. for fill in ("stale", "null"):
  245. for seed in (0, 1):
  246. failures += run(fill, seed)
  247. for msg in failures[:20]:
  248. print(" FAIL", msg)
  249. if failures:
  250. print(f"RECOVERSSM BOUNDARY: FAIL ({len(failures)} failures)")
  251. sys.exit(1)
  252. print(f"RECOVERSSM BOUNDARY: PASS ({len(CASES)} cases x 2 fills x 2 seeds)")