test_indexer_chunked.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. """CPU equivalence test: chunked dense indexer (sglang#39187 backport) vs the stock
  2. function lifted verbatim from this image'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-ours.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. def quantize_fp4_indexer_tensor(q, rne=True):
  66. # q: [T*H, 128] -> ([T*H, 64] int8, [T*H] int32); keep the token id in column 0
  67. return q[:, :64].to(torch.int8), torch.zeros(q.shape[0], dtype=torch.int32)
  68. fake_fp4 = types.ModuleType("sglang.kernels.ops.attention.dsv4.fp4_indexer")
  69. fake_fp4.quantize_fp4_indexer_tensor = quantize_fp4_indexer_tensor
  70. for name in ("sglang", "sglang.kernels", "sglang.kernels.ops", "sglang.kernels.ops.attention",
  71. "sglang.kernels.ops.attention.dsv4"):
  72. sys.modules.setdefault(name, types.ModuleType(name))
  73. sys.modules["sglang.kernels.ops.attention.dsv4.fp4_indexer"] = fake_fp4
  74. fake_module = types.ModuleType("deepseek_v4_backend_fake")
  75. for name, obj in dict(
  76. _dense_fp4_mqa_logits=_dense_fp4_mqa_logits, topk_transform_ragged_v2=topk_transform_ragged_v2,
  77. _mask_topk_scores=_mask_topk_scores, select_candidate_blocks=select_candidate_blocks,
  78. ceil_align=ceil_align, _as_int_list=_as_int_list, torch=torch,
  79. _TORCH_INDEXER_SCORE_BUDGET_BYTES=1 << 30,
  80. ).items():
  81. setattr(fake_module, name, obj)
  82. # ----- lift the stock methods verbatim from the image's source -----
  83. src = open(BACKEND_SRC).read()
  84. def lift(name):
  85. m = re.search(r"\n def " + name + r"\(.*?(?=\n def |\Z)", src, re.S)
  86. assert m, name
  87. body = "\n".join(line[4:] if line.startswith(" ") else line for line in m.group(0).splitlines())
  88. ns = dict(vars(fake_module))
  89. exec(body, ns)
  90. return ns[name]
  91. stock_dense = lift("_low_ratio_index_topk_dense")
  92. stock_publish = lift("_publish_or_consume_candidates")
  93. sys.path.insert(0, ".")
  94. import indexer_chunked # noqa: E402
  95. class StockBackend:
  96. _low_ratio_index_topk_dense = stock_dense
  97. _publish_or_consume_candidates = stock_publish
  98. # ----- scenario builder -----
  99. def build(seq_lens, q_lens, ratio, source, consume_masks=None, tail_lens=None, topk=TOPK):
  100. T = sum(q_lens)
  101. max_slots = max(seq_lens) + 8
  102. req_to_token = torch.stack([torch.arange(max_slots) + 1000 * r for r in range(len(seq_lens))])
  103. class Pool:
  104. def get_low_ratio_index_k_fp4(self, layer_id, k_slots):
  105. return k_slots.to(torch.int8), torch.zeros(k_slots.shape[0], dtype=torch.int32)
  106. core = SimpleNamespace(
  107. _page=torch.full((T + 3, topk), -7, dtype=torch.int32),
  108. _raw=torch.full((T + 3, topk), -7, dtype=torch.int32),
  109. )
  110. core.sparse_page_indices = lambda r: core._page
  111. core.sparse_raw_indices = lambda r: core._raw
  112. indexer = SimpleNamespace(
  113. is_candidate_source=source,
  114. uses_candidates=source or consume_masks is not None,
  115. index_topk=topk, candidate_topk_blocks=2, candidate_block_size=3,
  116. queries=lambda q_lora, freqs: q_lora,
  117. head_weights=lambda x: torch.ones(x.shape[0], HEADS),
  118. )
  119. layer = SimpleNamespace(compress_ratio=ratio, indexer=indexer, freqs_cis=torch.zeros(4096, 4), layer_id=1)
  120. pos = torch.cat([torch.arange(s - q, s) for s, q in zip(seq_lens, q_lens)])
  121. q_lora = torch.zeros(T, HEADS, 128)
  122. q_lora[:, :, 0] = torch.arange(T)[:, None].to(q_lora.dtype) # token id in column 0
  123. x = torch.zeros(T, 8)
  124. fb = SimpleNamespace(seq_lens_cpu=torch.tensor(seq_lens), req_pool_indices=torch.arange(len(seq_lens)))
  125. tail = None
  126. if tail_lens is not None:
  127. tail = SimpleNamespace(late_layer_tail=SimpleNamespace(cp_metadata=None, extend_seq_lens_cpu=list(tail_lens)))
  128. self = SimpleNamespace(
  129. token_to_kv_pool=Pool(), forward_metadata=SimpleNamespace(core_metadata=core),
  130. req_to_token=req_to_token, candidate_masks=consume_masks, tail_forward_metadata=tail,
  131. )
  132. return self, layer, x, q_lora, pos, fb, torch.tensor(q_lens), list(q_lens)
  133. def run(fn, budget, **kw):
  134. self, layer, x, q_lora, pos, fb, q_lens, q_lens_cpu = build(**kw)
  135. if fn is None:
  136. self._publish_or_consume_candidates = types.MethodType(stock_publish, self)
  137. StockBackend._low_ratio_index_topk_dense(self, layer, x, q_lora, pos, fb, q_lens, q_lens_cpu)
  138. else:
  139. f = indexer_chunked.make_index_topk_dense(fake_module, quantize_fp4_indexer_tensor, budget)
  140. f(self, layer, x, q_lora, pos, fb, q_lens, q_lens_cpu)
  141. return self.forward_metadata.core_metadata._page.clone(), self.forward_metadata.core_metadata._raw.clone(), self.candidate_masks
  142. def check(name, budgets, **kw):
  143. ref_page, ref_raw, ref_masks = run(None, None, **kw)
  144. tail_lens = kw.get("tail_lens")
  145. for budget in budgets:
  146. page, raw, masks = run(True, budget, **kw)
  147. assert torch.equal(page, ref_page), f"{name} budget={budget}: page_indices differ"
  148. assert torch.equal(raw, ref_raw), f"{name} budget={budget}: raw_indices differ"
  149. if kw["source"]:
  150. assert len(masks) == len(ref_masks)
  151. for b, (m, rm) in enumerate(zip(masks, ref_masks)):
  152. if tail_lens is not None and rm.numel():
  153. rm = rm[rm.shape[0] - tail_lens[b]:] # what enter_late_layer_tail keeps
  154. assert m.shape == rm.shape and torch.equal(m, rm), f"{name} budget={budget}: mask {b} differs {tuple(m.shape)} vs {tuple(rm.shape)}"
  155. print(f"[OK] {name} (budgets {budgets})")
  156. small = [16, 64, 160, 1 << 31] # 16 B => 1 row per chunk at lc<=4 ... up to one chunk
  157. check("plain, single request", small, seq_lens=[40], q_lens=[12], ratio=2, source=False)
  158. check("plain, three requests ragged", small, seq_lens=[40, 9, 61], q_lens=[12, 9, 5], ratio=1, source=False)
  159. 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)
  160. check("publish full masks (no tail metadata)", small, seq_lens=[40, 25], q_lens=[10, 7], ratio=1, source=True)
  161. 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])
  162. masks = [torch.rand(10, 40) > 0.4, torch.rand(7, 25) > 0.4]
  163. check("consume candidate masks", small, seq_lens=[40, 25], q_lens=[10, 7], ratio=1, source=False, consume_masks=masks)
  164. print("all passed")