| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474 |
- #!/usr/bin/env python3
- """KDA RecoverSSM (experimental/fixes/recoverssm.py) against the stock spec path.
- Drives both through the same simulated speculative decode at GLM-5.3-Flash's
- per-rank KDA shapes (TP=4: 16 heads x 128, conv width 4), several layers per
- KV group, with batch-level k varying step to step (2, 3, 4, 5, 7) and each
- row accepting 0..k drafts.
- stock: causal_conv1d_update(num_accepted_tokens = last step's count)
- + fused_recurrent_kda with 1 + k state slots per request
- recoverssm: the same conv call with num_accepted_tokens = 1
- + kda_recoverssm_verify from one checkpoint block
- + Glm5NextRecoverSSMCommitContext.commit(num_sampled)
- Every step, per layer, bit for bit:
- - conv output and recurrent output for every verified token
- - the checkpoint is unchanged by the verify
- - after the commit: the recurrent state equals the stock slot n - 1, and the
- conv window at column 0 equals the stock window at column n - 1
- - align mode (prefix caching): the state is committed to the block holding
- the last committed token, and a step that crosses a block boundary also
- leaves the stock state after the boundary token in the boundary block
- Runs with and without request_indices (spec rows interleaved with other rows)
- and with a trailing cudagraph padding row.
- Pass: everything bit-identical. The summary counts checks and failures per
- check class, with the first failing step (later failures in a class can be
- fallout: the two paths then carry different states). RECOVERSSM_TEST_ATOL=<x>
- accepts differences up to x and reports them (diagnosis only).
- --drift N runs one chained config for N steps and prints how far the
- committed state and the outputs move from the stock path's.
- No weights needed. The module is found as vllm.models.glm5next.common.recoverssm
- when recoverssm.yaml mounts it, else RECOVERSSM_PY, else next to this file,
- else ../../experimental/fixes/recoverssm.py. On one GB10:
- mkdir -p /tmp/rs && cp dev/patch-tests/_glm53_recoverssm_test.py \\
- experimental/fixes/recoverssm.py /tmp/rs/
- sudo docker run --rm --gpus all --ipc=host -v /tmp/rs:/rs -w / \\
- --entrypoint python3 <glm53 image> /rs/_glm53_recoverssm_test.py [--bench]
- or `docker cp` both files into a glm53 container and `docker exec` python3 on
- the test. It allocates up to ~0.7 GB of GPU memory: run it on a box that is
- not serving, since a serving GB10 has 1.5-3 GiB free.
- --bench adds a timing of the stock spec kernel against verify + commit at
- 34 layers.
- """
- import importlib.util
- import os
- import sys
- from pathlib import Path
- from types import SimpleNamespace
- import torch
- from vllm.model_executor.layers.mamba.mamba_utils import is_conv_state_dim_first
- from vllm.model_executor.layers.mamba.ops.causal_conv1d import causal_conv1d_update
- from vllm.models.glm5next.nvidia.ops.third_party.kda import fused_recurrent_kda
- def _load_module():
- name = "vllm.models.glm5next.common.recoverssm"
- try:
- return importlib.import_module(name)
- except ImportError:
- pass
- here = Path(__file__).resolve().parent
- candidates = [
- os.environ.get("RECOVERSSM_PY"),
- here / "recoverssm.py",
- here.parent.parent / "experimental" / "fixes" / "recoverssm.py",
- ]
- for path in candidates:
- if path and Path(path).is_file():
- spec = importlib.util.spec_from_file_location(name, path)
- module = importlib.util.module_from_spec(spec)
- sys.modules[name] = module
- spec.loader.exec_module(module)
- print(f"recoverssm module: {path}")
- return module
- raise SystemExit("recoverssm.py not found; set RECOVERSSM_PY")
- rs = _load_module()
- dev = torch.device("cuda")
- H, D = 16, 128 # per rank at TP=4 (64 heads)
- PROJ = H * D
- CONV_DIM = 3 * PROJ
- WIDTH = 4
- LOWER_BOUND = -5.0
- DTYPE = torch.bfloat16
- LEVELS = (2, 3, 4, 5, 7)
- ATOL = float(os.environ.get("RECOVERSSM_TEST_ATOL", "0"))
- DS = is_conv_state_dim_first()
- CLASSES = (
- "conv output",
- "recurrent output",
- "checkpoint untouched",
- "committed state",
- "committed conv",
- "boundary state",
- "boundary conv",
- )
- class Checker:
- def __init__(self) -> None:
- self.checks = dict.fromkeys(CLASSES, 0)
- self.failures = dict.fromkeys(CLASSES, 0)
- self.inexact = dict.fromkeys(CLASSES, 0)
- self.worst = dict.fromkeys(CLASSES, 0.0)
- self.first: dict[str, str] = {}
- self.printed = 0
- def eq(self, cls: str, where: str, got: torch.Tensor, want: torch.Tensor) -> None:
- self.checks[cls] += 1
- if torch.equal(got, want):
- return
- diff = (got.float() - want.float()).abs()
- err = float(diff.nan_to_num(float("inf")).max())
- self.worst[cls] = max(self.worst[cls], err)
- if err <= ATOL:
- self.inexact[cls] += 1
- return
- self.failures[cls] += 1
- self.first.setdefault(cls, where)
- if self.printed < 10:
- self.printed += 1
- scale = float(want.float().abs().max())
- print(f" MISMATCH {cls} at {where}: max abs {err:.3g} (scale "
- f"{scale:.3g}), {int((diff > 0).sum())} of {diff.numel()} differ")
- def report(self) -> None:
- print(f"{'class':<22}{'checks':>8}{'fail':>8}{'inexact':>9}{'worst':>11} first failure")
- for cls in CLASSES:
- print(f"{cls:<22}{self.checks[cls]:>8}{self.failures[cls]:>8}"
- f"{self.inexact[cls]:>9}{self.worst[cls]:>11.3g} {self.first.get(cls, '-')}")
- def conv_pool(lines: int, state_len: int) -> tuple[torch.Tensor, torch.Tensor]:
- """(raw kv_cache[0] tensor, its (lines, dim, state_len) view for the kernels)."""
- if DS:
- raw = torch.zeros(lines, CONV_DIM, state_len, device=dev, dtype=DTYPE)
- return raw, raw
- raw = torch.zeros(lines, state_len, CONV_DIM, device=dev, dtype=DTYPE)
- return raw, raw.transpose(-1, -2)
- def make_layer(g: torch.Generator, lines_stock: int, lines_rs: int, k: int, rows: int):
- state_len = WIDTH - 1 + k
- layer = SimpleNamespace()
- layer.conv_w = (torch.randn(CONV_DIM, WIDTH, device=dev, generator=g) * 0.3).float()
- layer.A_log = torch.log(
- torch.rand(1, 1, H, 1, device=dev, generator=g) * 8 + 1
- ).float()
- layer.dt_bias = (torch.randn(PROJ, device=dev, generator=g) * 0.5).float()
- layer.kda_lower_bound = LOWER_BOUND
- layer.conv_s_raw, layer.conv_s = conv_pool(lines_stock, state_len)
- layer.rec_s = torch.zeros(lines_stock, H, D, D, device=dev, dtype=torch.float32)
- conv_r_raw, layer.conv_r = conv_pool(lines_rs, state_len)
- layer.rec_r = torch.zeros(lines_rs, H, D, D, device=dev, dtype=torch.float32)
- layer.kv_cache = (conv_r_raw, layer.rec_r)
- layer.recoverssm_records = rs.allocate_records(rows, H, D, k + 1, DTYPE, dev)
- return layer
- def stock_step(layer, x, g1, beta, qsl, cols, na_prev, k):
- y = causal_conv1d_update(
- x.clone(), layer.conv_s, layer.conv_w, None, activation="silu",
- conv_state_indices=cols[:, 0], num_accepted_tokens=na_prev,
- query_start_loc=qsl, max_query_len=k + 1,
- )
- q, kk, v = y.split(PROJ, dim=-1)
- o, _ = fused_recurrent_kda(
- q=q.reshape(1, -1, H, D), k=kk.reshape(1, -1, H, D), v=v.reshape(1, -1, H, D),
- g=g1, beta=beta, initial_state=layer.rec_s, use_qk_l2norm_in_kernel=True,
- cu_seqlens=qsl, ssm_state_indices=cols, num_accepted_tokens=na_prev,
- sigmoid_beta=True, a_log=layer.A_log, g_bias=layer.dt_bias,
- compute_gate=True, lower_bound=LOWER_BOUND,
- )
- return y, o
- def rs_step(layer, x, g1, beta, qsl, idx, ones, k):
- y = causal_conv1d_update(
- x.clone(), layer.conv_r, layer.conv_w, None, activation="silu",
- conv_state_indices=idx, num_accepted_tokens=ones,
- query_start_loc=qsl, max_query_len=k + 1,
- )
- q, kk, v = y.split(PROJ, dim=-1)
- o = rs.kda_recoverssm_verify(
- q=q.reshape(1, -1, H, D), k=kk.reshape(1, -1, H, D), v=v.reshape(1, -1, H, D),
- g=g1, beta=beta, a_log=layer.A_log, g_bias=layer.dt_bias,
- lower_bound=LOWER_BOUND, checkpoint_state=layer.rec_r,
- records=layer.recoverssm_records, query_start_loc=qsl, state_indices=idx,
- )
- return y, o
- def run(k: int, align: bool, seed: int, chk: Checker, steps: int = 40,
- num_reqs: int = 6, num_layers: int = 3, block_size: int = 16,
- drift: list | None = None) -> None:
- g = torch.Generator(device=dev).manual_seed(seed)
- cpu = torch.Generator().manual_seed(seed)
- B = num_reqs
- rows = B + 1 # one trailing padding row, as a FULL-graph batch has
- width = 2 + (steps * (k + 1)) // block_size + 2
- lines_stock = 1 + B * (k + 1)
- lines_rs = 1 + B * (width if align else 1)
- layers = [make_layer(g, lines_stock, lines_rs, k, rows) for _ in range(num_layers)]
- ctx = rs.Glm5NextRecoverSSMCommitContext.create(
- layers, spec_query_len=k + 1, max_num_reqs=rows
- )
- stock_cols = torch.zeros(rows, k + 1, dtype=torch.int32)
- for i in range(B):
- stock_cols[i] = torch.arange(1 + i * (k + 1), 1 + (i + 1) * (k + 1))
- stock_cols = stock_cols.to(dev)
- if align:
- block_table = torch.zeros(rows, width, dtype=torch.int32)
- for i in range(B):
- block_table[i] = torch.arange(1 + i * width, 1 + (i + 1) * width)
- block_table = block_table.to(dev)
- num_computed = torch.randint(1, 2 * block_size, (B,), generator=cpu)
- cur_col = ((num_computed - 1) // block_size).tolist()
- else:
- block_table = None
- num_computed = torch.zeros(B, dtype=torch.int64)
- cur_col = [0] * B
- def rs_block(i: int, col: int) -> int:
- return int(block_table[i, col]) if align else 1 + i
- # Same random checkpoint in both paths: stock slot 0, read with na = 1.
- for layer in layers:
- for i in range(B):
- state = torch.randn(H, D, D, device=dev, generator=g) * 0.1
- hist = torch.randn(CONV_DIM, WIDTH - 1, device=dev, generator=g).to(DTYPE)
- layer.rec_s[int(stock_cols[i, 0])] = state
- layer.conv_s[int(stock_cols[i, 0]), :, : WIDTH - 1] = hist
- b = rs_block(i, cur_col[i])
- layer.rec_r[b] = state
- layer.conv_r[b, :, : WIDTH - 1] = hist
- na_prev = torch.ones(rows, dtype=torch.int32, device=dev)
- ones = torch.ones(rows, dtype=torch.int32, device=dev)
- for step in range(steps):
- k_step = min(LEVELS[int(torch.randint(len(LEVELS), (1,), generator=cpu))], k)
- # Most rows verify k_step drafts. About one in five has no drafts.
- qlens = [
- 1 if torch.rand(1, generator=cpu) < 0.2 else k_step + 1 for _ in range(B)
- ] + [0]
- qsl_cpu = torch.zeros(rows + 1, dtype=torch.int32)
- qsl_cpu[1:] = torch.cumsum(torch.tensor(qlens), 0)
- qsl = qsl_cpu.to(dev)
- T = int(qsl_cpu[-1])
- x = torch.randn(T, CONV_DIM, device=dev, generator=g).to(DTYPE)
- g1 = torch.randn(1, T, H, D, device=dev, generator=g).to(DTYPE)
- # beta as a column slice of a wider projection, like kda.py's (its row
- # is 6416 wide: a multiple of 16, as here, which Triton specializes on).
- beta = torch.randn(1, T, H + 16, device=dev, generator=g).to(DTYPE)[..., :H]
- # Runner align preprocess: the running block moves to the column the
- # verify reads, (seq_len - 1) // block_size.
- if align:
- for i in range(B):
- dst = (int(num_computed[i]) + qlens[i] - 1) // block_size
- if dst != cur_col[i]:
- src_b, dst_b = rs_block(i, cur_col[i]), rs_block(i, dst)
- for layer in layers:
- layer.rec_r[dst_b] = layer.rec_r[src_b]
- layer.conv_r[dst_b] = layer.conv_r[src_b]
- cur_col[i] = dst
- idx = torch.tensor(
- [rs_block(i, cur_col[i]) for i in range(B)] + [0],
- dtype=torch.int32, device=dev,
- )
- for li, layer in enumerate(layers):
- before = layer.rec_r[idx[:B].long()].clone()
- y_s, o_s = stock_step(layer, x, g1, beta, qsl, stock_cols, na_prev, k)
- y_r, o_r = rs_step(layer, x, g1, beta, qsl, idx, ones, k)
- tag = f"k={k} align={align} seed={seed} step={step} layer={li}"
- chk.eq("conv output", tag, y_r[:T], y_s[:T])
- chk.eq("recurrent output", tag, o_r[:, :T], o_s[:, :T])
- chk.eq("checkpoint untouched", tag, layer.rec_r[idx[:B].long()], before)
- if drift is not None:
- out_err = float((o_r[:, :T].float() - o_s[:, :T].float()).abs().max())
- accepted = [int(torch.randint(0, q, (1,), generator=cpu)) for q in qlens[:B]]
- n_sampled = [a + 1 for a in accepted]
- # Odd steps: spec rows interleaved with other batch rows.
- interleave = step % 2 == 1
- if interleave:
- batch_rows = [2 * i + 1 for i in range(B)]
- nb = 2 * B + 1
- request_indices = torch.tensor(batch_rows, dtype=torch.int32, device=dev)
- else:
- batch_rows = list(range(B))
- nb = B
- request_indices = None
- num_sampled = torch.randint(0, 9, (nb,), generator=cpu, dtype=torch.int32)
- nc_batch = torch.randint(0, 99, (nb,), generator=cpu, dtype=torch.int32)
- bt_batch = torch.zeros(nb, width if align else 1, dtype=torch.int32)
- for i, r in enumerate(batch_rows):
- num_sampled[r] = n_sampled[i]
- nc_batch[r] = int(num_computed[i])
- if align:
- bt_batch[r] = block_table[i].cpu()
- align_kwargs = {}
- if align:
- align_kwargs = dict(
- block_table=bt_batch.to(dev),
- num_computed_tokens=nc_batch.to(dev),
- mamba_block_size=block_size,
- )
- ctx.commit(
- num_sampled.to(dev), idx[:B], qsl[: B + 1],
- request_indices=request_indices, **align_kwargs,
- )
- state_err = 0.0
- for i in range(B):
- n = n_sampled[i]
- nc = int(num_computed[i])
- # The block holding the last committed token.
- final_col = (nc + n - 1) // block_size if align else 0
- final_b = rs_block(i, final_col)
- for li, layer in enumerate(layers):
- tag = f"k={k} align={align} seed={seed} step={step} layer={li} req={i} n={n}"
- s_col = int(stock_cols[i, n - 1])
- c_col = int(stock_cols[i, 0])
- chk.eq("committed state", tag, layer.rec_r[final_b], layer.rec_s[s_col])
- chk.eq("committed conv", tag,
- layer.conv_r[final_b, :, : WIDTH - 1],
- layer.conv_s[c_col, :, n - 1 : n - 1 + WIDTH - 1])
- if drift is not None:
- want = layer.rec_s[s_col]
- state_err = max(state_err, float(
- (layer.rec_r[final_b] - want).abs().max() / want.abs().max()))
- next_boundary = (nc // block_size + 1) * block_size
- if align and nc + n >= next_boundary:
- blen = next_boundary - nc
- bb = rs_block(i, next_boundary // block_size - 1)
- chk.eq("boundary state", tag,
- layer.rec_r[bb], layer.rec_s[int(stock_cols[i, blen - 1])])
- chk.eq("boundary conv", tag,
- layer.conv_r[bb, :, : WIDTH - 1],
- layer.conv_s[c_col, :, blen - 1 : blen - 1 + WIDTH - 1])
- if align:
- num_computed[i] = nc + n
- cur_col[i] = final_col
- na_prev = torch.tensor(n_sampled + [1], dtype=torch.int32, device=dev)
- if drift is not None:
- drift.append((step, state_err, out_err))
- torch.cuda.synchronize()
- def drift_run(steps: int) -> None:
- """Chained, never resynced: how far RecoverSSM moves from the stock path."""
- samples: list = []
- run(7, False, 3, Checker(), steps=steps, num_layers=1, drift=samples)
- every = max(1, steps // 10)
- print(f"drift over {steps} steps (k=7, 6 requests; state error relative to "
- "the state's max, output error absolute):")
- worst_state = worst_out = 0.0
- for step, state_err, out_err in samples:
- worst_state = max(worst_state, state_err)
- worst_out = max(worst_out, out_err)
- if (step + 1) % every == 0:
- print(f" step {step + 1:>6}: state {state_err:.3g} (worst so far "
- f"{worst_state:.3g}), last-layer output {out_err:.3g} "
- f"(worst {worst_out:.3g})")
- def bench(num_reqs: int, k: int = 7, num_layers: int = 34, iters: int = 20) -> None:
- """Stock spec kernel vs verify + commit, all rows verifying k drafts.
- Layers share one pool here (timing only), so the numbers are per step for
- num_layers layers of one KV group.
- """
- g = torch.Generator(device=dev).manual_seed(0)
- q_len = k + 1
- rows = num_reqs
- layer = make_layer(g, 1 + rows * q_len, 1 + rows, k, rows)
- layer.rec_s.normal_(generator=g).mul_(0.1)
- layer.rec_r.normal_(generator=g).mul_(0.1)
- layers = [layer] * num_layers
- ctx = rs.Glm5NextRecoverSSMCommitContext.create(
- layers, spec_query_len=q_len, max_num_reqs=rows
- )
- T = rows * q_len
- qsl = torch.arange(0, T + 1, q_len, dtype=torch.int32, device=dev)
- cols = (1 + torch.arange(rows * q_len, dtype=torch.int32, device=dev)).view(rows, q_len)
- idx = 1 + torch.arange(rows, dtype=torch.int32, device=dev)
- na = torch.full((rows,), q_len, dtype=torch.int32, device=dev)
- y = torch.randn(T, CONV_DIM, device=dev, generator=g).to(DTYPE)
- q, kk, v = (t.reshape(1, -1, H, D) for t in y.split(PROJ, dim=-1))
- g1 = torch.randn(1, T, H, D, device=dev, generator=g).to(DTYPE)
- beta = torch.randn(1, T, H, device=dev, generator=g).to(DTYPE)
- out = torch.empty(1, T, H, D, device=dev, dtype=DTYPE)
- sampled = torch.full((rows,), q_len, dtype=torch.int32, device=dev)
- def stock():
- for _ in range(num_layers):
- fused_recurrent_kda(
- q=q, k=kk, v=v, g=g1, beta=beta, initial_state=layer.rec_s,
- use_qk_l2norm_in_kernel=True, cu_seqlens=qsl, ssm_state_indices=cols,
- num_accepted_tokens=na, out=out, sigmoid_beta=True, a_log=layer.A_log,
- g_bias=layer.dt_bias, compute_gate=True, lower_bound=LOWER_BOUND,
- )
- def recover():
- for _ in range(num_layers):
- rs.kda_recoverssm_verify(
- q=q, k=kk, v=v, g=g1, beta=beta, a_log=layer.A_log,
- g_bias=layer.dt_bias, lower_bound=LOWER_BOUND,
- checkpoint_state=layer.rec_r, records=layer.recoverssm_records,
- query_start_loc=qsl, state_indices=idx, out=out,
- )
- ctx.commit(sampled, idx, qsl)
- def verify_only():
- for _ in range(num_layers):
- rs.kda_recoverssm_verify(
- q=q, k=kk, v=v, g=g1, beta=beta, a_log=layer.A_log,
- g_bias=layer.dt_bias, lower_bound=LOWER_BOUND,
- checkpoint_state=layer.rec_r, records=layer.recoverssm_records,
- query_start_loc=qsl, state_indices=idx, out=out,
- )
- def timed(fn) -> float:
- fn()
- torch.cuda.synchronize()
- start, end = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
- start.record()
- for _ in range(iters):
- fn()
- end.record()
- torch.cuda.synchronize()
- return start.elapsed_time(end) / iters
- t_stock, t_rec, t_ver = timed(stock), timed(recover), timed(verify_only)
- print(f"bench rows={rows} k={k} layers={num_layers}: stock {t_stock:.2f} ms, "
- f"verify {t_ver:.2f} ms + commit {t_rec - t_ver:.2f} ms = {t_rec:.2f} ms")
- chk = Checker()
- for k in (7, 3):
- for align in (False, True):
- for seed in (0, 1):
- run(k, align, seed, chk)
- print(f"k={k} align={align} seed={seed}: {sum(chk.checks.values())} "
- f"checks so far, {sum(chk.failures.values())} failures")
- chk.report()
- if "--bench" in sys.argv:
- for n in (16, 32, 64):
- bench(n)
- if "--drift" in sys.argv:
- drift_run(int(sys.argv[sys.argv.index("--drift") + 1]))
- checks, failures = sum(chk.checks.values()), sum(chk.failures.values())
- inexact = sum(chk.inexact.values())
- if failures:
- print(f"RECOVERSSM: FAIL ({failures} of {checks} checks)")
- sys.exit(1)
- if inexact:
- print(f"RECOVERSSM: PASS within atol {ATOL} ({inexact} of {checks} "
- "not bit-identical)")
- else:
- print(f"RECOVERSSM: PASS, bit-identical ({checks} checks)")
|