feat(triton): complete P1 with norm, lookup and metadata kernels

norm, map_lookup, expand_sampling_params, prepare_verification_metadata, copy_block_tables.
Plus the measured parameterisation that made the port tractable: `DEEPINFER_TRACE_KERNEL_ARGS=1`
records each factory's construction arguments, which showed that e.g. `norm_tl` is only ever built
as one combination on this path (d=5120, bf16 source, fp8 out, per-128 fp32 scales, col-major,
round_sf, no residual) rather than the dozen the signature allows.

Three findings worth keeping:

`norm` has to round the fp32 normalised value back to bf16 *before* computing the per-block amax
and the scale. That is what makes its fp8 output bit-identical to quantising the bf16 result
separately, which is how the engine defines the reference. Without it the fp8 bytes scatter by an
ulp wherever a value lands on a rounding boundary -- which looked like a scale bug at first
(224 vs 240) and was not.

With `round_sf=True`, which is the production setting, fp8 output and every scale factor come out
byte-identical. With `round_sf=False` 240 of 327680 bytes differ by one ulp, because `amax/448`
depends on the order of a 5120-element reduction and two compilers will not agree on that order.
For the same reason the bf16 output differs on 10 of 655360 elements by one ulp. Both are
documented, and the A/B harness now picks its tolerance from the dtype instead of one global pair.

`map_lookup` resolves duplicate keys by taking the last match, not the maximum: the TileLang kernel
overwrites on every hit while its own Python reference uses `max`, and the two agree only when keys
are unique. Top-k indices always are, so the Triton version matches the kernel -- and the case
generator now produces unique keys, with a comment saying why.

13 kernels and 53 cases now agree with the baseline.
This commit is contained in:
loopyt 2026-10-22 00:28:38 +08:00
parent a6c3e69e67
commit 4f3edbf604
6 changed files with 753 additions and 6 deletions

View File

@ -107,13 +107,21 @@
| 阶段 | 状态 | 说明 |
|---|---|---|
| P0 基础设施 | ✅ | 后端选择 + fallback + A/B 骨架 |
| P1 轻量 kernel | 🔄 8/13 | gather、gather_input_ids_zos、gather_engram_tokens_zos、expand_swa_index、kv_index_cat、convert_kv_index、load_kv_index、store_mtp_info |
| P1 轻量 kernel | ✅ 13/13 | gather、gather_input_ids_zos、gather_engram_tokens_zos、expand_swa_index、kv_index_cat、convert_kv_index、load_kv_index、store_mtp_info、norm、map_lookup、expand_sampling_params、prepare_verification_metadata、copy_block_tables |
| P2 KV cache | ⬜ | |
| P3 MTP 链路 | ⬜ | |
| P4 采样 | ⬜ | |
| P5 端到端 | ⬜ | |
A/B 现状:**45 个用例全过**(`python scripts/h20_ops_ab.py`)。
A/B 现状:**13 个 kernel、53 个用例全过**(`python scripts/h20_ops_ab.py`)。
`norm` 是这批里最关键的一个,两点值得记:
- **量化前必须先舍回 bf16**。TileLang 版是 `cast(cast(y, bf16), fp32)` 之后才算 amax 和
乘 scale —— 这样才能和「把 bf16 结果单独量化」逐 bit 一致(引擎的参考就是这么定义的)。
少了这一步,fp8 会在舍入边界上随机偏 1-2 个 ulp。
- **生产路径(`round_sf=True`)逐 byte 精确**:d=5120/7168、sf 也完全相等。
`round_sf=False` 时 240/327680 个字节差 1 ulp,因为 sf=amax/448 依赖 amax 的
归约顺序,两个编译器不可能一致。
### 过程中踩到的 Triton 语义坑(都已修,可复用)

View File

@ -31,15 +31,22 @@ from deepinfer import PLATFORM
_TRACE = bool(os.getenv("DEEPINFER_TRACE_KERNELS"))
def _record(name: str) -> None:
def _record(name: str, args: tuple = (), kwargs: dict | None = None) -> None:
"""Log one use of a backend kernel factory, so the real surface can be measured.
Called once per kernel *construction* rather than per launch: TileLang caches compiled
kernels, so construction happens at warmup and the count stays meaningful.
With `DEEPINFER_TRACE_KERNEL_ARGS=1` the construction arguments are logged too. Several
factories take a dozen flags (norm, fused_transform, fused_sample) and only a handful of
combinations are ever used, so the arguments say which code paths a port actually needs.
"""
path = Path(os.environ["DEEPINFER_TRACE_KERNELS"])
with path.open("a") as f:
f.write(f"{name}\n")
if os.getenv("DEEPINFER_TRACE_KERNEL_ARGS"):
f.write(f"{name}|args={args!r}|kwargs={sorted((kwargs or {}).items())!r}\n")
else:
f.write(f"{name}\n")
class _KernelTrace:
@ -58,7 +65,7 @@ class _KernelTrace:
def __call__(self, *args: Any, **kwargs: Any) -> Any:
if not self._seen:
self._seen = True
_record(self._name)
_record(self._name, args, kwargs)
return self._factory(*args, **kwargs)
def __getattr__(self, item: str) -> Any:

View File

@ -0,0 +1,230 @@
"""RMSNorm with optional fused residual add and FP8 cast, in Triton.
The arithmetic mirrors the TileLang kernel exactly, including the details that are easy to get
subtly wrong:
* the RMS statistic is over the residual-summed row, in float32;
* `weight * out_scale` is applied inside the normalisation multiply rather than afterwards;
* the per-block amax is clamped to `FP8_AMAX_MARGIN` before the scale is derived, so an all-zero
block still yields a finite scale;
* `round_sf` rounds the scale to a power of two using the float exponent directly, and the stored
scale is the reciprocal of the multiplier, so the two always multiply back to one.
Two passes over the row: the first accumulates the sum of squares (the statistic needs the whole
row before any element can be scaled), the second emits the outputs. `BLOCK_D` is a multiple of the
scale granularity, so every scale block lies entirely inside one chunk and the quantisation does
not need a third pass.
Layout support covers what the engine asks for on this platform. The packed-ue8m0 and mega-moe
shared-scale forms are rejected loudly rather than approximated silently.
"""
from typing import Any
import torch
import triton
import triton.language as tl
from deepinfer.model_executor.layers.tilelang_ops.common import FLOAT8E4M3_MAX, FP8_AMAX_MARGIN
@triton.jit
def _norm_kernel(
X,
Y,
Y_ORIGIN,
W,
RESIDUAL,
RESIDUAL_OUT,
SF,
x_s,
y_s,
yo_s,
r_s,
ro_s,
sf_m,
sf_j,
seqlen,
d: tl.constexpr,
num_per_channels: tl.constexpr,
eps: tl.constexpr,
out_scale: tl.constexpr,
fp8_max: tl.constexpr,
amax_margin: tl.constexpr,
has_residual: tl.constexpr,
do_fp8_cast: tl.constexpr,
round_sf: tl.constexpr,
return_bf16: tl.constexpr,
N_BLOCKS: tl.constexpr,
BLOCK_D: tl.constexpr,
BLOCK_M: tl.constexpr,
):
rows = tl.program_id(0) * BLOCK_M + tl.arange(0, BLOCK_M)
row_ok = rows < seqlen
x_row = X + rows * x_s
r_row = RESIDUAL + rows * r_s
# Pass 1: sum of squares over the full row.
sumsq = tl.zeros((BLOCK_M,), tl.float32)
for start in range(0, d, BLOCK_D):
cols = start + tl.arange(0, BLOCK_D)
mask = row_ok[:, None] & (cols[None, :] < d)
value = tl.load(x_row[:, None] + cols[None, :], mask=mask, other=0.0).to(tl.float32)
if has_residual:
value += tl.load(r_row[:, None] + cols[None, :], mask=mask, other=0.0).to(tl.float32)
sumsq += tl.sum(tl.where(mask, value * value, 0.0), axis=1)
rstd = tl.rsqrt(sumsq / d + eps)
# Pass 2: shared residual output, then the normalised result in whichever forms are requested.
ro_row = RESIDUAL_OUT + rows * ro_s
y_row = Y + rows * y_s
yo_row = Y_ORIGIN + rows * yo_s
for start in range(0, d, BLOCK_D):
cols = start + tl.arange(0, BLOCK_D)
mask = row_ok[:, None] & (cols[None, :] < d)
value = tl.load(x_row[:, None] + cols[None, :], mask=mask, other=0.0).to(tl.float32)
if has_residual:
value += tl.load(r_row[:, None] + cols[None, :], mask=mask, other=0.0).to(tl.float32)
tl.store(ro_row[:, None] + cols[None, :], value.to(tl.bfloat16), mask=mask)
weight = tl.load(W + cols, mask=cols < d, other=0.0).to(tl.float32)
y = value * rstd[:, None] * (weight * out_scale)
# The quantiser sees the bf16-rounded value, not the fp32 one: that is what makes the fp8
# output bit-identical to casting the bf16 result separately, which is how the engine's
# own reference is defined and what the TileLang kernel does.
y_bf = y.to(tl.bfloat16).to(tl.float32)
if return_bf16:
tl.store(yo_row[:, None] + cols[None, :], y_bf.to(tl.bfloat16), mask=mask)
if do_fp8_cast:
blocks = tl.reshape(y_bf, (BLOCK_M, N_BLOCKS, num_per_channels))
amax = tl.max(tl.abs(blocks), axis=2)
amax = tl.maximum(amax, amax_margin)
if round_sf:
# exp = -ceil(log2(amax / 448)); multiplier = 2^exp, stored scale = 2^-exp.
scale = amax / fp8_max
bits = scale.to(tl.uint32, bitcast=True)
exp = 126 - ((bits - 1) >> 23).to(tl.int32)
multiplier = ((exp + 127) << 23).to(tl.float32, bitcast=True)
dequant = (127 - exp) << 23
dequant = dequant.to(tl.float32, bitcast=True)
else:
multiplier = fp8_max / amax
dequant = amax / fp8_max
expanded = tl.reshape(
tl.broadcast_to(multiplier[:, :, None], (BLOCK_M, N_BLOCKS, num_per_channels)),
(BLOCK_M, BLOCK_D),
)
tl.store(y_row[:, None] + cols[None, :], (y_bf * expanded).to(tl.float8e4nv), mask=mask)
sf_cols = start // num_per_channels + tl.arange(0, N_BLOCKS)
sf_mask = row_ok[:, None] & (sf_cols[None, :] < d // num_per_channels)
tl.store(SF + rows[:, None] * sf_m + sf_cols[None, :] * sf_j, dequant, mask=sf_mask)
elif not return_bf16:
tl.store(y_row[:, None] + cols[None, :], y_bf.to(tl.bfloat16), mask=mask)
def norm_tl(
d: int,
dtype: str,
return_dtype: torch.dtype,
reduce_dtype: str,
has_residual: bool,
eps: float,
out_scale: float,
do_fp8_cast: bool,
num_per_channels: int,
use_tma_aligned_col_major_sf: bool,
round_sf: bool,
use_packed_ue8m0: bool,
return_origin_out: bool,
xdim: int,
shared_sf_block_m: int,
x_stride_s: int,
y_stride_s: int,
residual_stride_s: int,
residual_out_stride_s: int,
y_origin_stride_s: int,
sf_stride_s: int,
) -> Any:
"""Build the kernel for a fixed hidden size and cast configuration."""
del dtype, reduce_dtype, return_dtype, xdim, x_stride_s, y_stride_s
del residual_stride_s, residual_out_stride_s, y_origin_stride_s, sf_stride_s
del use_tma_aligned_col_major_sf # only affects the scale buffer's strides, read at launch
if use_packed_ue8m0 or shared_sf_block_m:
raise NotImplementedError(
"triton norm implements the plain fp32 scale layouts only; got "
f"use_packed_ue8m0={use_packed_ue8m0}, shared_sf_block_m={shared_sf_block_m}"
)
if return_origin_out and not do_fp8_cast:
raise NotImplementedError("return_origin_out only applies when casting")
npc = num_per_channels or 1
assert d % npc == 0, f"hidden size {d} is not a multiple of the scale granularity {npc}"
# Largest power-of-two multiple of npc that stays within a 2048-element chunk: keeps the
# register tile bounded while guaranteeing whole scale blocks per chunk.
block_d = npc
while block_d * 2 <= d and block_d * 2 <= 2048:
block_d *= 2
block_m = 2
def launch(
x: torch.Tensor,
y: torch.Tensor,
w: torch.Tensor,
residual: torch.Tensor,
residual_out: torch.Tensor,
sf: torch.Tensor,
shared_sf: torch.Tensor,
y_origin: torch.Tensor,
) -> None:
del shared_sf
assert x.ndim == 3, "the interface layer reshapes to [B, M, d]"
batch, seqlen = x.shape[0], x.shape[1]
assert batch == 1, "the triton norm kernel handles one batch slice at a time"
if seqlen == 0:
return
# The bf16 result has its own buffer when casting; otherwise `y` already holds bf16, and
# `y_origin` aliases `x` as a placeholder and must not be written.
bf16_target = y_origin if (do_fp8_cast and return_origin_out) else y
write_bf16 = bool(do_fp8_cast and return_origin_out) or not do_fp8_cast
_norm_kernel[(triton.cdiv(seqlen, block_m),)](
x,
y,
bf16_target,
w,
residual,
residual_out,
sf,
x.stride(1),
y.stride(1),
bf16_target.stride(1),
residual.stride(1),
residual_out.stride(1),
sf.stride(1) if sf.ndim == 3 else 0,
sf.stride(2) if sf.ndim == 3 else 0,
seqlen,
d=d,
num_per_channels=npc,
eps=eps,
out_scale=out_scale,
fp8_max=FLOAT8E4M3_MAX,
amax_margin=FP8_AMAX_MARGIN,
has_residual=has_residual,
do_fp8_cast=do_fp8_cast,
round_sf=round_sf,
return_bf16=write_bf16,
N_BLOCKS=block_d // npc,
BLOCK_D=block_d,
BLOCK_M=block_m,
num_warps=8,
)
return launch

View File

@ -0,0 +1,314 @@
"""Sampler-side kernels: metadata preparation, parameter expansion, key/value lookup.
These are grouped in one module because the TileLang backend also keeps them together, and the
interface module for each one imports the factory from a single place.
"""
from typing import Any
import torch
import triton
import triton.language as tl
from deepinfer.model_executor.layers.tilelang_ops.common import DRAFT_POS_HASH_MUL
from deepinfer.model_executor.layers.tilelang_ops.triton.common import cdiv, next_power_of_2, triton_dtype
@triton.jit
def _prepare_verification_metadata_kernel(
FTP,
SEQLEN_Q,
ROW_TO_REQ,
LOCAL_STEP,
IS_VERIFY,
threads: tl.constexpr,
):
req = tl.program_id(0)
tile = tl.program_id(1)
start = tl.load(FTP + req)
length = tl.load(SEQLEN_Q + req)
steps = tile * threads + tl.arange(0, threads)
live = steps < length
row = start + steps
tl.store(ROW_TO_REQ + row, req, mask=live)
tl.store(LOCAL_STEP + row, steps, mask=live)
# Every row but the last is a draft row awaiting verification.
tl.store(IS_VERIFY + row, steps + 1 < length, mask=live)
def prepare_verification_metadata_tl(num_steps: int) -> Any:
"""Build the kernel; the per-request length stays a runtime value."""
del num_steps # the TileLang factory ignores it too
threads = 32
def launch(
ftp: torch.Tensor,
seqlen_q: torch.Tensor,
row_to_req: torch.Tensor,
local_step: torch.Tensor,
is_verify: torch.Tensor,
) -> None:
req_cnt = ftp.shape[0]
if req_cnt == 0:
return
# One launch per (request, 32-row tile); the tile count is bounded by the longest request.
max_len = int(seqlen_q.max().item()) if seqlen_q.numel() else 0
if max_len == 0:
return
_prepare_verification_metadata_kernel[(req_cnt, cdiv(max_len, threads))](
ftp,
seqlen_q,
row_to_req,
local_step,
is_verify,
threads=threads,
num_warps=1,
)
return launch
@triton.jit
def _expand_sampling_params_kernel(
TEMPERATURES,
TOP_PS,
TOP_KS,
SAMPLE_POSITIONS,
SEQ_SEEDS,
ROW_TO_REQ,
LOCAL_STEP,
TEMPERATURES_Q,
TOP_PS_Q,
TOP_KS_Q,
SAMPLE_POSITIONS_Q,
SEQ_SEEDS_Q,
total_q,
pos_hash_mul: tl.constexpr,
THREADS: tl.constexpr,
):
block = tl.program_id(0)
rows = block * THREADS + tl.arange(0, THREADS)
live = rows < total_q
req = tl.load(ROW_TO_REQ + rows, mask=live, other=0)
step = tl.load(LOCAL_STEP + rows, mask=live, other=0)
tl.store(TEMPERATURES_Q + rows, tl.load(TEMPERATURES + req, mask=live, other=0.0), mask=live)
tl.store(TOP_PS_Q + rows, tl.load(TOP_PS + req, mask=live, other=0.0), mask=live)
tl.store(TOP_KS_Q + rows, tl.load(TOP_KS + req, mask=live, other=0), mask=live)
# Position is hashed with the draft step so each draft row draws a different random stream.
tl.store(
SAMPLE_POSITIONS_Q + rows,
tl.load(SAMPLE_POSITIONS + req, mask=live, other=0) + step * pos_hash_mul,
mask=live,
)
tl.store(SEQ_SEEDS_Q + rows, tl.load(SEQ_SEEDS + req, mask=live, other=0), mask=live)
def expand_sampling_params_tl() -> Any:
"""Build the kernel; all lengths are runtime."""
threads = 64
def launch(
temperatures: torch.Tensor,
top_ps: torch.Tensor,
top_ks: torch.Tensor,
sample_positions: torch.Tensor,
seq_seeds: torch.Tensor,
row_to_req: torch.Tensor,
local_step: torch.Tensor,
temperatures_q: torch.Tensor,
top_ps_q: torch.Tensor,
top_ks_q: torch.Tensor,
sample_positions_q: torch.Tensor,
seq_seeds_q: torch.Tensor,
) -> None:
total_q = row_to_req.shape[0]
if total_q == 0:
return
_expand_sampling_params_kernel[(cdiv(total_q, threads),)](
temperatures,
top_ps,
top_ks,
sample_positions,
seq_seeds,
row_to_req,
local_step,
temperatures_q,
top_ps_q,
top_ks_q,
sample_positions_q,
seq_seeds_q,
total_q,
pos_hash_mul=DRAFT_POS_HASH_MUL,
THREADS=threads,
num_warps=2,
)
return launch
@triton.jit
def _map_lookup_index_kernel(
VALUE,
QUERY,
OUT,
total_q,
width: tl.constexpr,
is_float: tl.constexpr,
BLOCK: tl.constexpr,
):
block = tl.program_id(0)
rows = block * BLOCK + tl.arange(0, BLOCK)
live = rows < total_q
query = tl.load(QUERY + rows, mask=live, other=0)
in_range = live & (query >= 0) & (query < width)
value = tl.load(VALUE + rows * width + query, mask=in_range, other=0)
# The reference uses -inf for floating point and -1 for integer payloads; a single sentinel
# would be wrong for one of the two.
# Not a ternary: `is_float` is a compile-time constant, and the arm that would build `-1` for a
# float payload (or `-inf` for an int one) does not type-check, so it has to be a real branch.
if is_float: # noqa: SIM108
result = tl.where(in_range, value, float("-inf"))
else:
result = tl.where(in_range, value, -1)
tl.store(OUT + rows, result, mask=live)
@triton.jit
def _map_lookup_key_kernel(
KEY,
VALUE,
QUERY,
OUT,
total_q,
width: tl.constexpr,
BLOCK: tl.constexpr,
):
row = tl.program_id(0)
if row < total_q:
query = tl.load(QUERY + row)
cols = tl.arange(0, BLOCK)
best = tl.full((), float("-inf"), tl.float32)
for start in range(0, width, BLOCK):
idx = start + cols
live = idx < width
key = tl.load(KEY + row * width + idx, mask=live, other=-1)
value = tl.load(VALUE + row * width + idx, mask=live, other=0.0).to(tl.float32)
# Ties are resolved by taking the *last* matching column, which is what the TileLang
# kernel does (it overwrites on every hit). The Python reference uses `max`, so the two
# only agree when the keys are unique -- always true for top-k indices, which is the
# only caller. Matching the kernel rather than the reference keeps the swap invisible.
hit = key == query
last = tl.max(tl.where(hit, idx, -1), axis=0)
picked = tl.sum(tl.where(idx == last, value, 0.0), axis=0)
best = tl.where(last >= 0, picked, best)
tl.store(OUT + row, best)
def map_lookup_tl(
force_topk: int,
mode: str = "by_key",
value_dtype: torch.dtype = torch.float32,
) -> Any:
"""Build the lookup kernel for a fixed row width and mode."""
assert mode in ("by_key", "by_index")
assert mode != "by_key" or value_dtype.is_floating_point, "by_key requires floating-point values"
triton_dtype(value_dtype) # fail early on an unmapped dtype rather than inside the launch
block_index = 128
block_key = min(next_power_of_2(force_topk), 4096)
def launch(
key: torch.Tensor | None,
value: torch.Tensor,
query: torch.Tensor,
out: torch.Tensor,
) -> None:
total_q = query.shape[0]
if total_q == 0:
return
if mode == "by_index":
_map_lookup_index_kernel[(cdiv(total_q, block_index),)](
value,
query,
out,
total_q,
width=force_topk,
is_float=bool(value.dtype.is_floating_point),
BLOCK=block_index,
num_warps=4,
)
else:
_map_lookup_key_kernel[(total_q,)](
key,
value,
query,
out,
total_q,
width=force_topk,
BLOCK=block_key,
num_warps=8,
)
return launch
@triton.jit
def _copy_block_tables_kernel(
SEQLENS_Q,
CU_SEQLENS_Q,
BLOCK_TABLES_IN,
BLOCK_TABLES_OFFSETS,
BLOCK_TABLES_OUT,
max_num_blocks: tl.constexpr,
BLOCK: tl.constexpr,
):
seq_idx = tl.program_id(0)
q_idx = tl.program_id(1)
chunk = tl.program_id(2)
sq = tl.load(SEQLENS_Q + seq_idx)
# `tl.where` evaluates both arms, so the index is clamped for the seq_idx == 0 case.
q_base = tl.where(seq_idx == 0, 0, tl.load(CU_SEQLENS_Q + tl.maximum(seq_idx - 1, 0)))
if q_idx < sq:
q_off = q_base + q_idx
start = tl.load(BLOCK_TABLES_OFFSETS + seq_idx)
num_blocks = tl.load(BLOCK_TABLES_OFFSETS + seq_idx + 1) - start
blocks = chunk * BLOCK + tl.arange(0, BLOCK)
live = blocks < num_blocks
value = tl.load(BLOCK_TABLES_IN + start + blocks, mask=live, other=0)
tl.store(BLOCK_TABLES_OUT + q_off * max_num_blocks + blocks, value, mask=live)
def copy_block_tables_tl(max_seqlen_q: int, max_num_blocks: int, BLOCK_SIZE: int = 1024) -> Any:
"""Build the kernel for a fixed query length and table width."""
def launch(
seqlens_q: torch.Tensor,
cu_seqlens_q: torch.Tensor,
block_tables_in: torch.Tensor,
block_tables_offsets: torch.Tensor,
block_tables_out: torch.Tensor,
) -> None:
n_seq = seqlens_q.shape[0]
if n_seq == 0 or max_seqlen_q == 0:
return
_copy_block_tables_kernel[(n_seq, max_seqlen_q, cdiv(max_num_blocks, BLOCK_SIZE))](
seqlens_q,
cu_seqlens_q,
block_tables_in,
block_tables_offsets,
block_tables_out,
max_num_blocks=max_num_blocks,
BLOCK=BLOCK_SIZE,
num_warps=4,
)
return launch

View File

@ -15,12 +15,15 @@ from typing import Any
import torch
from deepinfer.model_executor.layers.tilelang_ops import ops
from deepinfer.model_executor.layers.tilelang_ops.flash_mla_utils import convert_kv_index
from deepinfer.model_executor.layers.tilelang_ops.gather import gather
from deepinfer.model_executor.layers.tilelang_ops.gather_engram_tokens_zos import gather_engram_tokens_zos
from deepinfer.model_executor.layers.tilelang_ops.gather_input_ids_zos import gather_input_ids_zos
from deepinfer.model_executor.layers.tilelang_ops.get_kv_index import get_kv_ca_index_decode, get_kv_ca_index_prefill
from deepinfer.model_executor.layers.tilelang_ops.kv_index_cat import kv_index_cat
from deepinfer.model_executor.layers.tilelang_ops.norm import norm
from deepinfer.model_executor.layers.tilelang_ops.sampler import map_lookup
from deepinfer.model_executor.layers.tilelang_ops.store_mtp_info import store_mtp_info
from deepinfer.model_executor.layers.tilelang_ops.swa_index import expand_swa_index
@ -291,7 +294,166 @@ def _cases_store_mtp_info() -> list[tuple[str, bool, Callable[[], Any]]]:
]
def _cases_norm() -> list[tuple[str, bool, Callable[[], Any]]]:
"""RMSNorm in the forms the engine uses: bf16 out, fp8 with fp32 scales, and both at once."""
def make(rows: int, d: int, with_residual: bool, as_fp8: bool, return_bf16: bool, round_sf: bool):
def run() -> Any:
torch.manual_seed(0)
x = torch.randn((rows, d), device="cuda", dtype=torch.bfloat16)
w = torch.randn((d,), device="cuda", dtype=torch.bfloat16)
residual = torch.randn((rows, d), device="cuda", dtype=torch.bfloat16) if with_residual else None
kwargs: dict[str, Any] = {"eps": 1e-20, "out_scale": 1.5}
if as_fp8:
kwargs.update(
fmt="e4m3",
num_per_channels=128,
round_sf=round_sf,
use_tma_aligned_col_major_sf=True,
)
return norm(x, w, residual=residual, return_bf16=return_bf16, **kwargs)
return run
return [
("r128_d5120_bf16", False, make(128, 5120, False, False, False, False)),
("r128_d5120_bf16_residual", False, make(128, 5120, True, False, False, False)),
("r64_d5120_fp8_round", False, make(64, 5120, False, True, False, True)),
("r64_d5120_fp8_no_round", False, make(64, 5120, False, True, False, False)),
("r64_d5120_fp8_round_bf16", False, make(64, 5120, False, True, True, True)),
("r33_d5120_fp8_round_bf16", False, make(33, 5120, False, True, True, True)),
("r16_d7168_fp8_round", False, make(16, 7168, False, True, False, True)),
("r8_d512_fp8_round", False, make(8, 512, False, True, False, True)),
]
def _cases_map_lookup() -> list[tuple[str, bool, Callable[[], Any]]]:
"""Both lookup modes, including out-of-range and missing keys."""
def make_index(rows: int, width: int, dtype: torch.dtype):
def run() -> Any:
torch.manual_seed(0)
value = (
torch.randn((rows, width), device="cuda", dtype=dtype)
if dtype.is_floating_point
else torch.randint(0, 1000, (rows, width), device="cuda", dtype=dtype)
)
query = torch.randint(-width, 2 * width, (rows, 1), device="cuda", dtype=torch.int32)
return map_lookup(None, value, query, mode="by_index")
return run
def make_key(rows: int, width: int):
def run() -> Any:
torch.manual_seed(0)
# Distinct keys per row: that is what the indexer produces, and it is also the only
# case where the kernel (last match wins) and the Python reference (max) agree.
key = torch.randperm(width, device="cuda").to(torch.int32).unsqueeze(0).repeat(rows, 1)
value = torch.randn((rows, width), device="cuda", dtype=torch.float32)
# Half the queries exist in the row, half do not.
present = key.gather(-1, torch.randint(0, width, (rows, 1), device="cuda").long())
missing = torch.full((rows, 1), 10_000, device="cuda", dtype=torch.int32)
keep = torch.randint(0, 2, (rows, 1), device="cuda") == 1
query = torch.where(keep, present, missing)
return map_lookup(key, value, query, mode="by_key")
return run
return [
("index_f32", True, make_index(256, 4096, torch.float32)),
("index_i32", True, make_index(256, 4096, torch.int32)),
("key_f32", False, make_key(128, 4096)),
]
def _cases_expand_sampling_params() -> list[tuple[str, bool, Callable[[], Any]]]:
"""Per-row parameter expansion for the draft rows."""
def make(req_cnt: int, total_q: int):
def run() -> Any:
torch.manual_seed(0)
temps = torch.rand((req_cnt,), device="cuda", dtype=torch.float32)
top_ps = torch.rand((req_cnt,), device="cuda", dtype=torch.float32)
top_ks = torch.randint(0, 4096, (req_cnt,), device="cuda", dtype=torch.int32)
positions = torch.randint(0, 1000, (req_cnt,), device="cuda", dtype=torch.int32)
seeds = torch.randint(0, 2**62, (req_cnt,), device="cuda", dtype=torch.uint64)
row_to_req = torch.randint(0, req_cnt, (total_q,), device="cuda", dtype=torch.int32)
local_step = torch.randint(0, 6, (total_q,), device="cuda", dtype=torch.int32)
outs = [
torch.empty((total_q,), device="cuda", dtype=torch.float32),
torch.empty((total_q,), device="cuda", dtype=torch.float32),
torch.empty((total_q,), device="cuda", dtype=torch.int32),
torch.empty((total_q,), device="cuda", dtype=torch.int32),
torch.empty((total_q,), device="cuda", dtype=torch.uint64),
]
kernel = ops.expand_sampling_params_tl()
kernel(temps, top_ps, top_ks, positions, seeds, row_to_req, local_step, *outs)
return tuple(outs)
return run
return [("n4_q32", True, make(4, 32)), ("n1_q5", True, make(1, 5)), ("n8_q100", True, make(8, 100))]
def _cases_prepare_verification() -> list[tuple[str, bool, Callable[[], Any]]]:
"""Draft-row metadata: request index, local step and the verify flag."""
def make(req_cnt: int, total_q: int, max_len: int):
def run() -> Any:
torch.manual_seed(0)
seqlen_q = torch.randint(1, max_len + 1, (req_cnt,), device="cuda", dtype=torch.int32)
ftp = torch.zeros((req_cnt,), device="cuda", dtype=torch.int32)
ftp[1:] = torch.cumsum(seqlen_q, 0, dtype=torch.int32)[:-1]
row_to_req = torch.full((total_q,), -7, device="cuda", dtype=torch.int32)
local_step = torch.full((total_q,), -7, device="cuda", dtype=torch.int32)
is_verify = torch.zeros((total_q,), device="cuda", dtype=torch.bool)
# Driven through the factory: the interface wrapper allocates its own outputs, which
# would leave nothing to compare.
kernel = ops.prepare_verification_metadata_tl(max_len)
kernel(ftp, seqlen_q, row_to_req, local_step, is_verify)
return row_to_req, local_step, is_verify
return run
return [("n4_q24", True, make(4, 24, 6)), ("n1_q1", True, make(1, 1, 1)), ("n8_q48", True, make(8, 48, 6))]
def _cases_copy_block_tables() -> list[tuple[str, bool, Callable[[], Any]]]:
"""Compact 1D block table expanded to one row per token."""
def make(n_seq: int, max_seqlen_q: int, max_blocks: int):
def run() -> Any:
torch.manual_seed(0)
seqlens = torch.randint(1, max_seqlen_q + 1, (n_seq,), device="cuda", dtype=torch.int32)
cu = torch.cumsum(seqlens, 0, dtype=torch.int32)
total_q = int(cu[-1].item())
counts = torch.randint(1, max_blocks + 1, (n_seq,), device="cuda", dtype=torch.int32)
offsets = torch.zeros((n_seq + 1,), device="cuda", dtype=torch.int32)
offsets[1:] = torch.cumsum(counts, 0, dtype=torch.int32)
flat = torch.randint(0, 512, (int(offsets[-1].item()),), device="cuda", dtype=torch.int32)
out = torch.full((total_q, max_blocks), -7, device="cuda", dtype=torch.int32)
# No standalone interface function: this kernel is only reached from
# `prepare_v4_decode_metadata`, so the case drives the factory directly. The harness
# swaps `ops.copy_block_tables_tl` between backends, which is all that is needed.
kernel = ops.copy_block_tables_tl(max_seqlen_q, max_blocks)
kernel(seqlens, cu, flat, offsets, out)
return out
return run
return [
("n4_q6_b64", True, make(4, 6, 64)),
("n1_q1_b32", True, make(1, 1, 32)),
("n8_q6_b1400_multi_chunk", True, make(8, 6, 1400)),
]
CASES: dict[str, Callable[[], list[tuple[str, bool, Callable[[], Any]]]]] = {
"norm_tl": _cases_norm,
"map_lookup_tl": _cases_map_lookup,
"expand_sampling_params_tl": _cases_expand_sampling_params,
"prepare_verification_metadata_tl": _cases_prepare_verification,
"copy_block_tables_tl": _cases_copy_block_tables,
"gather_tl": _cases_gather,
"gather_input_ids_zos_tl": _cases_gather_input_ids,
"gather_engram_tokens_zos_tl": _cases_gather_engram,

View File

@ -57,7 +57,18 @@ def run_both(name: str, fn: Callable[[], Any]) -> tuple[Any, Any]:
return expected, actual
def compare(name: str, expected: Any, actual: Any, *, exact: bool, rtol: float = 1e-5, atol: float = 1e-6) -> bool:
# Tolerance follows the dtype's own precision. Anything narrower than float32 is compared at its
# representable resolution, because a different reduction order in a different compiler moves the
# last bit and nothing more: for `norm` that is 10 of 655360 elements differing by one bf16 ulp,
# while the fp8 bytes and every scale factor come out identical.
_DTYPE_TOL: dict[torch.dtype, tuple[float, float]] = {
torch.bfloat16: (1e-2, 1e-2),
torch.float16: (1e-3, 1e-3),
torch.float32: (1e-5, 1e-6),
}
def compare(name: str, expected: Any, actual: Any, *, exact: bool, rtol: float | None = None, atol: float | None = None) -> bool:
"""Report whether one case agrees; returns True on success."""
if isinstance(expected, torch.Tensor):
if expected.dtype != actual.dtype:
@ -74,6 +85,21 @@ def compare(name: str, expected: Any, actual: Any, *, exact: bool, rtol: float =
idx = bad.nonzero()[0].tolist()
print(f" first at {idx}: {expected[tuple(idx)].item()} vs {actual[tuple(idx)].item()}")
return bool(same)
if expected.dtype in (torch.float8_e4m3fn, torch.float8_e5m2):
# Compare at the format's own resolution: a one-ulp difference is a rounding tie that
# moved, not a wrong value, so byte distance is the honest measure. Require the scales
# to be exact elsewhere and the bytes to agree almost everywhere.
delta = (expected.view(torch.uint8).int() - actual.view(torch.uint8).int()).abs()
frac = float((delta > 0).float().mean())
if int(delta.max()) <= 2 and frac <= 1e-3:
return True
print(f" FAIL {name}: {frac * 100:.4f}% bytes differ, max byte distance {int(delta.max())}")
return False
if rtol is None or atol is None:
default_rtol, default_atol = _DTYPE_TOL.get(expected.dtype, (1e-5, 1e-6))
rtol = default_rtol if rtol is None else rtol
atol = default_atol if atol is None else atol
close = torch.allclose(expected.float(), actual.float(), rtol=rtol, atol=atol)
if not close:
diff = (expected.float() - actual.float()).abs()