test_indexer_chunked_v3.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. """CPU equivalence test (v2, candidate_metadata variant): chunked dense indexer vs the stock
  2. function lifted verbatim from the branch's deepseek_v4_backend.py, both driven by the
  3. same deterministic fake kernels. No GPU, no sglang import.
  4. Run: python3 test_indexer_chunked.py /path/to/backend-ours.py
  5. """
  6. import re
  7. import sys
  8. import types
  9. from types import SimpleNamespace
  10. import torch
  11. BACKEND_SRC = sys.argv[1] if len(sys.argv) > 1 else "backend-f80c91a4b.py"
  12. TOPK = 4
  13. HEADS = 2
  14. # ----- deterministic fake kernels (row-invariant, so chunking cannot change values) -----
  15. def ceil_align(x, a):
  16. return (x + a - 1) // a * a
  17. def _as_int_list(x):
  18. return [int(v) for v in x] if x is not None else None
  19. def _dense_fp4_mqa_logits(q_fp4, kv_fp4, weights, ks, ke, max_seqlen_k):
  20. q, _ = q_fp4
  21. rows = q.shape[0]
  22. # score depends only on the row's own token id (q[:,0,0]) and the column: row-invariant
  23. tok = q[:, 0, 0].to(torch.float32)
  24. j = torch.arange(max_seqlen_k, dtype=torch.float32)
  25. logits = ((tok[:, None] * 31 + j[None, :] * 7) % 97) + 0.01 * j[None, :]
  26. # garbage beyond ke - ks, as the real kernel leaves it
  27. valid = j[None, :] < (ke - ks).to(torch.float32)[:, None]
  28. return torch.where(valid, logits, torch.full_like(logits, 12345.0))
  29. def topk_transform_ragged_v2(logits, lens, out_offsets, out_indices):
  30. rows, width = logits.shape
  31. k = out_indices.shape[1]
  32. for r in range(rows):
  33. n = int(lens[r])
  34. sc = logits[r, :n]
  35. if n == 0:
  36. out_indices[r].fill_(-1)
  37. continue
  38. order = torch.argsort(sc, descending=True, stable=True)[:k]
  39. out = torch.full((k,), -1, dtype=torch.int32)
  40. out[: order.shape[0]] = (order + int(out_offsets[r])).to(torch.int32)
  41. out_indices[r] = out
  42. def _mask_topk_scores(scores, indices, offsets=None):
  43. columns = indices.to(torch.int64)
  44. if offsets is not None:
  45. columns = columns - offsets[:, None]
  46. selected_scores = scores.gather(1, columns.clamp(0, scores.shape[1] - 1))
  47. valid = (columns >= 0) & (columns < scores.shape[1]) & (selected_scores > -torch.inf)
  48. return indices.masked_fill(~valid, -1)
  49. def select_candidate_blocks(scores, lens, topk_blocks, block_size):
  50. rows, lc = scores.shape
  51. nb = (lc + block_size - 1) // block_size
  52. mask = torch.zeros((rows, lc), dtype=torch.bool)
  53. for r in range(rows):
  54. bs = torch.full((nb,), -torch.inf)
  55. for b in range(nb):
  56. seg = scores[r, b * block_size : min(lc, (b + 1) * block_size)]
  57. seg = seg[seg > -torch.inf]
  58. if seg.numel():
  59. bs[b] = seg.max()
  60. keep = torch.argsort(bs, descending=True, stable=True)[:topk_blocks]
  61. for b in keep.tolist():
  62. if bs[b] > -torch.inf:
  63. mask[r, b * block_size : min(lc, (b + 1) * block_size)] = True
  64. return mask
  65. class CandidateMasks:
  66. def __init__(self, request_masks):
  67. self.request_masks = request_masks
  68. def published_masks(candidate):
  69. assert isinstance(candidate, CandidateMasks), "candidate masks missing"
  70. return candidate
  71. mask_topk_scores = _mask_topk_scores
  72. def quantize_fp4_indexer_tensor(q, rne=True):
  73. # q: [T*H, 128] -> ([T*H, 64] int8, [T*H] int32); keep the token id in column 0
  74. return q[:, :64].to(torch.int8), torch.zeros(q.shape[0], dtype=torch.int32)
  75. fake_fp4 = types.ModuleType("sglang.kernels.ops.attention.dsv4.fp4_indexer")
  76. fake_fp4.quantize_fp4_indexer_tensor = quantize_fp4_indexer_tensor
  77. for name in ("sglang", "sglang.kernels", "sglang.kernels.ops", "sglang.kernels.ops.attention",
  78. "sglang.kernels.ops.attention.dsv4"):
  79. sys.modules.setdefault(name, types.ModuleType(name))
  80. sys.modules["sglang.kernels.ops.attention.dsv4.fp4_indexer"] = fake_fp4
  81. fake_module = types.ModuleType("deepseek_v4_backend_fake")
  82. fake_module.get_parallel = lambda: None
  83. for name, obj in dict(
  84. _dense_fp4_mqa_logits=_dense_fp4_mqa_logits, topk_transform_ragged_v2=topk_transform_ragged_v2,
  85. _mask_topk_scores=_mask_topk_scores, select_candidate_blocks=select_candidate_blocks,
  86. ceil_align=ceil_align, _as_int_list=_as_int_list, torch=torch,
  87. _TORCH_INDEXER_SCORE_BUDGET_BYTES=1 << 30, CandidateMasks=CandidateMasks, published_masks=published_masks, mask_topk_scores=mask_topk_scores,
  88. ).items():
  89. setattr(fake_module, name, obj)
  90. # ----- lift the stock methods verbatim from the image's source -----
  91. src = open(BACKEND_SRC).read()
  92. def lift(name):
  93. m = re.search(r"\n def " + name + r"\(.*?(?=\n def |\Z)", src, re.S)
  94. assert m, name
  95. body = "\n".join(line[4:] if line.startswith(" ") else line for line in m.group(0).splitlines())
  96. ns = dict(vars(fake_module))
  97. exec(body, ns)
  98. return ns[name]
  99. stock_dense = lift("_low_ratio_index_topk_dense")
  100. stock_publish = lift("_publish_or_consume_candidates")
  101. sys.path.insert(0, ".")
  102. import indexer_chunked_v3 as indexer_chunked # noqa: E402
  103. import spark_prefill_dense # noqa: E402
  104. class StockBackend:
  105. _low_ratio_index_topk_dense = stock_dense
  106. _publish_or_consume_candidates = stock_publish
  107. # ----- scenario builder -----
  108. def build(seq_lens, q_lens, ratio, source, consume_masks=None, tail_lens=None, topk=TOPK):
  109. T = sum(q_lens)
  110. max_slots = max(seq_lens) + 8
  111. req_to_token = torch.stack([torch.arange(max_slots) + 1000 * r for r in range(len(seq_lens))])
  112. class Pool:
  113. def get_low_ratio_index_k_fp4(self, layer_id, k_slots):
  114. return k_slots.to(torch.int8), torch.zeros(k_slots.shape[0], dtype=torch.int32)
  115. core = SimpleNamespace(
  116. _page=torch.full((T + 3, topk), -7, dtype=torch.int32),
  117. _raw=torch.full((T + 3, topk), -7, dtype=torch.int32),
  118. )
  119. core.sparse_page_indices = lambda r: core._page
  120. core.sparse_raw_indices = lambda r: core._raw
  121. indexer = SimpleNamespace(
  122. is_candidate_source=source,
  123. uses_candidates=source or consume_masks is not None,
  124. index_topk=topk, candidate_topk_blocks=2, candidate_block_size=3,
  125. queries=lambda q_lora, freqs: q_lora,
  126. head_weights=lambda x: torch.ones(x.shape[0], HEADS),
  127. )
  128. layer = SimpleNamespace(compress_ratio=ratio, indexer=indexer, freqs_cis=torch.zeros(4096, 4), layer_id=1)
  129. pos = torch.cat([torch.arange(s - q, s) for s, q in zip(seq_lens, q_lens)])
  130. q_lora = torch.zeros(T, HEADS, 128)
  131. q_lora[:, :, 0] = torch.arange(T)[:, None].to(q_lora.dtype) # token id in column 0
  132. x = torch.zeros(T, 8)
  133. fb = SimpleNamespace(seq_lens_cpu=torch.tensor(seq_lens), req_pool_indices=torch.arange(len(seq_lens)), forward_mode=SimpleNamespace(is_extend_without_speculative=lambda: True))
  134. tail = None
  135. if tail_lens is not None:
  136. tail = SimpleNamespace(late_layer_tail=SimpleNamespace(cp_metadata=None, extend_seq_lens_cpu=list(tail_lens)))
  137. self = SimpleNamespace(
  138. token_to_kv_pool=Pool(), forward_metadata=SimpleNamespace(core_metadata=core, candidate_metadata=(CandidateMasks(consume_masks) if consume_masks is not None else None)),
  139. req_to_token=req_to_token, candidate_masks=consume_masks, tail_forward_metadata=tail,
  140. )
  141. self._dsv41_native_prefill_tp = SPLIT[0]
  142. return self, layer, x, q_lora, pos, fb, torch.tensor(q_lens), list(q_lens)
  143. def run(fn, budget, **kw):
  144. self, layer, x, q_lora, pos, fb, q_lens, q_lens_cpu = build(**kw)
  145. if fn is None:
  146. self._publish_or_consume_candidates = types.MethodType(stock_publish, self)
  147. StockBackend._low_ratio_index_topk_dense(self, layer, x, q_lora, pos, fb, q_lens, q_lens_cpu)
  148. else:
  149. f = indexer_chunked.make_index_topk_dense(fake_module, quantize_fp4_indexer_tensor, budget)
  150. f(self, layer, x, q_lora, pos, fb, q_lens, q_lens_cpu)
  151. cm = self.forward_metadata.candidate_metadata
  152. return self.forward_metadata.core_metadata._page.clone(), self.forward_metadata.core_metadata._raw.clone(), (cm.request_masks if isinstance(cm, CandidateMasks) else None)
  153. SPLIT = [None]
  154. class FakeGroup:
  155. """all_gather that recomputes every rank's local selections from the recorded call."""
  156. def __init__(self, world):
  157. self.world_size = world; self.rank_in_group = 0; self.last = None
  158. def all_gather(self, local, dim=0):
  159. parts = []
  160. for r in range(self.world_size):
  161. a = dict(self.last); a["rank"] = r
  162. parts.append(REAL_SPLIT_LOCAL(**a))
  163. return torch.cat(parts, dim=0)
  164. def make_split(world):
  165. g = FakeGroup(world)
  166. tp = spark_prefill_dense.NativePrefillTPSplit(g, 0, world, True, minimum_context=1, minimum_rows=1)
  167. def recording(indexer,q_fp4,q_sf,k_fp4,k_sf,weights,ks,lens,lengths,q_lengths,world_,rank,**kw):
  168. g.last = dict(indexer=indexer,q_fp4=q_fp4,q_sf=q_sf,k_fp4=k_fp4,k_sf=k_sf,weights=weights,ks=ks,lens=lens,lengths=lengths,q_lengths=q_lengths,world=world_,rank=rank,**kw)
  169. return REAL_SPLIT_LOCAL(indexer,q_fp4,q_sf,k_fp4,k_sf,weights,ks,lens,lengths,q_lengths,world_,rank,**kw)
  170. indexer_chunked._split_local = recording
  171. return tp
  172. REAL_SPLIT_LOCAL = indexer_chunked._split_local
  173. def check(name, budgets, **kw):
  174. ref_page, ref_raw, ref_masks = run(None, None, **kw)
  175. tail_lens = kw.get("tail_lens")
  176. for budget in budgets:
  177. page, raw, masks = run(True, budget, **kw)
  178. assert torch.equal(page, ref_page), f"{name} budget={budget}: page_indices differ"
  179. assert torch.equal(raw, ref_raw), f"{name} budget={budget}: raw_indices differ"
  180. if kw["source"]:
  181. assert len(masks) == len(ref_masks)
  182. for b, (m, rm) in enumerate(zip(masks, ref_masks)):
  183. if tail_lens is not None and rm.numel():
  184. rm = rm[rm.shape[0] - tail_lens[b]:] # what enter_late_layer_tail keeps
  185. assert m.shape == rm.shape and torch.equal(m, rm), f"{name} budget={budget}: mask {b} differs {tuple(m.shape)} vs {tuple(rm.shape)}"
  186. print(f"[OK] {name} (budgets {budgets})")
  187. small = [16, 64, 160, 1 << 31] # 16 B => 1 row per chunk at lc<=4 ... up to one chunk
  188. check("plain, single request", small, seq_lens=[40], q_lens=[12], ratio=2, source=False)
  189. check("plain, three requests ragged", small, seq_lens=[40, 9, 61], q_lens=[12, 9, 5], ratio=1, source=False)
  190. check("plain, request with lc=0 and t_len=0", small, seq_lens=[1, 30, 20], q_lens=[1, 0, 6], ratio=2, source=False)
  191. check("publish full masks (no tail metadata)", small, seq_lens=[40, 25], q_lens=[10, 7], ratio=1, source=True)
  192. check("publish tail-only masks", small, seq_lens=[40, 25, 33], q_lens=[10, 7, 9], ratio=1, source=True, tail_lens=[3, 7, 1])
  193. masks = [torch.rand(10, 40) > 0.4, torch.rand(7, 25) > 0.4]
  194. check("consume candidate masks", small, seq_lens=[40, 25], q_lens=[10, 7], ratio=1, source=False, consume_masks=masks)
  195. torch.cuda.is_current_stream_capturing = lambda: False
  196. for world in (1, 2, 4):
  197. SPLIT[0] = make_split(world)
  198. check(f"SPLIT world={world}: plain ragged", [64, 1 << 31], seq_lens=[300, 129, 700], q_lens=[140, 129, 260], ratio=1, source=False)
  199. check(f"SPLIT world={world}: publish masks", [64, 1 << 31], seq_lens=[300, 260], q_lens=[140, 130], ratio=1, source=True)
  200. check(f"SPLIT world={world}: publish tail-only", [64, 1 << 31], seq_lens=[300, 260, 500], q_lens=[140, 130, 200], ratio=1, source=True, tail_lens=[3, 130, 7])
  201. masks = [torch.rand(140, 300) > 0.4, torch.rand(130, 260) > 0.4]
  202. check(f"SPLIT world={world}: consume masks", [64, 1 << 31], seq_lens=[300, 260], q_lens=[140, 130], ratio=1, source=False, consume_masks=masks)
  203. SPLIT[0] = None
  204. print("all passed")