diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e4b353e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.py text eol=lf +*.sh text eol=lf +*.md text eol=lf diff --git a/benchmarks/benchmark_mla_rope_cache.py b/benchmarks/benchmark_mla_rope_cache.py new file mode 100644 index 0000000..574fa01 --- /dev/null +++ b/benchmarks/benchmark_mla_rope_cache.py @@ -0,0 +1,207 @@ +"""Compare fused NineToothed MLA RoPE/cache write against the vLLM chain.""" + +import argparse +import statistics +import time + +import torch + +import ntops +import ntops.kernels.mla_rope_cache as primary_kernel +import ntops.kernels.mla_rope_cache_pair as pair_kernel +from ntops.torch.mla_rope_cache_pair import mla_rope_cache_pair + + +def _import_vllm_mla(): + """Import the unchanged vLLM MLA operators on supported vendor stacks.""" + # CoreX exposes its CUDA-compatible device through NVML. Hiding that + # device makes older vendor vLLM builds fall back to an unspecified + # platform, so preserve the normal discovery path on IX-ML. + if getattr(torch, "corex", False): + from vllm import _custom_ops as ops + from vllm.model_executor.layers.rotary_embedding import get_rope + + return ops, get_rope + + # Some BW images expose both ROCm SMI and an NVML compatibility shim. + # vLLM then detects two built-in platforms and aborts during import. Hide + # only the spurious CUDA discovery while vLLM resolves its platform; the + # timed RoPE and concat/cache operators remain the unmodified vLLM code. + try: + from vllm.utils import import_pynvml + except ImportError: + from vllm.utils.import_utils import import_pynvml + + pynvml = import_pynvml() + original = pynvml.nvmlDeviceGetCount + pynvml.nvmlDeviceGetCount = lambda: 0 + + try: + from vllm import _custom_ops as ops + from vllm.model_executor.layers.rotary_embedding import get_rope + + return ops, get_rope + finally: + pynvml.nvmlDeviceGetCount = original + + +def _rotate_interleaved(value, cos_sin): + cos, sin = cos_sin.chunk(2, dim=-1) + cos = cos.repeat_interleave(2, dim=-1).unsqueeze(-2).float() + sin = sin.repeat_interleave(2, dim=-1).unsqueeze(-2).float() + even = value[..., 0::2].float() + odd = value[..., 1::2].float() + output = torch.empty_like(value, dtype=torch.float32) + output[..., 0::2] = even * cos[..., 0::2] - odd * sin[..., 0::2] + output[..., 1::2] = odd * cos[..., 1::2] + even * sin[..., 1::2] + return output.to(value.dtype) + + +def _time(function, iterations): + torch.cuda.synchronize() + started = time.perf_counter() + + for _ in range(iterations): + function() + + torch.cuda.synchronize() + return (time.perf_counter() - started) * 1000 / iterations + + +def _paired_median(first, second, warmup=100, iterations=1000, rounds=9): + for _ in range(warmup): + first() + second() + + timings = ([], []) + + for round_index in range(rounds): + order = (0, 1) if round_index % 2 == 0 else (1, 0) + + for index in order: + function = first if index == 0 else second + timings[index].append(_time(function, iterations)) + + return tuple(statistics.median(values) for values in timings) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--num-heads", type=int, default=32) + parser.add_argument( + "--implementation", + choices=("primary", "pair"), + default="primary", + ) + parser.add_argument("--num-warps", type=int) + parser.add_argument("--tokens", type=int, choices=(1, 16, 128)) + args = parser.parse_args() + if args.num_warps is not None: + primary_kernel.NUM_WARPS = args.num_warps + pair_kernel.NUM_WARPS = args.num_warps + submission_op = ( + ntops.torch.mla_rope_cache + if args.implementation == "primary" + else mla_rope_cache_pair + ) + + ops, get_rope = _import_vllm_mla() + torch.manual_seed(0) + dtype = torch.bfloat16 + rope_dim = 64 + kv_lora_rank = 512 + num_heads = args.num_heads + max_position = 16384 + rope = get_rope( + rope_dim, + rope_dim, + max_position, + 10000.0, + is_neox_style=False, + dtype=dtype, + ) + rope.cos_sin_cache = rope.cos_sin_cache.to(device="cuda", dtype=dtype) + + cases = ((1, 16), (16, 16), (128, 64)) + if args.tokens is not None: + cases = tuple(case for case in cases if case[0] == args.tokens) + + for tokens, block_size in cases: + num_blocks = 64 + positions = torch.randperm(max_position, device="cuda")[:tokens] + slots = torch.randperm(num_blocks * block_size, device="cuda")[:tokens] + q_input = torch.randn( + tokens, num_heads, rope_dim, device="cuda", dtype=dtype + ) + k_input = torch.randn(tokens, 1, rope_dim, device="cuda", dtype=dtype) + kv_c = torch.randn(tokens, kv_lora_rank, device="cuda", dtype=dtype) + cache_shape = (num_blocks, block_size, kv_lora_rank + rope_dim) + scale = torch.ones(1, device="cuda", dtype=torch.float32) + + cos_sin = rope.cos_sin_cache.index_select(0, positions) + expected_q = _rotate_interleaved(q_input, cos_sin) + expected_k = _rotate_interleaved(k_input, cos_sin).squeeze(1) + expected_cache = torch.zeros(cache_shape, device="cuda", dtype=dtype) + combined = torch.cat((kv_c, expected_k), dim=-1) + + for token, slot in enumerate(slots.tolist()): + expected_cache[slot // block_size, slot % block_size] = combined[token] + + q_vllm = q_input.clone() + k_vllm = k_input.clone() + # IX-ML's vLLM ABI retains the singleton KV-head dimension, while the + # ROCm vLLM ABI accepts the equivalent flattened PE row. + k_vllm_cache = ( + k_vllm if getattr(torch, "corex", False) else k_vllm.squeeze(1) + ) + cache_vllm = torch.zeros(cache_shape, device="cuda", dtype=dtype) + rope.forward_cuda(positions, q_vllm, k_vllm) + ops.concat_and_cache_mla( + kv_c, k_vllm_cache, cache_vllm, slots, "auto", scale + ) + torch.testing.assert_close(q_vllm, expected_q, rtol=2e-2, atol=2e-2) + torch.testing.assert_close(cache_vllm, expected_cache, rtol=2e-2, atol=2e-2) + + q_nt = q_input.clone() + cache_nt = torch.zeros(cache_shape, device="cuda", dtype=dtype) + submission_op( + q_nt, + k_input, + kv_c, + positions, + rope.cos_sin_cache, + slots, + cache_nt, + ) + torch.testing.assert_close(q_nt, expected_q, rtol=2e-2, atol=2e-2) + torch.testing.assert_close(cache_nt, expected_cache, rtol=2e-2, atol=2e-2) + + def vllm_baseline(): + rope.forward_cuda(positions, q_vllm, k_vllm) + ops.concat_and_cache_mla( + kv_c, k_vllm_cache, cache_vllm, slots, "auto", scale + ) + + def submission(): + submission_op( + q_nt, + k_input, + kv_c, + positions, + rope.cos_sin_cache, + slots, + cache_nt, + ) + + vllm_ms, submission_ms = _paired_median(vllm_baseline, submission) + print( + f"shape=seq{tokens}_heads{num_heads}_block{block_size} " + f"correctness=pass vllm_ms={vllm_ms:.6f} " + f"submission_ms={submission_ms:.6f} " + f"formal_speedup={vllm_ms / submission_ms:.4f}", + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/benchmark_mla_rope_cache_warps.py b/benchmarks/benchmark_mla_rope_cache_warps.py new file mode 100644 index 0000000..ea06e1b --- /dev/null +++ b/benchmarks/benchmark_mla_rope_cache_warps.py @@ -0,0 +1,99 @@ +"""Sweep Triton warp counts for the fused MLA RoPE/cache kernel.""" + +import argparse +import importlib +import statistics +import time + +import torch + +import ntops +from ntops.torch import utils + +kernel_module = importlib.import_module("ntops.kernels.mla_rope_cache") +wrapper_module = importlib.import_module("ntops.torch.mla_rope_cache") + + +def _median(function, *, warmup=100, iterations=1000, rounds=5): + for _ in range(warmup): + function() + + timings = [] + + for _ in range(rounds): + torch.cuda.synchronize() + started = time.perf_counter() + + for _ in range(iterations): + function() + + torch.cuda.synchronize() + timings.append((time.perf_counter() - started) * 1000 / iterations) + + return statistics.median(timings) + + +def _reset_wrapper(): + utils._cached_make.cache_clear() + wrapper_module._LAST_VALIDATED = None + wrapper_module._LAST_VIEWS = None + wrapper_module._LAST_KERNEL = None + wrapper_module._LAST_CALL = None + + +def _inputs(tokens, block_size, num_heads): + dtype = torch.bfloat16 + rope_dim = 64 + kv_lora_rank = 512 + max_position = 16384 + num_blocks = 64 + frequencies = 1.0 / ( + 10000.0 + ** (torch.arange(0, rope_dim, 2, device="cuda").float() / rope_dim) + ) + angles = torch.outer( + torch.arange(max_position, device="cuda").float(), frequencies + ) + cos_sin_cache = torch.cat((angles.cos(), angles.sin()), dim=-1).to(dtype) + positions = torch.randperm(max_position, device="cuda")[:tokens] + slots = torch.randperm(num_blocks * block_size, device="cuda")[:tokens] + q = torch.randn(tokens, num_heads, rope_dim, device="cuda", dtype=dtype) + k = torch.randn(tokens, 1, rope_dim, device="cuda", dtype=dtype) + kv_c = torch.randn(tokens, kv_lora_rank, device="cuda", dtype=dtype) + cache = torch.zeros( + num_blocks, + block_size, + kv_lora_rank + rope_dim, + device="cuda", + dtype=dtype, + ) + return q, k, kv_c, positions, cos_sin_cache, slots, cache + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--num-heads", type=int, default=32) + args = parser.parse_args() + + torch.manual_seed(0) + + for warps in (1, 2, 4, 8): + kernel_module.NUM_WARPS = warps + + for tokens, block_size in ((1, 16), (16, 16), (128, 64)): + _reset_wrapper() + inputs = _inputs(tokens, block_size, args.num_heads) + + def submission(): + ntops.torch.mla_rope_cache(*inputs) + + latency_ms = _median(submission) + print( + f"warps={warps} seq={tokens} heads={args.num_heads} " + f"latency_ms={latency_ms:.6f}", + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/benchmark_mxfp4_grouped_gemm_reference.py b/benchmarks/benchmark_mxfp4_grouped_gemm_reference.py new file mode 100644 index 0000000..451d1ff --- /dev/null +++ b/benchmarks/benchmark_mxfp4_grouped_gemm_reference.py @@ -0,0 +1,271 @@ +"""T1 MXFP4 grouped GEMM reference comparison. + +The benchmark separates three cases: + +* ``pytorch_native``: PyTorch's native ``_scaled_grouped_mm`` when the + installed build accepts the T1 packed MXFP4 tensors; +* ``pytorch_raw_reference``: a GPU PyTorch implementation that decodes the + vLLM/OCP MXFP4 E2M1 + E8M0 representation on every call; +* ``vllm_fallback_reference``: the same vLLM fallback execution model after + weights have been dequantized once, followed by grouped BF16 GEMMs. + +The last two are explicit semantic references, not silently relabelled as a +native PyTorch kernel. The output reports native API availability separately. +""" + +import argparse +import csv +import importlib +import platform +import statistics +import time +from pathlib import Path + +import torch + +import ntops + +_E2M1_VALUES = ( + 0.0, + 0.5, + 1.0, + 1.5, + 2.0, + 3.0, + 4.0, + 6.0, + -0.0, + -0.5, + -1.0, + -1.5, + -2.0, + -3.0, + -4.0, + -6.0, +) + + +def _decode_weight(packed_weight, weight_scale): + table = torch.tensor( + _E2M1_VALUES, + dtype=torch.float32, + device=packed_weight.device, + ) + decoded = torch.empty( + (*packed_weight.shape[:-1], packed_weight.shape[-1] * 2), + dtype=torch.float32, + device=packed_weight.device, + ) + decoded[..., 0::2] = table[(packed_weight & 0x0F).long()] + decoded[..., 1::2] = table[(packed_weight >> 4).long()] + decoded.mul_(torch.exp2(weight_scale.float() - 127.0).repeat_interleave(32, dim=-1)) + return decoded + + +def _grouped_mm(input, decoded_weight, offsets, output): + starts = (0, *offsets[:-1]) + for expert, (start, end) in enumerate(zip(starts, offsets)): + output[start:end].copy_( + torch.mm(input[start:end], decoded_weight[expert].t()).to(output.dtype) + ) + return output + + +def _native_probe(input, packed_weight, scale, offsets, dtype): + function = getattr(torch, "_scaled_grouped_mm", None) + if not callable(function): + return "unavailable:torch._scaled_grouped_mm_missing" + try: + output = function( + input, + packed_weight, + torch.ones((1,), device=input.device, dtype=torch.float32), + scale, + offsets, + out_dtype=dtype, + ) + torch.cuda.synchronize() + return f"available:shape={tuple(output.shape)}" + except Exception as error: + return f"unavailable:{type(error).__name__}:{error}" + + +def _measure(function, warmup, iterations, rounds): + for _ in range(warmup): + function() + torch.cuda.synchronize() + samples = [] + for _ in range(rounds): + started = time.perf_counter() + for _ in range(iterations): + function() + torch.cuda.synchronize() + samples.append((time.perf_counter() - started) * 1000 / iterations) + return statistics.median(samples), samples + + +def _parse_args(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--experts", type=int, default=4) + parser.add_argument("--n", type=int, default=256) + parser.add_argument("--k", type=int, default=256) + parser.add_argument("--counts", default="1,3,5,7") + parser.add_argument("--dtype", choices=("bfloat16", "float16"), default="bfloat16") + parser.add_argument("--warmup", type=int, default=20) + parser.add_argument("--iterations", type=int, default=100) + parser.add_argument("--rounds", type=int, default=7) + parser.add_argument("--csv", type=Path) + return parser.parse_args(argv) + + +def _append_csv(path, row): + path.parent.mkdir(parents=True, exist_ok=True) + fieldnames = tuple(row) + write_header = not path.exists() or path.stat().st_size == 0 + + with path.open("a", encoding="utf-8", newline="") as output: + writer = csv.DictWriter(output, fieldnames=fieldnames) + + if write_header: + writer.writeheader() + + writer.writerow(row) + + +def main(argv=None): + args = _parse_args(argv) + counts = tuple(int(value) for value in args.counts.split(",")) + if len(counts) != args.experts or any(value < 0 for value in counts): + raise ValueError("counts must have one nonnegative value per expert") + if args.k % 32: + raise ValueError("K must be divisible by 32") + + dtype = getattr(torch, args.dtype) + torch.manual_seed(20260829) + tokens = sum(counts) + boundaries = (0, *torch.tensor(counts).cumsum(0).tolist()) + offsets = torch.tensor(boundaries[1:], device="cuda", dtype=torch.int32) + input = torch.randn(tokens, args.k, device="cuda", dtype=dtype) + packed_weight = torch.randint( + 0, + 256, + (args.experts, args.n, args.k // 2), + device="cuda", + dtype=torch.uint8, + ) + weight_scale = torch.randint( + 120, + 132, + (args.experts, args.n, args.k // 32), + device="cuda", + dtype=torch.uint8, + ) + output = torch.empty(tokens, args.n, device="cuda", dtype=dtype) + + decoded_weight = _decode_weight(packed_weight, weight_scale) + decoded_weight_compute = decoded_weight.to(dtype) + + def pytorch_predecoded(): + return _grouped_mm(input, decoded_weight_compute, boundaries[1:], output) + + def pytorch_raw(): + return _grouped_mm( + input, + _decode_weight(packed_weight, weight_scale).to(dtype), + boundaries[1:], + output, + ) + + def submission(): + return ntops.torch.mxfp4_grouped_gemm( + input, + packed_weight, + weight_scale, + offsets, + out=output, + ) + + expected = _grouped_mm( + input.float(), decoded_weight, boundaries[1:], output + ).clone() + torch.testing.assert_close(pytorch_raw(), expected, rtol=3e-2, atol=3e-2) + first = submission().clone() + torch.testing.assert_close(first, expected, rtol=3e-2, atol=3e-2) + + wrapper = importlib.import_module("ntops.torch.mxfp4_grouped_gemm") + cold_bound = wrapper._LAST_CALL[3] + native_status = _native_probe(input, packed_weight, weight_scale, offsets, dtype) + + predecoded_ms, predecoded_samples = _measure( + pytorch_predecoded, args.warmup, args.iterations, args.rounds + ) + raw_ms, raw_samples = _measure( + pytorch_raw, args.warmup, args.iterations, args.rounds + ) + bound_ms, bound_samples = _measure( + cold_bound, args.warmup, args.iterations, args.rounds + ) + submission_ms, submission_samples = _measure( + submission, args.warmup, args.iterations, args.rounds + ) + + try: + vllm = importlib.import_module("vllm") + vllm_version = getattr(vllm, "__version__", "unknown") + except Exception as error: + vllm_version = f"unavailable:{type(error).__name__}" + + device_name = torch.cuda.get_device_name(0) + speedup = predecoded_ms / submission_ms + raw_speedup = raw_ms / submission_ms + bound_speedup = predecoded_ms / bound_ms + print( + "T1_RESULT " + f"device={device_name!r} " + f"torch={torch.__version__!r} vllm={vllm_version!r} " + f"experts={args.experts} counts={counts} n={args.n} k={args.k} " + f"dtype={args.dtype} correctness=pass " + f"pytorch_native={native_status} " + f"vllm_fallback_predecoded_ms={predecoded_ms:.6f} " + f"pytorch_raw_reference_ms={raw_ms:.6f} " + f"submission_bound_ms={bound_ms:.6f} " + f"submission_public_ms={submission_ms:.6f} " + f"speedup_vs_vllm_fallback={speedup:.6f} " + f"speedup_vs_raw_reference={raw_speedup:.6f} " + f"bound_speedup_vs_vllm_fallback={bound_speedup:.6f} " + f"predecoded_samples={predecoded_samples} raw_samples={raw_samples} " + f"bound_samples={bound_samples} submission_samples={submission_samples}", + flush=True, + ) + + if args.csv is not None: + _append_csv( + args.csv, + { + "accelerator": device_name, + "python": platform.python_version(), + "torch": torch.__version__, + "vllm": vllm_version, + "experts": args.experts, + "counts": ";".join(str(value) for value in counts), + "n": args.n, + "k": args.k, + "dtype": args.dtype, + "baseline": "vllm_semantic_fallback_predecoded", + "native_pytorch": native_status, + "correctness": "pass", + "baseline_ms": f"{predecoded_ms:.6f}", + "submission_ms": f"{submission_ms:.6f}", + "speedup": f"{speedup:.6f}", + "raw_reference_ms": f"{raw_ms:.6f}", + "raw_reference_speedup": f"{raw_speedup:.6f}", + "bound_submission_ms": f"{bound_ms:.6f}", + "warmup": args.warmup, + "iterations": args.iterations, + "rounds": args.rounds, + }, + ) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/benchmark_rms_norm_gated.py b/benchmarks/benchmark_rms_norm_gated.py new file mode 100644 index 0000000..9f5dc94 --- /dev/null +++ b/benchmarks/benchmark_rms_norm_gated.py @@ -0,0 +1,236 @@ +"""Benchmark the fused T3 kernel against the unmodified vLLM operator. + +The reported speedup follows the competition definition: + + baseline_latency / submission_latency + +Both paths are invoked through their public Python interfaces in the same +process. PyTorch eager expressions are used for correctness only and are +never timed. +""" + +import argparse +import csv +import statistics +import time +from pathlib import Path + +import torch + +import ntops + + +def _import_vllm_rms_norm_gated(): + # Some BW images expose both ROCm SMI and an NVML compatibility shim. + # vLLM 0.9 then detects both built-in platforms and aborts before the + # operator can be imported. Suppress only the spurious CUDA discovery + # while vLLM resolves its platform; the timed operator remains unchanged. + # This is the unchanged operator called by + # ``Mixer2RMSNormGated.forward_cuda``. Calling it directly avoids + # initializing vLLM's unrelated distributed model context, which is not + # registered as a standard CUDA platform by some CoreX vLLM builds. + try: + from vllm.utils import import_pynvml + except ImportError: + from vllm.utils.import_utils import import_pynvml + + pynvml = import_pynvml() + original = pynvml.nvmlDeviceGetCount + pynvml.nvmlDeviceGetCount = lambda: 0 + + try: + try: + from vllm.model_executor.layers.mamba.ops.layernorm_gated import ( + rms_norm_gated, + ) + except ImportError: + # vLLM 0.9 only exposes the operation through the layer wrapper. + from vllm.distributed.parallel_state import ( + init_distributed_environment, + initialize_model_parallel, + ) + from vllm.model_executor.layers.mamba.mamba_mixer2 import ( + Mixer2RMSNormGated, + ) + + torch.cuda.set_device(0) + init_distributed_environment( + world_size=1, + rank=0, + local_rank=0, + distributed_init_method="tcp://127.0.0.1:29532", + ) + initialize_model_parallel(1, 1) + return Mixer2RMSNormGated, False + + return rms_norm_gated, True + finally: + pynvml.nvmlDeviceGetCount = original + + +def _reference(input, gate, weight, eps): + gated = input.float() * torch.nn.functional.silu(gate.float()) + variance = gated.square().mean(dim=-1, keepdim=True) + return (gated * torch.rsqrt(variance + eps) * weight.float()).to(input.dtype) + + +def _time_once(function, iterations): + torch.cuda.synchronize() + started = time.perf_counter() + for _ in range(iterations): + function() + torch.cuda.synchronize() + return (time.perf_counter() - started) * 1000 / iterations + + +def _measure_pair(baseline, submission, warmup=50, iterations=500, rounds=7): + for _ in range(warmup): + baseline() + submission() + torch.cuda.synchronize() + + baseline_samples = [] + submission_samples = [] + for round_index in range(rounds): + if round_index % 2 == 0: + baseline_samples.append(_time_once(baseline, iterations)) + submission_samples.append(_time_once(submission, iterations)) + else: + submission_samples.append(_time_once(submission, iterations)) + baseline_samples.append(_time_once(baseline, iterations)) + + return ( + statistics.median(baseline_samples), + statistics.median(submission_samples), + baseline_samples, + submission_samples, + ) + + +def _parse_args(argv=None): + parser = argparse.ArgumentParser( + description="Benchmark the T3 submission against the vLLM baseline." + ) + parser.add_argument("--warmup", type=int, default=50) + parser.add_argument("--iterations", type=int, default=500) + parser.add_argument("--rounds", type=int, default=7) + parser.add_argument( + "--csv", + type=Path, + help="Optionally write the median latency and speedup rows as CSV.", + ) + return parser.parse_args(argv) + + +def main(argv=None): + args = _parse_args(argv) + torch.manual_seed(0) + vllm_entry, direct_vllm_op = _import_vllm_rms_norm_gated() + eps = 1e-5 + results = [] + + for tokens, hidden_size in ((64, 128), (128, 512), (16, 4096)): + if direct_vllm_op: + layer = None + weight = torch.empty( + hidden_size, device="cuda", dtype=torch.bfloat16 + ).uniform_(-1, 1) + else: + layer = vllm_entry( + full_hidden_size=hidden_size, + full_n_groups=1, + use_rms_norm=True, + eps=eps, + ).to(device="cuda", dtype=torch.bfloat16) + layer.weight.data.uniform_(-1, 1) + weight = layer.weight + input = torch.randn( + tokens, hidden_size, device="cuda", dtype=torch.bfloat16 + ) + gate = torch.randn_like(input) + + def baseline(): + if direct_vllm_op: + return vllm_entry( + input, + weight, + bias=None, + z=gate, + eps=eps, + norm_before_gate=False, + ) + return layer.forward_cuda(input, gate) + + def submission(): + return ntops.torch.rms_norm_gated( + input, + gate, + weight, + eps=eps, + group_size=hidden_size, + norm_before_gate=False, + activation="swish", + ) + + baseline_output = baseline() + submission_output = submission() + expected = _reference(input, gate, weight, eps) + torch.testing.assert_close( + baseline_output, expected, rtol=2e-2, atol=2e-2 + ) + torch.testing.assert_close( + submission_output, expected, rtol=2e-2, atol=2e-2 + ) + + ( + baseline_ms, + submission_ms, + baseline_samples, + submission_samples, + ) = _measure_pair( + baseline, + submission, + warmup=args.warmup, + iterations=args.iterations, + rounds=args.rounds, + ) + speedup = baseline_ms / submission_ms + baseline_path = ( + "direct_op" if direct_vllm_op else "layer_forward_cuda" + ) + print( + f"shape={tokens}x{hidden_size} correctness=pass " + f"vllm_path={baseline_path} " + f"vllm_ms={baseline_ms:.6f} " + f"submission_ms={submission_ms:.6f} speedup={speedup:.4f} " + f"vllm_samples={baseline_samples} " + f"submission_samples={submission_samples}", + flush=True, + ) + results.append( + { + "shape": f"{tokens}x{hidden_size}", + "tokens": tokens, + "hidden_size": hidden_size, + "correctness": "pass", + "baseline": "vLLM", + "baseline_path": baseline_path, + "baseline_ms": f"{baseline_ms:.6f}", + "submission_ms": f"{submission_ms:.6f}", + "speedup": f"{speedup:.4f}", + "warmup": args.warmup, + "iterations": args.iterations, + "rounds": args.rounds, + } + ) + + if args.csv is not None: + args.csv.parent.mkdir(parents=True, exist_ok=True) + with args.csv.open("w", encoding="utf-8", newline="") as file: + writer = csv.DictWriter(file, fieldnames=tuple(results[0])) + writer.writeheader() + writer.writerows(results) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/benchmark_rms_norm_gated_runtime_ablation.py b/benchmarks/benchmark_rms_norm_gated_runtime_ablation.py new file mode 100644 index 0000000..12cc406 --- /dev/null +++ b/benchmarks/benchmark_rms_norm_gated_runtime_ablation.py @@ -0,0 +1,133 @@ +"""Micro-ablate the checked and prevalidated NineToothed launch paths. + +This is not a competition speedup benchmark. It isolates the B-part runtime +launch optimization while keeping the generated T3 kernel and tensor objects +fixed. The competition result remains the vLLM-versus-submission measurement +in ``benchmark_rms_norm_gated.py``. +""" + +import statistics +import time + +import torch + +import ntops +from ntops.torch.utils import _cached_make + + +def _time_once(function, iterations): + torch.cuda.synchronize() + started = time.perf_counter() + for _ in range(iterations): + function() + torch.cuda.synchronize() + return (time.perf_counter() - started) * 1000 / iterations + + +def _measure_pair(lhs, rhs, warmup=50, iterations=500, rounds=7): + for _ in range(warmup): + lhs() + rhs() + torch.cuda.synchronize() + + lhs_samples = [] + rhs_samples = [] + for round_index in range(rounds): + if round_index % 2 == 0: + lhs_samples.append(_time_once(lhs, iterations)) + rhs_samples.append(_time_once(rhs, iterations)) + else: + rhs_samples.append(_time_once(rhs, iterations)) + lhs_samples.append(_time_once(lhs, iterations)) + + return ( + statistics.median(lhs_samples), + statistics.median(rhs_samples), + lhs_samples, + rhs_samples, + ) + + +def _make_kernel(input, gate, weight, output): + hidden_size = input.shape[-1] + return _cached_make( + ntops.kernels.rms_norm_gated.premake, + input.ndim, + hidden_size, + ntops.kernels.rms_norm_gated.ActivationVariant.SILU, + False, + input_dtype=input.dtype, + gate_dtype=gate.dtype, + weight_dtype=weight.dtype, + output_dtype=output.dtype, + block_size=hidden_size, + input_shape=tuple(input.shape), + gate_shape=tuple(gate.shape), + weight_shape=tuple(weight.shape), + output_shape=tuple(output.shape), + input_strides=tuple(input.stride()), + gate_strides=tuple(gate.stride()), + weight_strides=tuple(weight.stride()), + output_strides=tuple(output.stride()), + num_warps=ntops.kernels.rms_norm_gated.NUM_WARPS, + num_stages=ntops.kernels.rms_norm_gated.NUM_STAGES, + ) + + +def main(): + torch.manual_seed(0) + eps = 1e-5 + + for tokens, hidden_size in ((64, 128), (128, 512), (16, 4096)): + input = torch.randn( + tokens, hidden_size, device="cuda", dtype=torch.bfloat16 + ) + gate = torch.randn_like(input) + weight = torch.empty( + hidden_size, device="cuda", dtype=torch.bfloat16 + ).uniform_(-1, 1) + output = torch.empty_like(input) + kernel = _make_kernel(input, gate, weight, output) + arguments = (input, gate, weight, eps, output) + + def checked(): + return kernel(*arguments) + + prevalidated = getattr(kernel, "_launch_prevalidated_noalias", None) + if prevalidated is None: + raise RuntimeError("The prevalidated no-alias launcher is unavailable.") + + def fast(): + return prevalidated(*arguments) + + checked() + fast() + torch.testing.assert_close( + output, + ntops.torch.rms_norm_gated( + input, + gate, + weight, + eps=eps, + group_size=hidden_size, + norm_before_gate=False, + activation="swish", + ), + rtol=2e-2, + atol=2e-2, + ) + + checked_ms, fast_ms, checked_samples, fast_samples = _measure_pair( + checked, fast + ) + print( + f"shape={tokens}x{hidden_size} correctness=pass " + f"checked_ms={checked_ms:.6f} fast_ms={fast_ms:.6f} " + f"runtime_speedup={checked_ms / fast_ms:.4f} " + f"checked_samples={checked_samples} fast_samples={fast_samples}", + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/benchmark_scaled_mm.py b/benchmarks/benchmark_scaled_mm.py new file mode 100644 index 0000000..3d8ae1c --- /dev/null +++ b/benchmarks/benchmark_scaled_mm.py @@ -0,0 +1,270 @@ +import argparse +import time + +import torch + +import ntops + + +SCALE_BLOCK_SIZE = 128 + +DEFAULT_SHAPES = ( + (1, 4096, 4096), + (16, 4096, 4096), + (128, 4096, 4096), +) + + +def _ceil_div(value, divisor): + return (value + divisor - 1) // divisor + + +def _make_inputs(m, n, k): + fp8_dtype = torch.float8_e4m3fn + input = torch.randn((m, k), device="cuda").clamp(-2, 2).to(fp8_dtype) + mat2 = torch.randn((n, k), device="cuda").clamp(-2, 2).to(fp8_dtype).t() + scale_a = torch.rand( + (m, _ceil_div(k, SCALE_BLOCK_SIZE)), + device="cuda", + dtype=torch.float32, + ) + 0.25 + scale_b = torch.rand( + ( + _ceil_div(k, SCALE_BLOCK_SIZE), + _ceil_div(n, SCALE_BLOCK_SIZE), + ), + device="cuda", + dtype=torch.float32, + ) + 0.25 + + return input, mat2, scale_a, scale_b + + +def _torch_eager_reference(input, mat2, scale_a, scale_b): + m, k = input.shape + n = mat2.shape[1] + output = torch.zeros((m, n), dtype=torch.float32, device=input.device) + + for block in range(_ceil_div(k, SCALE_BLOCK_SIZE)): + start = block * SCALE_BLOCK_SIZE + end = min(start + SCALE_BLOCK_SIZE, k) + scale_b_block = scale_b[block].repeat_interleave(SCALE_BLOCK_SIZE)[:n] + partial = input[:, start:end].float() @ mat2[start:end, :].float() + output += partial * scale_a[:, block, None] * scale_b_block[None, :] + + return output.to(torch.bfloat16) + + +def _make_rowwise_baseline(input, mat2, scale_a, scale_b): + n = mat2.shape[1] + blocks = [] + + for block in range(scale_a.shape[1]): + start = block * SCALE_BLOCK_SIZE + end = start + SCALE_BLOCK_SIZE + blocks.append( + ( + input[:, start:end], + mat2[start:end, :], + scale_a[:, block : block + 1].contiguous(), + scale_b[block] + .repeat_interleave(SCALE_BLOCK_SIZE)[:n] + .unsqueeze(0) + .contiguous(), + ) + ) + + def run(): + output = torch.zeros( + (input.shape[0], mat2.shape[1]), + dtype=torch.float32, + device=input.device, + ) + + for input_block, mat2_block, scale_a_block, scale_b_block in blocks: + partial = torch._scaled_mm( + input_block, + mat2_block, + scale_a_block, + scale_b_block, + out_dtype=torch.bfloat16, + ) + output += partial.float() + + return output.to(torch.bfloat16) + + return run + + +def _make_vllm_baseline(input, mat2, scale_a, scale_b): + try: + from vllm.model_executor.layers.quantization.utils.fp8_utils import ( + w8a8_triton_block_scaled_mm, + ) + except (ImportError, RuntimeError) as error: + return None, f"import failed: {error}" + + m, k = input.shape + n = mat2.shape[1] + num_k_blocks = _ceil_div(k, SCALE_BLOCK_SIZE) + num_n_blocks = _ceil_div(n, SCALE_BLOCK_SIZE) + + if scale_a.shape != (m, num_k_blocks) or scale_b.shape != ( + num_k_blocks, + num_n_blocks, + ): + return None, "only the 1x128/128x128 scaling layout is comparable" + + # vLLM accepts a contiguous [N, K] weight and [N/128, K/128] scales. + # Prepare the equivalent views outside the timed region. + weight = mat2.t().contiguous() + weight_scale = scale_b.t().contiguous() + + def run(): + return w8a8_triton_block_scaled_mm( + input, + weight, + scale_a, + weight_scale, + [SCALE_BLOCK_SIZE, SCALE_BLOCK_SIZE], + torch.bfloat16, + ) + + return run, None + + +def _correctness_error(actual, expected, rtol=0.05, atol=0.05): + close = torch.isclose(actual, expected, rtol=rtol, atol=atol) + + if bool(close.all()): + return None + + mismatch = int((~close).sum().item()) + maximum = float((actual.float() - expected.float()).abs().max().item()) + + return ( + f"correctness failed: {mismatch}/{close.numel()} mismatched, " + f"max_abs={maximum:.6g}" + ) + + +def _short_error(error): + return " ".join(str(error).splitlines()) + + +def _latency_ms(function, warmup, iterations): + for _ in range(warmup): + function() + + torch.cuda.synchronize() + start = time.perf_counter() + + for _ in range(iterations): + function() + + torch.cuda.synchronize() + + return (time.perf_counter() - start) * 1000 / iterations + + +def _parse_shape(text): + values = tuple(int(value) for value in text.lower().split("x")) + + if len(values) != 3: + raise argparse.ArgumentTypeError("Shapes must use the MxNxK format.") + + return values + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--shape", action="append", type=_parse_shape) + parser.add_argument("--warmup", type=int, default=5) + parser.add_argument("--iterations", type=int, default=20) + args = parser.parse_args() + + torch.manual_seed(0) + shapes = tuple(args.shape) if args.shape else DEFAULT_SHAPES + + print( + "m,n,k,ntops_ms,torch_rowwise_ms,vllm_ms,eager_reference_ms," + "torch_rowwise_ratio,vllm_ratio,eager_reference_ratio" + ) + + for m, n, k in shapes: + input, mat2, scale_a, scale_b = _make_inputs(m, n, k) + + def run_ntops(): + return ntops.torch.scaled_mm( + input, + mat2, + scale_a, + scale_b, + out_dtype=torch.bfloat16, + ) + + run_rowwise = _make_rowwise_baseline(input, mat2, scale_a, scale_b) + run_vllm, vllm_error = _make_vllm_baseline( + input, + mat2, + scale_a, + scale_b, + ) + + actual = run_ntops() + expected = _torch_eager_reference(input, mat2, scale_a, scale_b) + torch.testing.assert_close(actual, expected, rtol=0.05, atol=0.05) + + ntops_ms = _latency_ms(run_ntops, args.warmup, args.iterations) + + try: + run_rowwise() + except RuntimeError as error: + print(f"torch_rowwise_unavailable={_short_error(error)}") + rowwise_ms = None + else: + rowwise_ms = _latency_ms( + run_rowwise, + args.warmup, + args.iterations, + ) + + if run_vllm is not None: + try: + vllm_output = run_vllm() + except (AssertionError, RuntimeError) as error: + vllm_error = f"execution failed: {_short_error(error)}" + else: + vllm_error = _correctness_error(vllm_output, expected) + + if vllm_error is not None: + print(f"vllm_unavailable={vllm_error}") + vllm_ms = None + else: + vllm_ms = _latency_ms( + run_vllm, + args.warmup, + args.iterations, + ) + + eager_ms = _latency_ms( + lambda: _torch_eager_reference(input, mat2, scale_a, scale_b), + args.warmup, + args.iterations, + ) + + rowwise_text = "nan" if rowwise_ms is None else f"{rowwise_ms:.4f}" + vllm_text = "nan" if vllm_ms is None else f"{vllm_ms:.4f}" + rowwise_ratio = ( + "nan" if rowwise_ms is None else f"{rowwise_ms / ntops_ms:.3f}" + ) + vllm_ratio = "nan" if vllm_ms is None else f"{vllm_ms / ntops_ms:.3f}" + print( + f"{m},{n},{k},{ntops_ms:.4f},{rowwise_text},{vllm_text}," + f"{eager_ms:.4f},{rowwise_ratio},{vllm_ratio}," + f"{eager_ms / ntops_ms:.3f}" + ) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/probe_official_scaled_mm.py b/benchmarks/probe_official_scaled_mm.py new file mode 100644 index 0000000..ee2981c --- /dev/null +++ b/benchmarks/probe_official_scaled_mm.py @@ -0,0 +1,359 @@ +"""Probe PyTorch/vLLM T2 baseline paths on a target accelerator. + +The eager implementation in this file is used only as a correctness oracle. It +is never timed and is never reported as a performance baseline. + +``vllm_portable`` is a separately labelled vLLM-derived compatibility baseline; +the ``pytorch`` and ``vllm_triton`` modes continue to execute installed code +without changing its timed kernel. +""" + +import argparse +import time + +import torch + + +SCALE_BLOCK_SIZE = 128 + + +def _ceil_div(value, divisor): + return (value + divisor - 1) // divisor + + +def _make_inputs(m, n, k, fp8_dtype): + input = torch.randn((m, k), device="cuda").clamp(-2, 2).to(fp8_dtype) + weight = torch.randn((n, k), device="cuda").clamp(-2, 2).to(fp8_dtype) + scale_a = torch.rand( + (m, _ceil_div(k, SCALE_BLOCK_SIZE)), + device="cuda", + dtype=torch.float32, + ) + 0.25 + weight_scale = torch.rand( + ( + _ceil_div(n, SCALE_BLOCK_SIZE), + _ceil_div(k, SCALE_BLOCK_SIZE), + ), + device="cuda", + dtype=torch.float32, + ) + 0.25 + return input, weight, scale_a, weight_scale + + +def _reference(input, weight, scale_a, weight_scale): + m, k = input.shape + n = weight.shape[0] + output = torch.zeros((m, n), dtype=torch.float32, device=input.device) + + for block in range(_ceil_div(k, SCALE_BLOCK_SIZE)): + start = block * SCALE_BLOCK_SIZE + end = min(start + SCALE_BLOCK_SIZE, k) + scale_b_block = weight_scale[:, block].repeat_interleave( + SCALE_BLOCK_SIZE + )[:n] + partial = input[:, start:end].float() @ weight[:, start:end].float().t() + output += partial * scale_a[:, block, None] * scale_b_block[None, :] + + return output.to(torch.bfloat16) + + +def _make_candidate( + mode, + input, + weight, + scale_a, + weight_scale, + vllm_config=None, + ntops_max_block_m=None, +): + if mode == "ntops": + import ntops + + if ntops_max_block_m is not None: + ntops.kernels.scaled_mm.MAX_SPECIALIZED_BLOCK_SIZE_M = ( + ntops_max_block_m + ) + + mat2 = weight.t() + scale_b = weight_scale.t().contiguous() + + def run(): + return ntops.torch.scaled_mm( + input, + mat2, + scale_a, + scale_b, + out_dtype=torch.bfloat16, + ) + + return run + + if mode == "pytorch": + mat2 = weight.t() + scale_b = weight_scale.t().contiguous() + + def run(): + return torch._scaled_mm( + input, + mat2, + scale_a, + scale_b, + out_dtype=torch.bfloat16, + ) + + return run + + if mode == "vllm_portable": + from vllm_portable_scaled_mm import ( + fp8_dot_fallback_name, + w8a8_portable_block_scaled_mm, + ) + + print( + "stage=vllm_portable " + f"fp8_dot_fallback={fp8_dot_fallback_name()} source=vllm", + flush=True, + ) + + def run(): + return w8a8_portable_block_scaled_mm( + input, + weight, + scale_a, + weight_scale, + [SCALE_BLOCK_SIZE, SCALE_BLOCK_SIZE], + torch.bfloat16, + vllm_config, + ) + + return run + + if mode == "vllm_triton": + restore_platform_probe = _select_vllm_rocm_platform() + + try: + from vllm.model_executor.layers.quantization.utils import fp8_utils + finally: + restore_platform_probe() + + if vllm_config is not None: + fp8_utils.get_w8a8_block_fp8_configs = ( + lambda _n, _k, _block_n, _block_k: {input.shape[0]: vllm_config} + ) + + function = getattr(fp8_utils, "w8a8_triton_block_scaled_mm", None) + + if function is None: + function = getattr(fp8_utils, "w8a8_block_fp8_matmul", None) + + if function is None: + raise RuntimeError("vLLM does not expose a block-scaled FP8 GEMM.") + + def run(): + return function( + input, + weight, + scale_a, + weight_scale, + [SCALE_BLOCK_SIZE, SCALE_BLOCK_SIZE], + torch.bfloat16, + ) + + return run + + if mode == "vllm_vendor": + restore_platform_probe = _select_vllm_rocm_platform() + + try: + from vllm.model_executor.layers.quantization.utils.fp8_utils import ( + cutlass_scaled_mm, + ) + finally: + restore_platform_probe() + + def run(): + return cutlass_scaled_mm( + input, + weight, + scale_a, + weight_scale, + [SCALE_BLOCK_SIZE, SCALE_BLOCK_SIZE], + torch.bfloat16, + ) + + return run + + raise ValueError(f"Unknown mode: {mode}") + + +def _select_vllm_rocm_platform(): + """Prevent the DTK NVML shim from activating CUDA beside ROCm. + + The DTK vLLM wheel otherwise activates both built-in platform plugins on a + BW device. This changes platform discovery only; the timed FP8 kernel is + imported unchanged from the installed vLLM package. + """ + + if getattr(torch, "corex", False): + return lambda: None + + try: + from vllm.utils import import_pynvml + except ImportError: + from vllm.utils.import_utils import import_pynvml + + pynvml = import_pynvml() + original = pynvml.nvmlDeviceGetCount + pynvml.nvmlDeviceGetCount = lambda: 0 + + def restore(): + pynvml.nvmlDeviceGetCount = original + + return restore + + +def _correctness(output, expected, rtol, atol): + close = torch.isclose(output, expected, rtol=rtol, atol=atol) + difference = (output.float() - expected.float()).abs() + return { + "passed": bool(close.all()), + "mismatched": int((~close).sum().item()), + "elements": close.numel(), + "max_abs": float(difference.max().item()), + } + + +def _latency_ms(function, warmup, iterations): + for _ in range(warmup): + function() + torch.cuda.synchronize() + start = time.perf_counter() + for _ in range(iterations): + function() + torch.cuda.synchronize() + return (time.perf_counter() - start) * 1000 / iterations + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--mode", + required=True, + choices=( + "ntops", + "pytorch", + "vllm_portable", + "vllm_triton", + "vllm_vendor", + ), + ) + parser.add_argument("--m", type=int, default=1) + parser.add_argument("--n", type=int, default=4096) + parser.add_argument("--k", type=int, default=4096) + parser.add_argument("--warmup", type=int, default=20) + parser.add_argument("--iterations", type=int, default=100) + parser.add_argument( + "--fp8-dtype", + choices=("e4m3fn", "e5m2", "e4m3fnuz", "e5m2fnuz"), + default="e4m3fn", + ) + parser.add_argument("--rtol", type=float, default=0.05) + parser.add_argument("--atol", type=float, default=0.05) + parser.add_argument("--vllm-block-m", type=int) + parser.add_argument("--vllm-block-n", type=int) + parser.add_argument("--vllm-num-warps", type=int, default=4) + parser.add_argument("--vllm-group-m", type=int, default=16) + parser.add_argument("--vllm-matrix-instr-nonkdim", type=int, default=16) + parser.add_argument("--vllm-kpack", type=int, default=1) + parser.add_argument("--ntops-max-block-m", type=int) + args = parser.parse_args() + + if args.ntops_max_block_m is not None and ( + args.mode != "ntops" + or args.ntops_max_block_m < 16 + or args.ntops_max_block_m > 128 + or args.ntops_max_block_m & (args.ntops_max_block_m - 1) + ): + raise ValueError( + "--ntops-max-block-m requires --mode ntops and a power of two " + "between 16 and 128." + ) + + if args.n % SCALE_BLOCK_SIZE or args.k % SCALE_BLOCK_SIZE: + raise ValueError("The official baseline probe requires N and K / 128.") + + if args.ntops_max_block_m is not None: + print( + f"stage=ntops_config max_block_m={args.ntops_max_block_m}", + flush=True, + ) + + print( + f"stage=setup mode={args.mode} shape={args.m}x{args.n}x{args.k} " + f"fp8_dtype={args.fp8_dtype}", + flush=True, + ) + torch.manual_seed(0) + fp8_dtype = getattr(torch, f"float8_{args.fp8_dtype}") + input, weight, scale_a, weight_scale = _make_inputs( + args.m, + args.n, + args.k, + fp8_dtype, + ) + vllm_config = None + + if args.vllm_block_m is not None or args.vllm_block_n is not None: + if args.vllm_block_m is None or args.vllm_block_n is None: + raise ValueError("Both vLLM block dimensions must be provided.") + vllm_config = { + "BLOCK_SIZE_M": args.vllm_block_m, + "BLOCK_SIZE_N": args.vllm_block_n, + "BLOCK_SIZE_K": SCALE_BLOCK_SIZE, + "GROUP_SIZE_M": args.vllm_group_m, + "num_warps": args.vllm_num_warps, + "matrix_instr_nonkdim": args.vllm_matrix_instr_nonkdim, + "kpack": args.vllm_kpack, + } + print(f"stage=vllm_config config={vllm_config}", flush=True) + + candidate = _make_candidate( + args.mode, + input, + weight, + scale_a, + weight_scale, + vllm_config, + args.ntops_max_block_m, + ) + torch.cuda.synchronize() + print("stage=first_call", flush=True) + first_start = time.perf_counter() + output = candidate() + torch.cuda.synchronize() + first_call_ms = (time.perf_counter() - first_start) * 1000 + print("stage=reference", flush=True) + expected = _reference(input, weight, scale_a, weight_scale) + result = _correctness(output, expected, args.rtol, args.atol) + print( + "stage=correctness " + f"passed={result['passed']} mismatched={result['mismatched']}/" + f"{result['elements']} max_abs={result['max_abs']:.6g} " + f"first_call_ms={first_call_ms:.4f}", + flush=True, + ) + + if not result["passed"]: + raise SystemExit(2) + + latency_ms = _latency_ms(candidate, args.warmup, args.iterations) + role = "submission" if args.mode == "ntops" else "baseline" + print( + f"stage=latency {role}={args.mode} latency_ms={latency_ms:.6f} " + f"warmup={args.warmup} iterations={args.iterations}", + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/run_kernelswift_t4.sh b/benchmarks/run_kernelswift_t4.sh new file mode 100644 index 0000000..2cd14a7 --- /dev/null +++ b/benchmarks/run_kernelswift_t4.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +NTOPS_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +NINETOOTHED_ROOT="${NINETOOTHED_ROOT:-${NTOPS_ROOT}/../ninetoothed}" +NINETOOTHED_ROOT="$(cd -- "${NINETOOTHED_ROOT}" && pwd)" + +timestamp="$(date +%Y%m%d_%H%M%S)" +RESULT_DIR="${1:-${NTOPS_ROOT}/results/kernelswift_t4_${timestamp}}" + +if [[ -e "${RESULT_DIR}" ]]; then + echo "结果目录已存在,拒绝复用缓存或覆盖日志:${RESULT_DIR}" >&2 + exit 2 +fi + +mkdir -p "${RESULT_DIR}/logs" "${RESULT_DIR}/ninetoothed-cache" + +export PYTHONPATH="${NTOPS_ROOT}/src:${NINETOOTHED_ROOT}/src${PYTHONPATH:+:${PYTHONPATH}}" +export NINETOOTHED_CACHE_DIR="${RESULT_DIR}/ninetoothed-cache" + +is_corex=0 +if python -c 'import torch; raise SystemExit(0 if getattr(torch, "corex", False) else 1)'; then + is_corex=1 +fi + +if [[ "${is_corex}" == 1 ]]; then + export NINETOOTHED_BACKEND="${NINETOOTHED_BACKEND:-cuda}" + export CUDA_HOME="${CUDA_HOME:-/usr/local/corex}" + export NINETOOTHED_CUDA_COMPILER="${NINETOOTHED_CUDA_COMPILER:-${CUDA_HOME}/bin/clang++}" + export NINETOOTHED_CUDA_LANGUAGE="${NINETOOTHED_CUDA_LANGUAGE:-ivcore}" +else + export NINETOOTHED_BACKEND="${NINETOOTHED_BACKEND:-triton}" +fi + +python - <<'PY' | tee "${RESULT_DIR}/logs/environment.log" +import platform + +import torch +import triton +import vllm + +print("python", platform.python_version()) +print("torch", torch.__version__) +print("triton", triton.__version__) +print("vllm", vllm.__version__) +print("corex", bool(getattr(torch, "corex", False))) +print("cuda_available", torch.cuda.is_available()) +if not torch.cuda.is_available(): + raise SystemExit("没有可用的 CUDA 兼容 GPU") +print("device", torch.cuda.get_device_name(0)) +x = torch.ones(1, device="cuda") +torch.cuda.synchronize() +print("gpu_health", x.item()) +PY + +( + cd "${NTOPS_ROOT}" + python -m ruff check \ + benchmarks/benchmark_mla_rope_cache.py \ + src/ntops/kernels/mla_rope_cache.py \ + src/ntops/kernels/mla_rope_cache_pair.py \ + src/ntops/torch/mla_rope_cache.py \ + src/ntops/torch/mla_rope_cache_pair.py \ + tests/test_mla_rope_cache.py +) 2>&1 | tee "${RESULT_DIR}/logs/ruff_ntops.log" + +( + cd "${NINETOOTHED_ROOT}" + python -m ruff check \ + src/ninetoothed/backends/emitters/ssa.py \ + src/ninetoothed/backends/materializers/cuda.py \ + src/ninetoothed/backends/toolchain.py \ + tests/test_compiler_cache_runtime.py \ + tests/test_backend_registry.py +) 2>&1 | tee "${RESULT_DIR}/logs/ruff_ninetoothed.log" + +( + cd "${NTOPS_ROOT}" + python -m pytest -q tests/test_mla_rope_cache.py +) 2>&1 | tee "${RESULT_DIR}/logs/correctness.log" + +( + cd "${NINETOOTHED_ROOT}" + python -m pytest -q \ + tests/test_ssa_first_backend_lowering.py \ + tests/test_triton_runtime_auto_tuning.py \ + tests/test_ssa_scalar_argument_emission.py \ + tests/test_static_layout_bounds.py \ + tests/test_static_tensor_strides.py \ + tests/test_compiler_cache_runtime.py \ + tests/test_backend_registry.py +) 2>&1 | tee "${RESULT_DIR}/logs/compiler_regression.log" + +for heads in 32 128; do + ( + cd "${NTOPS_ROOT}" + python benchmarks/benchmark_mla_rope_cache.py --num-heads "${heads}" + ) 2>&1 | tee "${RESULT_DIR}/logs/benchmark_heads${heads}.log" +done + +( + cd "${NTOPS_ROOT}" + sha256sum \ + src/ntops/kernels/mla_rope_cache.py \ + src/ntops/kernels/mla_rope_cache_pair.py \ + src/ntops/torch/mla_rope_cache.py \ + src/ntops/torch/mla_rope_cache_pair.py \ + benchmarks/benchmark_mla_rope_cache.py \ + tests/test_mla_rope_cache.py + cd "${NINETOOTHED_ROOT}" + sha256sum \ + src/ninetoothed/backends/emitters/ssa.py \ + src/ninetoothed/backends/materializers/cuda.py \ + src/ninetoothed/backends/toolchain.py \ + tests/test_compiler_cache_runtime.py \ + tests/test_backend_registry.py +) > "${RESULT_DIR}/SHA256SUMS" + +grep -hE 'shape=.*correctness=pass' \ + "${RESULT_DIR}/logs/benchmark_heads32.log" \ + "${RESULT_DIR}/logs/benchmark_heads128.log" \ + | tee "${RESULT_DIR}/FORMAL_SPEEDUP_SUMMARY" + +echo "T4 构建、正确性、回归和正式 vLLM benchmark 已完成。" +echo "结果目录:${RESULT_DIR}" diff --git a/benchmarks/vllm_portable_scaled_mm.py b/benchmarks/vllm_portable_scaled_mm.py new file mode 100644 index 0000000..6bfcf3d --- /dev/null +++ b/benchmarks/vllm_portable_scaled_mm.py @@ -0,0 +1,201 @@ +"""Portable benchmark-only adaptation of vLLM block-scaled FP8 GEMM. + +The kernel structure and default launch configuration follow vLLM's +``w8a8_triton_block_scaled_mm`` implementation in ``fp8_utils.py`` (Apache-2.0). +The only semantic-neutral portability addition is an explicit FP8 operand cast +for vendor Triton toolchains whose native FP8 ``tl.dot`` is unavailable or +incorrect. This module is an experimental baseline and is not imported by the +ntops operator implementation. +""" + +import importlib.metadata + +import torch +import triton +import triton.language as tl + + +FP8_DOT_NATIVE = 0 +FP8_DOT_FLOAT16 = 1 +FP8_DOT_BFLOAT16 = 2 + + +def _fp8_dot_fallback(): + version = importlib.metadata.version("triton").lower() + + if "+corex." in version: + return FP8_DOT_BFLOAT16 + if ".dtk" in version or "+das." in version: + return FP8_DOT_FLOAT16 + return FP8_DOT_NATIVE + + +_EFFECTIVE_FP8_DOT_FALLBACK = _fp8_dot_fallback() + + +def fp8_dot_fallback_name(): + return { + FP8_DOT_NATIVE: "native", + FP8_DOT_FLOAT16: "float16", + FP8_DOT_BFLOAT16: "bfloat16", + }[_EFFECTIVE_FP8_DOT_FALLBACK] + + +@triton.jit +def _w8a8_portable_block_scaled_mm( + A, + B, + C, + As, + Bs, + M, + N, + K, + group_n, + group_k, + stride_am, + stride_ak, + stride_bk, + stride_bn, + stride_cm, + stride_cn, + stride_As_m, + stride_As_k, + stride_Bs_k, + stride_Bs_n, + BLOCK_SIZE_M: tl.constexpr, + BLOCK_SIZE_N: tl.constexpr, + BLOCK_SIZE_K: tl.constexpr, + GROUP_SIZE_M: tl.constexpr, + FP8_DOT_FALLBACK: tl.constexpr, +): + pid = tl.program_id(axis=0) + num_pid_m = tl.cdiv(M, BLOCK_SIZE_M) + num_pid_n = tl.cdiv(N, BLOCK_SIZE_N) + num_pid_in_group = GROUP_SIZE_M * num_pid_n + group_id = pid // num_pid_in_group + first_pid_m = group_id * GROUP_SIZE_M + group_size_m = min(num_pid_m - first_pid_m, GROUP_SIZE_M) + pid_m = first_pid_m + (pid % group_size_m) + pid_n = (pid % num_pid_in_group) // group_size_m + + offs_am = (pid_m * BLOCK_SIZE_M + tl.arange(0, BLOCK_SIZE_M)) % M + offs_bn = (pid_n * BLOCK_SIZE_N + tl.arange(0, BLOCK_SIZE_N)) % N + offs_k = tl.arange(0, BLOCK_SIZE_K) + a_ptrs = A + offs_am[:, None] * stride_am + offs_k[None, :] * stride_ak + b_ptrs = B + offs_k[:, None] * stride_bk + offs_bn[None, :] * stride_bn + + as_ptrs = As + offs_am * stride_As_m + offs_bsn = offs_bn // group_n + bs_ptrs = Bs + offs_bsn * stride_Bs_n + + accumulator = tl.zeros((BLOCK_SIZE_M, BLOCK_SIZE_N), dtype=tl.float32) + for k in range(0, tl.cdiv(K, BLOCK_SIZE_K)): + a = tl.load( + a_ptrs, + mask=offs_k[None, :] < K - k * BLOCK_SIZE_K, + other=0.0, + ) + b = tl.load( + b_ptrs, + mask=offs_k[:, None] < K - k * BLOCK_SIZE_K, + other=0.0, + ) + + if FP8_DOT_FALLBACK == 1: + a = a.to(tl.float16) + b = b.to(tl.float16) + elif FP8_DOT_FALLBACK == 2: + a = a.to(tl.bfloat16) + b = b.to(tl.bfloat16) + + k_start = k * BLOCK_SIZE_K + offs_ks = k_start // group_k + a_s = tl.load(as_ptrs + offs_ks * stride_As_k) + b_s = tl.load(bs_ptrs + offs_ks * stride_Bs_k) + + accumulator += tl.dot(a, b) * a_s[:, None] * b_s[None, :] + a_ptrs += BLOCK_SIZE_K * stride_ak + b_ptrs += BLOCK_SIZE_K * stride_bk + + if C.dtype.element_ty == tl.bfloat16: + c = accumulator.to(tl.bfloat16) + elif C.dtype.element_ty == tl.float16: + c = accumulator.to(tl.float16) + else: + c = accumulator.to(tl.float32) + + offs_cm = pid_m * BLOCK_SIZE_M + tl.arange(0, BLOCK_SIZE_M) + offs_cn = pid_n * BLOCK_SIZE_N + tl.arange(0, BLOCK_SIZE_N) + c_ptrs = C + stride_cm * offs_cm[:, None] + stride_cn * offs_cn[None, :] + c_mask = (offs_cm[:, None] < M) & (offs_cn[None, :] < N) + tl.store(c_ptrs, c, mask=c_mask) + + +def w8a8_portable_block_scaled_mm( + A, + B, + As, + Bs, + block_size, + output_dtype=torch.float16, + config=None, +): + assert len(block_size) == 2 + block_n, block_k = block_size + assert A.shape[-1] == B.shape[-1] + assert A.shape[:-1] == As.shape[:-1] and A.is_contiguous() + assert triton.cdiv(A.shape[-1], block_k) == As.shape[-1] + + m = A.numel() // A.shape[-1] + n, k = B.shape + assert B.ndim == 2 and Bs.ndim == 2 + assert triton.cdiv(n, block_n) == Bs.shape[0] + assert triton.cdiv(k, block_k) == Bs.shape[1] + + output = A.new_empty(A.shape[:-1] + (n,), dtype=output_dtype) + launch_config = { + "BLOCK_SIZE_M": 64, + "BLOCK_SIZE_N": block_n, + "BLOCK_SIZE_K": block_k, + "GROUP_SIZE_M": 32, + "num_warps": 4, + "num_stages": 2, + } + if config is not None: + launch_config.update(config) + launch_config["FP8_DOT_FALLBACK"] = _EFFECTIVE_FP8_DOT_FALLBACK + + def grid(meta): + return ( + triton.cdiv(m, meta["BLOCK_SIZE_M"]) + * triton.cdiv(n, meta["BLOCK_SIZE_N"]), + ) + + _w8a8_portable_block_scaled_mm[grid]( + A, + B, + output, + As, + Bs, + m, + n, + k, + block_n, + block_k, + A.stride(-2), + A.stride(-1), + B.stride(1), + B.stride(0), + output.stride(-2), + output.stride(-1), + As.stride(-2), + As.stride(-1), + Bs.stride(1), + Bs.stride(0), + **launch_config, + ) + return output + + +__all__ = ["fp8_dot_fallback_name", "w8a8_portable_block_scaled_mm"] diff --git a/docs/KERNELSWIFT_SUBMISSION_MANIFEST.md b/docs/KERNELSWIFT_SUBMISSION_MANIFEST.md new file mode 100644 index 0000000..5b9c74f --- /dev/null +++ b/docs/KERNELSWIFT_SUBMISSION_MANIFEST.md @@ -0,0 +1,115 @@ +# KernelSwift 算子创新大赛统一提交清单 + +## 1. 队伍信息 + +| 字段 | 内容 | +|---|---| +| 参赛队伍 | `NULL` | +| 队长 | `陈伟汉` | +| 参赛成员 | `陈健勇` | +| 所选赛题 | T1、T2、T3、T4 | + +## 2. 仓库与 commit 映射 + +### 2.1 `ntops` 算子提交 + +| 项目 | 内容 | +|---|---| +| upstream base | `9ae4166` | +| 验证代码锚点 | PR final HEAD | + +| 赛题 | commit | 主要内容 | +|---|---|---| +| T1 | `ec8f4e1` | MXFP4 W4A16 分组专家 GEMM、Torch wrapper、测试、benchmark、报告与复现脚本 | +| T2 | `8a5be7a` | Block-scaled FP8 GEMM、Torch wrapper、测试、baseline 探测、benchmark、报告与复现脚本 | +| T3 | `7fc8074` | Gated RMSNorm 融合算子、Torch wrapper、测试、vLLM benchmark、报告与复现脚本 | +| T4 | 本 commit(PR final HEAD) | MLA RoPE 与压缩 KV Cache 融合算子、Torch wrapper、测试、vLLM benchmark、报告与复现脚本及统一 manifest | + +`src/ntops/kernels/__init__.py` 与 `src/ntops/torch/__init__.py` 在四个题目 commit 中依次追加导出。算子数学接口、测试输入语义和基线实现未被修改。 + +### 2.2 `ninetoothed` 编译器/后端提交 + +| 项目 | 内容 | +|---|---| +| upstream base | `b77f930` | +| 验证代码锚点 | `6b79203` | + +| commit | 主要关联赛题 | 能力边界 | +|---|---|---| +| `18c50cd` | T1/T3 及共享基础 | 通用 SSA tensor lowering、静态布局/stride、标量 ABI 与 Triton runtime 基础能力 | +| `5b6abe8` | T1/T3 | `bitcast`、`interleave`、transpose 与已验证 Triton 直接绑定能力 | +| `4ea752e` | T2 | 按工具链能力选择的可移植 FP8 dot 合法化及专项回归 | +| `0dbe0e9` | T4 | MLA cache 所需 masked-producer sinking、CUDA 低开销 launch 与 CoreX 工具链支持 | +| `6b79203` | T1-T4 统一验证 | 修正 jagged public value 的 runtime stride 校验并增加定向回归;dense Tensor 校验保持不变 | + +该分支不包含按公开 case 编号、隐藏数据或答案表选择结果的代码。通用分析位于 SSA/frontend/runtime 层,平台后端只保留工具链、FP8 能力和 CUDA/CoreX launch 等硬件相关实现。 + +## 3. A/B 修改边界 + +| 赛题 | A:`ntops` 算子实现 | B:`ninetoothed` 通用编译/后端 | +|---|---|---| +| T1 | MXFP4 解码、分组专家映射、tile 与并行策略、融合累加 | `bitcast`、`interleave`、transpose lowering、稳定参数直接绑定、jagged runtime 契约 | +| T2 | block-scale 索引、FP8 tile、FP32 累加、输出转换 | FP8 dot operand 合法化、按 Triton 工具链能力选择 FP16/BF16 fallback | +| T3 | RMSNorm、门控、affine 与残差融合;静态语义变体 | 通用静态布局、application block、标量 ABI、预验证 no-alias launch | +| T4 | RoPE、KV 拼接与 cache 写入融合;scalar/pair 能力驱动分派 | gather/SSA lowering、masked producer sinking、CUDA 直接 launch、CoreX 编译器发现 | + +benchmark 文件不被 submission kernel 导入;第三方实现仅作为正确性 reference 或报告中明确披露的性能 baseline。 + +## 4. 统一 HEAD 双平台最终复测 + +下表全部来自本 PR 最终 `ntops` HEAD 与 `6b79203` 的干净工作树。各题 correctness、题目脚本列出的编译器回归和 benchmark 使用彼此隔离的新缓存;所有 benchmark case 均通过正确性检查。 + +| 赛题 | 海光 BW DTK25.04 | 天数智芯天垓150 | timed baseline | +|---|---|---|---| +| T1 | `15 passed`;回归 `98 passed`;5 case 平均 `5.892741×`,最低 `2.659205×` | `15 passed`;回归 `98 passed`;5 case 平均 `2.970092×`,最低 `1.504281×` | `vllm_semantic_fallback_predecoded`,明确披露的 vLLM/OCP 语义兼容路径 | +| T2 | `30 passed`;回归 `120 passed`;6 case 平均 `3.316458×`,最低 `1.777644×` | `30 passed`;回归 `120 passed`;6 case 平均 `1.508378×`,最低 `1.202633×` | `vllm_portable`,明确披露的 vLLM-derived portable compatibility baseline | +| T3 | `32 passed`;回归 `94 passed`;3 case 平均 `1.2626×`,最低 `1.2403×` | `32 passed`;回归 `94 passed`;3 case 平均 `1.3436×`,最低 `1.3295×` | 未修改 vLLM `RMSNormGated` 调用链 | +| T4 | `26 passed`;回归 `122 passed`;6 case 平均 `2.199483×`,最低 `1.4298×` | `26 passed`;回归 `122 passed`;6 case 平均 `1.5766×`,最低 `1.1648×` | 未修改 vLLM `RoPE.forward_cuda + concat_and_cache_mla` 调用链 | + +所有 speedup 均按 `baseline_latency / submission_latency` 计算,不使用 PyTorch eager latency。T1/T2 因目标镜像中的原生 PyTorch 接口不能执行同语义工作负载,使用报告中明确披露的兼容 baseline;它们不得表述为原生 PyTorch 或未经修改的上游 vLLM binary。 + +远程原始结果目录(路径为示意,实际由运行时的 `--output-dir` 指定): + +- BW:`/bw/`; +- 天垓150:`/tiangai150/`。 + +## 5. 一键联合复测 + +以下命令只能在远程 GPU 主机执行。两个仓库、结果和缓存必须位于远程主机的指定工作目录下;每题、每平台必须使用全新输出目录。 + +```bash +export KS_ROOT=/path/to/remote/workspace +export NTOPS_DIR=${KS_ROOT}/ntops +export NINETOOTHED_DIR=${KS_ROOT}/ninetoothed + +cd "${NTOPS_DIR}" + +bash scripts/run_kernelswift_t1.sh \ + --ninetoothed-dir "${NINETOOTHED_DIR}" \ + --output-dir "${KS_ROOT}/results/t1-$(date +%Y%m%d-%H%M%S)" \ + --mode all + +bash scripts/run_kernelswift_t2.sh \ + --ninetoothed-dir "${NINETOOTHED_DIR}" \ + --output-dir "${KS_ROOT}/results/t2-$(date +%Y%m%d-%H%M%S)" \ + --baseline vllm_portable \ + --mode all + +bash scripts/run_kernelswift_t3.sh \ + --ninetoothed-dir "${NINETOOTHED_DIR}" \ + --output-dir "${KS_ROOT}/results/t3-$(date +%Y%m%d-%H%M%S)" \ + --mode all + +NINETOOTHED_ROOT="${NINETOOTHED_DIR}" \ + bash benchmarks/run_kernelswift_t4.sh \ + "${KS_ROOT}/results/t4-$(date +%Y%m%d-%H%M%S)" +``` + +## 6. 文档索引 + +| 赛题 | 技术报告 | 一键复现 | +|---|---|---| +| T1 | `docs/KERNELSWIFT_T1_REPORT.md` | `docs/KERNELSWIFT_T1_REPRODUCE.md` | +| T2 | `docs/KERNELSWIFT_T2_REPORT.md` | `docs/KERNELSWIFT_T2_REPRODUCE.md` | +| T3 | `docs/KERNELSWIFT_T3_REPORT.md` | `docs/KERNELSWIFT_T3_REPRODUCE.md` | +| T4 | `docs/KERNELSWIFT_T4_REPORT.md` | `docs/KERNELSWIFT_T4_REPRODUCE.md` | diff --git a/docs/KERNELSWIFT_T1_REPORT.md b/docs/KERNELSWIFT_T1_REPORT.md new file mode 100644 index 0000000..fc3d034 --- /dev/null +++ b/docs/KERNELSWIFT_T1_REPORT.md @@ -0,0 +1,237 @@ +# KernelSwift T1:MXFP4 W4A16 分组专家矩阵乘算子—编译协同优化 + +**报告日期**:2026-08-31 + +**赛题**:T1 MXFP4 W4A16 分组专家矩阵乘 + +**作品形态**:A(NineToothed 算子)+ B(通用编译器/平台后端)统一构建与评测 + +**当前状态**:统一源码使用本 PR 的最终 `ntops` HEAD 与 `ninetoothed@6b79203`,已在 BW 与天垓150通过正确性和相关编译器回归;BF16 已完成 10 个双平台 A+B 代表性能点,FP16 已完成 BW 的 5 个代表性能点并保留天垓150的 3 个扩展性能点,全部 `speedup > 1` + +## 1. 摘要 + +T1 面向 MoE 推理中的低精度专家计算。权重使用 OCP/vLLM MXFP4 编码:每字节包含两个 E2M1 值,每 32 个逻辑权重共享一个 E8M0 scale;activation 为 BF16/FP16,按 expert offsets 组成 ragged grouped-M 输入。朴素实现通常先完整反量化权重,再逐 expert 调用 GEMM,产生额外显存流量、临时张量和多个 Python/kernel launch。 + +本作品在 NineToothed application 中即时解码 E2M1 与 E8M0,将解码、缩放和 dot 融合,FP32 累加后直接写回输出;wrapper 根据实际 expert token 上界与 N 选择 tile,并缓存已验证的 stable-buffer launch。B 部分补齐可复用的 `bitcast`、`interleave`、`transpose` SSA lowering,并在严格 no-alias/稳定对象契约下将 Triton specialization 绑定为直接 compiled launch,同时保持 current-stream 语义和安全回退。 + +相同最终源码在天垓150和 BW 上都取得 `15 passed` 的 T1 测试和 `98 passed` 的相关编译器回归。BF16 的天垓150 5 个代表 case 平均 speedup 为 **2.970092×**,最低 **1.504281×**;BW 在同样的 50/500/9 协议下平均 **5.892741×**,最低 **2.659205×**。FP16 在 BW DTK25.04 的 5 个代表 case 平均 speedup 为 **6.183218×**,最低 **2.579908×**;天垓150的 3 个扩展 case 平均 speedup 为 **2.058291×**,最低 **1.250629×**。结果汇总见本报告第 7 节;BF16 和 FP16 的所有已记录性能点均大于 1。 + +## 2. 代码版本与复现锚点 + +结果对应本次 PR 中的提交链。以下记录官方基线 commit、本题提交和统一验证锚点,便于从 PR 历史复现。 + +| 仓库 | 官方基线 commit | 本题提交/验证锚点 | +|---|---|---| +| ntops | `9ae4166` | 本 PR 的 T1 题目 commit;统一验证使用本 PR 最终 HEAD | +| ninetoothed | `b77f930` | 通用 SSA 基础 `18c50cd`,T1 编译/运行时能力 `5b6abe8`;统一验证锚点 `6b79203` | + +核心源码位于: + +- `src/ntops/kernels/mxfp4_grouped_gemm.py` +- `src/ntops/torch/mxfp4_grouped_gemm.py` +- `tests/test_mxfp4_grouped_gemm.py` +- `benchmarks/benchmark_mxfp4_grouped_gemm_reference.py` +- `src/ninetoothed/backends/emitters/ssa.py` +- `src/ninetoothed/backends/emitters/triton.py` +- `src/ninetoothed/backends/materializers/triton.py` +- `src/ninetoothed/frontend/python.py` +- `src/ninetoothed/frontend/types.py` + +## 3. 数学语义与接口 + +输入: + +- activation `X`:`[sum(tokens_e), K]`,BF16 或 FP16; +- packed weight `W_p`:`[E, N, K/2]`,`uint8`,低/高 nibble 各存一个 E2M1; +- scale `S`:`[E, N, K/32]`,`uint8` E8M0; +- expert offsets:长度 `E` 的 exclusive end offsets,也兼容以 0 开头、长度 `E+1` 的边界向量; +- 输出 `Y`:`[sum(tokens_e), N]`,与 activation 同 dtype。 + +对 expert `e`: + +$$ +Y_e = X_e \cdot \operatorname{dequant}(W_{p,e}, S_e)^T, +$$ + +其中每个 E2M1 nibble 解码为集合 +`{±0, ±0.5, ±1, ±1.5, ±2, ±3, ±4, ±6}`,每 32 个值乘对应 E8M0 scale。实现用 FP16/BF16 dot operand 和 FP32 accumulator,最终转换为输出 dtype。 + +公开入口为: + +```python +ntops.torch.mxfp4_grouped_gemm( + input, + weight_mxfp4, + weight_scale, + expert_offsets, + num_experts=None, + out=None, +) +``` + +`out` 用于 LLM 推理中稳定 buffer 复用,不提供时返回独立的新输出张量。 + +## 4. 修改内容与动机 + +### 4.1 A 部分:NineToothed 算子 + +1. **即时 MXFP4 解码**:E2M1 nibble 通过整数位运算构造幅值;E8M0 通过整数位模式和通用 `bitcast` 得到 FP32 scale,不物化完整 FP32 weight。 +2. **解码—缩放—dot 融合**:偶/奇 nibble 解码后通过 `transpose + interleave` 恢复逻辑 K 顺序,在 K block 内完成 dot 与 scale 乘法。 +3. **ragged expert 布局**:用 NineToothed jagged tensor 表达 expert 维,每个 expert 可有不同 token 数,支持零 token expert。 +4. **两级 tile 选择**:M tile 由实际最大 expert token 数推导为 16/32/64;N tile 由输出宽度推导为 16/32/64/128。没有按公开 case ID 分支,也没有答案表。 +5. **并行与调优**:expert、M tile、N tile 并行;允许 4/8 warps,由统一 auto-tuner 选择,pipeline stage 使用两个平台均合法的 1。 +6. **稳定 buffer 热路径**:缓存 offsets padding/上界、layout state 和已绑定 launch;输入数据可原地变化,但 shape、stride、storage 与 routing contract 每次仍验证。 +7. **安全输出语义**:显式 `out` 时允许复用;隐式分配时使用 workspace 计算后 clone,保证多次返回值互不覆盖。 + +### 4.2 B 部分:通用 SSA 与 Triton 后端 + +| 层次 | 修改 | T1作用 | 架构归属 | +|---|---|---|---| +| Python→SSA frontend | 函数式 `cast(..., bitcast=True)` | 表达 E8M0 位模式到 FP32 的无损解释 | 通用 frontend | +| 通用类型推导 | `interleave` 形状推导 | 两组 E2M1 nibble 恢复为连续 K | 通用 SSA 类型系统 | +| 统一 emitter | `bitcast`、`interleave`、block transpose | 将 T1 表达合法降到 Triton,而非在硬件后端写赛题模板 | 通用 emitter | +| Triton emitter | `tl.*` 合法拼写 | 同一 SSA 同时支持 BW 与 CoreX vendor Triton | 通用 Triton target | +| Triton materializer | 预验证 no-alias direct compiled launch | 去掉热路径重复 binder、specialization key、options 与 cache lookup | 通用运行时能力 | +| 兼容层 | `binder`/`device_caches` feature detection | 兼容不同厂商 Triton 3.1 fork | 能力探测,不按平台名硬编码 | +| 兼容层 | 二元嵌套 runtime stride guard | 避免旧 Triton 拒绝三项链式 `and` | 通用 emitter 兼容修复 | + +直接 compiled launch 只通过私有 `_launch_prevalidated_noalias` 暴露。wrapper 必须先验证 dtype、device、shape、stride、offsets、output 不别名和对象稳定性;任何能力缺失或绑定失败都返回公开 checked handle。每次 launch 都重新读取 active device stream,非默认 stream 测试已覆盖。 + +## 5. 关键设计取舍 + +### 5.1 为什么不先完整反量化 + +完整反量化会把 4-bit weight 扩展为 BF16/FP16,额外读取 packed weight、写入大中间张量,再由 GEMM 重新读取。当前融合表达只读取 packed nibble 和 scale,在寄存器中构造 dot operand,降低显存流量并避免反量化 launch。 + +### 5.2 为什么 B 放在通用层 + +`bitcast`、`interleave`、transpose lowering 和 direct compiled binding 并非 T1 专属:量化解码、布局交织以及稳定 buffer 小算子都可复用。实现没有在海光或天数目录中放 shape 分支;两个平台当前都由同一 Triton target 和能力探测路径生成代码,符合“通用能力放通用层,硬件特性才放平台后端”的边界。 + +### 5.3 current-stream 与安全回退 + +绑定阶段只缓存 specialization、runtime arguments 和 grid,不缓存 stream;每次调用从 Triton driver 获取 current stream。若 vendor Triton 缺少底层 compiled ABI、输出可能 alias、对象/layout 变化或绑定异常,自动回退到完整 NineToothed checked launch。 + +## 6. 实验环境与 baseline + +| 项目 | 海光 | 天数智芯 | +|---|---|---| +| GPU | BW 64 GiB,gfx936,warp 64 | 天垓150 / BI-V150 32 GiB,warp 64 | +| 软件栈 | DTK 25.04 | IX-ML 4.4.0 | +| Python | 3.10.12 | 3.12.3 | +| PyTorch | 2.5.1 | 2.7.1 | +| Triton | 3.1 | 3.1.0 | +| vLLM | 0.9.2 | 0.11.2 | + +### 6.1 原生 PyTorch/vLLM 可执行性 + +- BW PyTorch 2.5.1没有 `torch._scaled_grouped_mm`; +- 天垓150 PyTorch 2.7.1存在该符号,但对 T1 BF16 activation 报错:`Expected mat_a to be Float8_e4m3 matrix got BFloat16`; +- 当前 vLLM 的直接 MXFP4 路径还依赖镜像中缺失的 `quark`。 + +因此当前不能得到原生 PyTorch/vLLM T1 latency。按照赛题“对齐 PyTorch 接口并参考 vLLM MXFP4 编码”的含义,预评测实现两条明确披露的 reference: + +1. `vllm_semantic_fallback_predecoded`:按 vLLM/OCP 编码将 weight 预解码一次,然后逐 expert 执行 BF16/FP16 GEMM,作为主 baseline; +2. `pytorch_raw_reference`:每次都用 PyTorch tensor op 解码 E2M1/E8M0并执行 grouped GEMM,只作为开发正确性和额外性能参考。 + +这解决的是“当前环境如何获得可执行 baseline”;报告不会把语义 fallback 描述为厂商原生 kernel。 + +### 6.2 计时方法 + +两个平台的正式代表 case 均预热 50 次、每轮 500 次、共 9 轮,GPU 同步包围 wall-clock 区间并报告 9 轮中位数。每个平台都使用全新输出目录以及彼此隔离的 correctness、regression 和 benchmark JIT cache;长 K 性能测试也使用独立 cache。 + +## 7. 正确性、回归与性能 + +### 7.1 正确性与回归 + +| 平台 | T1算子测试 | 相关编译器回归 | 状态 | +|---|---:|---:|---| +| BW | 15 passed | 98 passed | 统一 HEAD 通过 | +| 天垓150 | 15 passed | 98 passed | 统一 HEAD 通过 | + +15 个 T1 测试覆盖 BF16/FP16、ragged offsets、legacy boundaries、零 token expert、4组 shape、E2M1 编码契约、offsets 校验/缓存、动态输入、独立输出、非默认 CUDA stream,以及长归约 tile 选择契约。98 个相关回归同时覆盖 unified SSA lowering、Triton runtime/validation/emitter 和 jagged runtime stride 契约。 + +### 7.2 BF16 A+B 双平台 speedup + +| 平台 | E | counts | N | K | baseline ms | A+B ms | speedup | +|---|---:|---|---:|---:|---:|---:|---:| +| BW | 1 | 1 | 16 | 32 | 0.062637 | 0.023555 | **2.659205×** | +| BW | 2 | 1,3 | 32 | 64 | 0.119773 | 0.024942 | **4.801980×** | +| BW | 2 | 5,2 | 64 | 128 | 0.122312 | 0.023991 | **5.098238×** | +| BW | 4 | 1,0,17,5 | 128 | 256 | 0.213102 | 0.027844 | **7.653477×** | +| BW | 4 | 1,3,5,7 | 256 | 256 | 0.233672 | 0.025260 | **9.250804×** | +| 天垓150 | 1 | 1 | 16 | 32 | 0.028474 | 0.018929 | **1.504281×** | +| 天垓150 | 2 | 1,3 | 32 | 64 | 0.055614 | 0.019579 | **2.840501×** | +| 天垓150 | 2 | 5,2 | 64 | 128 | 0.055897 | 0.019500 | **2.866542×** | +| 天垓150 | 4 | 1,0,17,5 | 128 | 256 | 0.102651 | 0.029008 | **3.538677×** | +| 天垓150 | 4 | 1,3,5,7 | 256 | 256 | 0.109238 | 0.026641 | **4.100457×** | + +- BW 统一复测算术平均:**5.892741×**,最低 **2.659205×**; +- 天垓150统一复测算术平均:**2.970092×**,最低 **1.504281×**; +- 10/10 个双平台代表性能点均大于 1。 + +### 7.3 FP16 A+B 双平台 speedup + +FP16 使用相同的 A+B 构建,只将 activation/output dtype 改为 FP16。BW 使用与 BF16 代表矩阵相同的 5 个 shape 和 `50/500/9` 协议;天垓150记录 3 个扩展 case,使用 `30/200/7` 协议。每个 case 的正确性均通过,speedup 按 `baseline_ms / submission_ms` 计算: + +| 平台 | 协议 | E | counts | N | K | baseline ms | A+B ms | speedup | +|---|---|---:|---|---:|---:|---:|---:|---:| +| BW | 50/500/9 | 1 | 1 | 16 | 32 | 0.062881 | 0.024373 | **2.579908×** | +| BW | 50/500/9 | 2 | 1,3 | 32 | 64 | 0.125746 | 0.025228 | **4.984353×** | +| BW | 50/500/9 | 2 | 5,2 | 64 | 128 | 0.127347 | 0.024685 | **5.158904×** | +| BW | 50/500/9 | 4 | 1,0,17,5 | 128 | 256 | 0.205542 | 0.023939 | **8.586130×** | +| BW | 50/500/9 | 4 | 1,3,5,7 | 256 | 256 | 0.235877 | 0.024553 | **9.606796×** | +| 天垓150 | 30/200/7 | 1 | 1 | 16 | 32 | 0.029918 | 0.019345 | **1.546566×** | +| 天垓150 | 30/200/7 | 4 | 1,0,17,5 | 128 | 256 | 0.102779 | 0.030429 | **3.377677×** | +| 天垓150 | 30/200/7 | 4 | 1,3,5,7 | 512 | 1024 | 0.113628 | 0.090857 | **1.250629×** | + +- BW FP16 性能算术平均:**6.183218×**,最低 **2.579908×**; +- 天垓150 FP16 扩展性能算术平均:**2.058291×**,最低 **1.250629×**; +- 8/8 个 FP16 性能点均大于 1;不同协议的结果分别统计,不合并计算平均值; +- 本次环境为 Python 3.10.12、PyTorch 2.5.1、Triton 3.1、vLLM 0.9.2,设备为 BW gfx936;原生 `torch._scaled_grouped_mm` 不存在,因此仍使用已披露的 vLLM 语义 fallback baseline。 + +统一复测原始证据目录为: + +- BW:`/t1/bw/{correctness,regression,benchmark}`; +- 天垓150:`/t1/tiangai150/{correctness,regression,benchmark}`。 + +## 8. 优化来源与消融解释 + +性能来自 A+B 协同: + +- A 消除完整反量化中间张量,并用 ragged tile 只计算实际 token; +- tile/warp 选择提高不同小M、N、K组合的利用率; +- offsets/layout缓存避免每次重建 jagged ABI; +- B 的 direct compiled launch 跳过重复 Python/Triton binder与 specialization cache lookup。 + +benchmark 同时记录 `submission_bound_ms`。天垓 final-clean 中 bound launch 为约 `0.0082–0.0278 ms`,公开 wrapper 为约 `0.0191–0.0285 ms`;两者差值反映参数校验和 Python wrapper 的剩余开销。 + +## 9. 工程质量与适用边界 + +### 9.1 已完成 + +- 接口新增最小且兼容;数学语义、测试和 baseline 口径均显式记录; +- 通用 frontend/SSA/emitter/runtime能力位于 ninetoothed 通用层; +- 没有按公开 shape、case ID或隐藏数据硬编码; +- 没有绕过 NineToothed 编译链调用未申报闭源算子; +- fast path 有 no-alias、对象身份、layout、offsets和 current-stream 测试; +- 两个平台使用同一源码逻辑,vendor差异通过 feature detection 安全回退; +- Ruff、贡献规范、T1测试和相关回归已在 BW 与天垓150通过。 + +### 9.2 已知限制 + +1. 原生 PyTorch/vLLM BF16×MXFP4 kernel在当前镜像不可执行; +2. 当前主要性能矩阵为 BF16;FP16 已通过正确性测试,并已形成 BW 与天垓150的性能记录。两平台 FP16 的 case/protocol 不完全对称,因此本报告分别统计,不将 FP16 汇总为双平台同矩阵 speedup; +3. 扩展矩阵已覆盖 FP16、大 N/K、更多 experts 和不均衡 routing,但仍不能代表隐藏 case; +4. 直接 compiled launch依赖 vendor Triton暴露稳定底层 ABI,能力缺失时会回退,性能可能下降但正确性不变; + +## 10. 一键复现 + +完整命令、输出结构、baseline 与判定标准见 `docs/KERNELSWIFT_T1_REPRODUCE.md`。核心命令: + +```bash +cd /path/to/ntops +bash scripts/run_kernelswift_t1.sh \ + --ninetoothed-dir /path/to/ninetoothed \ + --output-dir /path/to/fresh/results \ + --mode all +``` diff --git a/docs/KERNELSWIFT_T1_REPRODUCE.md b/docs/KERNELSWIFT_T1_REPRODUCE.md new file mode 100644 index 0000000..752f63f --- /dev/null +++ b/docs/KERNELSWIFT_T1_REPRODUCE.md @@ -0,0 +1,176 @@ +# KernelSwift T1 一键构建与评测说明 + +## 1. 适用范围 + +本文档用于复现 T1 MXFP4 W4A16 分组专家矩阵乘的统一 A+B 作品: + +- A 部分:`ntops` 中的 NineToothed MXFP4 解码、分组 GEMM、布局与并行实现; +- B 部分:`ninetoothed` 中的通用 SSA `bitcast`、`interleave`、`transpose` lowering,以及可安全绑定的 Triton 低开销 launch; +- 接口语义:对齐 PyTorch `scaled_grouped_mm` 的 grouped-M offsets,并采用 vLLM/OCP MXFP4 E2M1+E8M0 编码; +- 预评测 baseline:当前厂商环境无法执行原生 BF16×MXFP4 PyTorch/vLLM kernel,因此使用文档中明确披露的 vLLM 语义 fallback,并另外记录逐次解码的 PyTorch raw reference。 + +统一复现版本为本 PR 最终 `ntops` HEAD 与 `ninetoothed@6b79203`。其中 T1 使用的通用 SSA 基础来自 `18c50cd`,T1 编译与运行时能力来自 `5b6abe8`。 + +## 2. 目录与环境要求 + +将两个仓库放在同一级目录: + +```text +workspace/ +├── ntops/ +└── ninetoothed/ +``` + +要求: + +- Python 3.10 或更高版本; +- 厂商镜像提供且可用的 PyTorch 与 Triton; +- 一个可见的海光 BW 或天数智芯 GPU; +- `pytest` 与 `ruff`; +- `ntops` 使用本 PR 最终 HEAD,`ninetoothed` 固定为 `6b79203`。 + +脚本通过 `PYTHONPATH` 直接导入源码,不覆盖厂商 PyTorch、Triton 或 vLLM。NineToothed/Triton 在首次调用时 JIT 构建 kernel,因此首次正确性测试同时完成 clean build 验证。 + +## 3. 一条命令完成验证 + +```bash +cd /path/to/workspace/ntops +bash scripts/run_kernelswift_t1.sh \ + --ninetoothed-dir /path/to/workspace/ninetoothed \ + --output-dir /path/to/results/t1_run_001 \ + --mode all +``` + +每次复现必须使用新的 `--output-dir`。脚本为 correctness、compiler regression 和 benchmark 分别创建独立的 NineToothed、Triton 与 XDG cache,避免跨阶段或跨平台共享生成产物。 + +以下命令均应在远程 Linux GPU 主机执行,`/path/to/remote/workspace` 仅表示该主机上的工作目录。 + +### 海光 BW/DTK 25.04 示例 + +```bash +bash -l +cd /path/to/remote/workspace/ntops +bash scripts/run_kernelswift_t1.sh \ + --ninetoothed-dir /path/to/remote/workspace/ninetoothed \ + --output-dir /path/to/remote/workspace/results/t1_bw_run_001 \ + --mode all +``` + +### 天数智芯天垓150示例 + +```bash +bash -l +cd /path/to/remote/workspace/ntops +bash scripts/run_kernelswift_t1.sh \ + --ninetoothed-dir /path/to/remote/workspace/ninetoothed \ + --output-dir /path/to/remote/workspace/results/t1_tiangai_run_001 \ + --mode all +``` + +厂商 GPU 库由登录 shell 初始化。若直接通过 SSH 执行非交互命令,应使用 `bash -lic`,否则可能出现 `libgalaxyhip.so` 或 `libcudart.so` 不在动态库路径中的假性失败。 + +## 4. 可单独执行的阶段 + +```bash +# 环境、仓库 HEAD、dirty 状态和关键文件 SHA256 +bash scripts/run_kernelswift_t1.sh --mode environment + +# Ruff 与 ninetoothed 贡献规范检查 +bash scripts/run_kernelswift_t1.sh --mode lint + +# 15 个算子正确性/缓存/stream/tile 选择测试 +bash scripts/run_kernelswift_t1.sh --mode correctness + +# 86 个相关 SSA、Triton runtime 与 emitter 回归 +bash scripts/run_kernelswift_t1.sh --mode regression + +# 5 个 BF16 代表 case,生成 benchmark.log +bash scripts/run_kernelswift_t1.sh --mode benchmark + +# 扩展矩阵示例:FP16、大 N/K、更多 expert 和不均衡 routing +python benchmarks/benchmark_mxfp4_grouped_gemm_reference.py \ + --experts 4 --n 256 --k 1024 --counts 1,3,5,7 --dtype bfloat16 \ + --warmup 50 --iterations 500 --rounds 9 +``` + +BW DTK25.04 的 FP16 性能测试使用相同的 5 个代表 case、`50/500/9` 协议和独立 cache。以下命令会生成远程实验日志;最终提交只在报告中汇报设备、软件版本、测试方法和汇总结果: + +```bash +FP16_OUTPUT=/path/to/fresh/results/t1_bw_fp16 +mkdir -p "$FP16_OUTPUT/cache_ninetoothed" "$FP16_OUTPUT/cache_triton" "$FP16_OUTPUT/cache_xdg" +export NINETOOTHED_CACHE_DIR="$FP16_OUTPUT/cache_ninetoothed" +export TRITON_CACHE_DIR="$FP16_OUTPUT/cache_triton" +export XDG_CACHE_HOME="$FP16_OUTPUT/cache_xdg" + +{ + python benchmarks/benchmark_mxfp4_grouped_gemm_reference.py \ + --dtype float16 --experts 1 --n 16 --k 32 --counts 1 \ + --warmup 50 --iterations 500 --rounds 9 + python benchmarks/benchmark_mxfp4_grouped_gemm_reference.py \ + --dtype float16 --experts 2 --n 32 --k 64 --counts 1,3 \ + --warmup 50 --iterations 500 --rounds 9 + python benchmarks/benchmark_mxfp4_grouped_gemm_reference.py \ + --dtype float16 --experts 2 --n 64 --k 128 --counts 5,2 \ + --warmup 50 --iterations 500 --rounds 9 + python benchmarks/benchmark_mxfp4_grouped_gemm_reference.py \ + --dtype float16 --experts 4 --n 128 --k 256 --counts 1,0,17,5 \ + --warmup 50 --iterations 500 --rounds 9 + python benchmarks/benchmark_mxfp4_grouped_gemm_reference.py \ + --dtype float16 --experts 4 --n 256 --k 256 --counts 1,3,5,7 \ + --warmup 50 --iterations 500 --rounds 9 +} 2>&1 | tee "$FP16_OUTPUT/benchmark.log" +``` + +也可以直接执行核心正确性测试: + +```bash +export PYTHONPATH=/path/to/ntops/src:/path/to/ninetoothed/src:${PYTHONPATH:-} +export NINETOOTHED_CACHE_DIR=/path/to/new/cache/ninetoothed +export TRITON_CACHE_DIR=/path/to/new/cache/triton +cd /path/to/ntops +python -m pytest tests/test_mxfp4_grouped_gemm.py -q +``` + +## 5. 输出文件 + +`--mode all` 生成: + +```text +/ +├── environment.log +├── lint.log +├── correctness.log +├── compiler_regression.log +├── benchmark.log +├── cache_correctness/ +├── cache_regression/ +└── cache_benchmark/ +``` + +判定标准: + +- `correctness.log` 为 `15 passed`(当前 T1 版本); +- `compiler_regression.log` 无失败; +- `speedup = baseline_ms / submission_ms`; +- 每个预评测 case 的 `speedup > 1`。 + +## 6. baseline 与计时口径 + +脚本先探测原生 `torch._scaled_grouped_mm`: + +- 若当前 PyTorch 和硬件接受 BF16/FP16 activation、packed MXFP4 weight、E8M0 scale 与 grouped offsets,则单独报告原生路径可用; +- 当前已测 BW PyTorch 2.5.1没有该入口;天垓150 PyTorch 2.7.1存在入口,但拒绝 BF16 activation并要求 FP8,因此两者均不能作为当前可执行 T1 原生 baseline。 + +当前 benchmark 的主要 baseline 为 `vllm_semantic_fallback_predecoded`:严格按 vLLM/OCP MXFP4 编码预解码 weight 一次,然后逐 expert 执行 BF16/FP16 grouped GEMM。它是公开、可执行并明确披露的语义 fallback,不伪装成原生 PyTorch kernel。脚本同时记录每次重新解码 weight 的 PyTorch raw reference,但后者不作为主 speedup。 + +扩展矩阵只作为本地实验记录,不纳入提交文件。wrapper 对长归约、少量 expert token 且宽 N 的输入,根据 `K/N` 和实际最大 token 数选择较窄的 N tile;这是张量属性驱动的通用启发式,不是公开 case 分支。扩展结果中,长 K case 使用正式的 `50/500/9` 计时,其余扩展 case 使用明确记录的 `30/200/7` 预评测协议。 + +每个 case: + +- 预热 50 次; +- 每轮 500 次; +- 共 9 轮; +- 每轮使用 GPU 同步包围 wall-clock 计时区间; +- 报告 9 个样本的中位数; +- baseline 与 A+B 使用相同输入、dtype、MXFP4 编码、expert offsets 和输出语义; +- 计时前执行独立 FP32 解码参考的正确性检查。 diff --git a/docs/KERNELSWIFT_T2_REPORT.md b/docs/KERNELSWIFT_T2_REPORT.md new file mode 100644 index 0000000..812c85c --- /dev/null +++ b/docs/KERNELSWIFT_T2_REPORT.md @@ -0,0 +1,341 @@ +# KernelSwift T2:Block-scaled FP8 矩阵乘算子—编译协同优化 + +**报告日期**:2026-08-31 + +**赛题**:T2 Block-scaled FP8 矩阵乘 + +**作品形态**:A(NineToothed 算子)+ B(通用编译器/平台后端)统一构建与评测 + +**当前状态**:统一源码使用本 PR 的最终 `ntops` HEAD 与 `ninetoothed@6b79203`,已在海光 BW 与天数智芯天垓150通过 T2 正确性、相关编译器回归和 12 个 A+B 性能点;全部 `speedup > 1` + +## 1. 摘要 + +T2 面向 FP8 线性层、MoE 与 Attention 投影中的 block-scaled FP8 GEMM。其核心负载是按 K 维 128 元素分块,在每个分块内完成 FP8 矩阵乘,并将 activation scale 与 weight scale 融入 FP32 累加。直接反量化完整矩阵会增加显存流量和临时张量;直接把厂商兼容细节写入算子又会破坏 NineToothed 的跨平台抽象。 + +本作品在 A 部分使用 NineToothed 张量元编程表达 tiled GEMM、三种 scale layout、FP32 累加、可选 bias 与 BF16/FP16 输出;在 B 部分增加通用的 FP8 block-dot operand 合法化能力,由 Triton 工具链能力自动选择 native、FP16 或 BF16 lowering,同时复用静态布局分析与预验证 no-alias launch。公开接口不因平台改变,平台差异不按 case 或公开 shape 硬编码。 + +最终源码在海光 BW/DTK25.04 与天数智芯天垓150/IX-ML 4.4.0 上均得到 `30 passed` 的 T2 测试和 `120 passed` 的相关编译器回归。对 `(M,N,K)=(1/16/128,4096,4096)`、E4M3FN/E5M2 共六个 case,海光平均 speedup 为 **3.316458×**,天数平均为 **1.508378×**,两平台各 50% 合并为 **2.412418×**;12 个平台—dtype—shape 测试点全部大于 1。 + +## 2. 代码版本与复现锚点 + +结果对应本次 PR 中的提交链。以下记录官方基线 commit、本题提交和统一验证锚点,便于从 PR 历史复现。 + +| 仓库 | 官方基线 commit | 本题提交/验证锚点 | +|---|---|---| +| ntops | `9ae4166` | 本 PR 的 T2 题目 commit;统一验证使用本 PR 最终 HEAD | +| ninetoothed | `b77f930` | T2 可移植 FP8 dot 合法化 `4ea752e`;统一验证锚点 `6b79203` | + +最终双平台数据只绑定本 PR 最终 `ntops` HEAD 与 `6b79203`。 + +## 3. 研究问题与设计推理 + +### 3.1 问题 + +目标是在同一份 NineToothed 算子源码上支持海光与天数智芯平台,并优化: + +1. FP8 数据的分块加载与矩阵乘; +2. activation/weight scale 的索引、广播和融合; +3. 小 M 推理形状的 tile 与 launch 开销; +4. 厂商 Triton 对 FP8 `tl.dot` 能力不一致时的合法 lowering; +5. PyTorch `_scaled_mm` 风格接口的正确性、兼容性和失败行为。 + +### 3.2 关键观察 + +- scale block 固定为 128,scale 可以在每个 K block 的 dot 结果上融合,不需要生成完整反量化矩阵; +- M=1/16 的解码和小 batch 场景容易被过大的 M tile 与 Python launch 开销支配; +- CoreX 与 DTK 厂商 Triton 对原生 FP8 dot 的支持不同,算子层显式写死一种 cast 会把平台能力泄漏到算子源码; +- wrapper 已经完成 shape、dtype、stride、device 和输出别名条件校验,后端重复执行同一套绑定/alias 检查会产生纯运行时开销; +- PyTorch/vLLM 在目标平台提供了接口和编码规范参考,但不等于镜像中一定存在可执行的同语义二进制 baseline。 + +### 3.3 假设 + +如果将 K-block dot、两侧 scale 和 FP32 accumulation 表达为一个 tiled kernel,并把 FP8 dot 合法化放入能力驱动的后端层,那么可以同时: + +- 保持算子源码与平台无关; +- 避免完整反量化与中间张量流量; +- 在小 M 形状减少无效工作和 launch 开销; +- 在两种国产 GPU 软件栈上通过同一数学语义的正确性测试; +- 相对可执行的开源兼容基线取得 `speedup > 1`。 + +### 3.4 可证伪判据 + +方案在出现任一情况时判为失败: + +- 三种 scale layout、非 128 整除边界、bias 或两种输出 dtype 出现超容差误差; +- 后端 fallback 污染非 FP8 dot,或不同 fallback 共用错误的编译缓存; +- 任何已测正式 case 的 `baseline_latency / submission_latency <= 1`; +- 为特定公开 shape、case ID 或隐藏数据建立分支或答案表; +- baseline 与 submission 数学语义、输入或 dtype 不一致。 + +当前保存的测试与日志未触发以上失败条件。 + +## 4. 数学语义与接口 + +设输入 `A∈FP8^(M×K)`,列主序矩阵 `B∈FP8^(K×N)`,K block 大小为 128。对第 `q` 个 K block: + +$$ +P_q = \operatorname{dot}(A[:,q:q+128], B[q:q+128,:]), +$$ + +$$ +Y = \operatorname{cast}_{out}\left( + \sum_q P_q \odot S_A(q) \odot S_B(q) + bias +\right). +$$ + +`P_q` 与缩放后的累加值使用 FP32 accumulator,最终输出为 BF16 或 FP16。实现支持三种 scale layout: + +| 变体 | `scale_a` | `scale_b` | +|---|---|---| +| 1×128 / 128×128 | `[M, ceil(K/128)]` | `[ceil(K/128), ceil(N/128)]` | +| 1×128 / 1×128 | `[M, ceil(K/128)]` | `[ceil(K/128), N]` | +| 128×128 / 1×128 | `[ceil(M/128), ceil(K/128)]` | `[ceil(K/128), N]` | + +公开入口为: + +```python +ntops.torch.scaled_mm( + input, + mat2, + scale_a, + scale_b, + bias=None, + scale_result=None, + out_dtype=None, + use_fast_accum=False, +) +``` + +接口对齐 PyTorch `_scaled_mm` 的核心参数: + +- `input` 必须二维、row-major contiguous; +- `mat2` 必须二维、`stride(0)==1` 的 column-major 视图; +- 两个矩阵使用 E4M3FN 或 E5M2; +- scale 使用 FP32 且连续; +- `out_dtype` 支持 BF16/FP16,默认 BF16; +- `bias` 可选,dtype 与输出一致; +- 高精度输出时,PyTorch 不应用 `scale_result`,实现只校验其兼容性而不修改结果; +- 实现始终 FP32 累加,因此 `use_fast_accum` 不降低当前数值保证。 + +FNUZ FP8 格式在当前两平台的软件栈与后端语义不一致,wrapper 明确拒绝,而不是静默按错误格式解释。 + +## 5. 修改内容与动机 + +### 5.1 A 部分:NineToothed 算子实现 + +涉及文件: + +- `src/ntops/kernels/scaled_mm.py` +- `src/ntops/torch/scaled_mm.py` +- `src/ntops/kernels/__init__.py` +- `src/ntops/torch/__init__.py` +- `tests/test_scaled_mm.py` + +主要修改: + +1. **张量化分块表达**:output 按 `(BLOCK_M,BLOCK_N)` 切块;A 按 `(BLOCK_M,128)`、B 按 `(128,BLOCK_N)` 切块,K block 作为静态归约域。 +2. **缩放融合**:每个 K block 执行一次 dot,再将对应 `scale_a × scale_b` 广播到输出 tile 并立即累加,不物化完整反量化矩阵。 +3. **FP32 accumulation**:dot partial 的缩放结果累加到 FP32 accumulator,完成后才转为输出 dtype。 +4. **三种布局统一表达**:通过 `ScalingVariant` 和 arrangement 选择 scale tile,application 的数学主体保持一致。 +5. **shape-specialized tile**:tile 由矩阵 extent 和 scale layout 推导。M=1 使用 16 行 masked tile;普通 row-scaled 路径 M tile 最大为 64;weight row-scaled 变体可使用 128 行。不存在 case ID 或公开 shape 答案表。 +6. **融合 bias**:bias 作为输出 tile 的广播输入,在 accumulator 写回前融合。 +7. **静态 kernel cache**:wrapper 将 shape、dtype、scale layout、bias 和 tile 元参数作为编译契约,复用 `_cached_make`。 +8. **低开销调用**:wrapper 完整校验输入后,为新分配且不别名的 output 使用 `_launch_prevalidated_noalias`;其他后端自动回退公开 kernel handle。 + +### 5.2 B 部分:通用编译器与 Triton 后端 + +T2 实际依赖的 B 部分能力如下: + +| 层次 | 修改 | T2 作用 | 架构归属 | +|---|---|---|---| +| 通用 SSA emitter | block dot operand coercion hook | 在 lowering 阶段合法化 dot operands,而不是把厂商 cast 写入算子 | 通用编译器扩展点 | +| Triton backend options | `fp8_dot_fallback=auto/none/float16/bfloat16` | 用显式、可测试、可缓存的能力配置选择 lowering | 通用 Triton 后端 | +| 工具链能力检测 | 根据 Triton distribution metadata 选择有效 fallback | CoreX→BF16、DTK→FP16、支持 native 的上游 Triton→none | 平台工具链能力逻辑 | +| Triton emitter | 已知 FP8 dtype 在 `tl.dot` 前统一转换 | 避免 CoreX 错误 lowering 与 DTK 编译失败 | 后端合法化 | +| 编译缓存 | 有效 fallback 进入 cache key | 防止不同硬件/工具链误复用 kernel | 通用编译缓存语义 | +| 静态布局与 SSA application block | 静态 shape/stride、合法 tile 与 mask 分析 | 生成 tiled GEMM 和边界 mask | 通用编译器层 | +| 运行时 | 预验证 no-alias launch plan | 跳过 wrapper 已经完成的重复 ABI、绑定和 alias 检查 | 通用私有快速路径 | + +`fp8_dot_fallback` 是 Triton 后端的通用能力,而不是 `scaled_mm` 专用 pass。测试使用独立的最小 block-dot kernel 验证 option normalization、source emission、非 FP8 保持、未知 dtype guard 和 cache key 隔离。 + +## 6. 关键设计取舍 + +### 6.1 为什么不在 A 部分直接写死 FP16 cast + +早期 A 部分曾在 dot 前显式将两侧 FP8 转成 FP16。它能绕过部分厂商编译问题,但会把平台限制固化到算子表达,且 CoreX 的有效兼容类型与 DTK 不同。最终实现撤回算子内 cast,把合法化交给 B 部分;算子只表达“对 FP8 tile 做 dot”的数学意图。 + +### 6.2 为什么保留 FP32 accumulator + +block scale 可能放大不同 K block 的 partial。若直接以 BF16/FP16 累加,误差会随 K block 数增长。FP32 accumulator 增加少量寄存器成本,但提高跨 dtype、scale layout 与非整除边界的稳定性,并使 `use_fast_accum=False` 的语义明确。 + +### 6.3 为什么对小 M 使用较小 tile + +MoE、Attention decode 和小 batch 线性层常见 M=1/16。M tile 过大时,大部分行被 mask,造成无效 load、dot lane 和输出 predicate。当前 tile 由 extent 的 2 次幂可整除性与 layout 约束推导,而不是仅对六个 benchmark shape 建表。 + +### 6.4 为什么使用预验证 launch + +公开 kernel handle 必须对一般调用执行完整 ABI、layout 和 alias 检查。T2 wrapper 每次已经验证 shape、stride、dtype、device,且 output 是新分配张量,因此可安全使用私有 no-alias launch。该优化不删除公开检查,也不改变外部接口;后端不提供私有入口时自动回退。 + +### 6.5 被否决或撤回的实验 + +开发历史保留了可追溯的负结果: + +- 两 warp candidate 在目标 shape 上更慢,已撤回; +- row-scaled M tile autotune 没有形成跨平台稳定收益,已撤回; +- 强制 BF16 dot fallback 不适合两套平台,改为工具链能力选择; +- 静态 K loop lowering 的实验没有有效收益,已回退; +- 所有诊断性 benchmark 开关均未进入最终 timed kernel。 + +这些负结果说明最终配置不是只保留“成功数据”的 shape 查表,但当前没有为每个撤回实验保存统一格式的数值消融表。 + +## 7. 架构与工程规范符合性 + +### 7.1 接口改动最小 + +- 只新增 `ntops.torch.scaled_mm` / `_scaled_mm` 入口与导出; +- 参数保持 PyTorch 风格,不增加海光/天数专用公开参数; +- 不修改输入数学语义、官方测试或官方计时程序; +- 对不支持的 dtype/layout 显式报错,不静默回退到错误算法。 + +### 7.2 通用能力与平台特化边界 + +- scale layout、tile、dot-scale fusion 属于算子层; +- FP8 operand coercion hook、cache key、静态布局和 launch plan 属于通用编译器层; +- CoreX/DTK 差异只通过 Triton 工具链能力选择 fallback,不出现在 T2 shape 或语义分支; +- 没有在海光或天数后端复制同一套算子逻辑。 + + +## 8. 实验环境与方法 + +### 8.1 环境 + +| 项目 | 海光 | 天数智芯 | +|---|---|---| +| GPU | BW,64 GiB,gfx936 | 天垓150 / BI-V150,32 GiB | +| 软件栈 | DTK 25.04 | IX-ML 4.4.0,Driver 4.4.0 | +| Python | 3.10.12 | 3.12.3 | +| PyTorch | 2.5.1 | 2.7.1 | +| Triton | 3.1 | 3.1.0 | +| vLLM | 0.9.2 | 0.11.2 | +| submission fallback | FP16 | BF16 | +| timed baseline | `vllm_portable`,FP16 fallback | `vllm_portable`,BF16 fallback | + +### 8.2 正确性 + +独立 FP32 eager reference 对每个 K block 执行 FP32 matmul 和 scale,再累加并转为目标 dtype。T2 测试容差为 `rtol=0.05, atol=0.05`,覆盖: + +- M=1、16 和非规则 `(33,257,385)`; +- 三种 scale layout; +- BF16/FP16 输出; +- bias 与高精度输出下的 `scale_result` 行为; +- M/N/K 非 128 整除边界 mask; +- tile 选择规则; +- FNUZ 明确拒绝; +- FP8 fallback option、source emission 与 cache key。 + +### 8.3 性能 + +- shape:`M=1/16/128,N=K=4096`; +- dtype:E4M3FN 与 E5M2; +- scale layout:1×128 / 128×128; +- output:BF16; +- 每轮在首次 JIT 和正确性通过后计时; +- 三轮交替 submission/baseline 的先后顺序; +- 每段计时前后 GPU synchronize; +- speedup 使用三轮中位 latency 计算; +- 计时后再次验证 submission 输出; +- eager reference 不计时。 + +## 9. 正确性与回归结果 + +| 平台 | T2 算子测试 | FP8 fallback 专项回归 | 性能 case 计时后正确性 | +|---|---:|---:|---:| +| 海光 BW/DTK25.04 | `30 passed in 78.98s` | `120 passed in 10.14s` | 6/6 pass | +| 天数智芯天垓150 | `30 passed in 23.59s` | `120 passed in 9.31s` | 6/6 pass | + + +上述数据来自全新目录的一键脚本正式复测,不是短时 smoke;原始日志位于同一结果目录。 + +## 10. 性能结果 + +### 10.1 海光 BW/DTK25.04 + +| FP8 dtype | M | submission (ms) | baseline (ms) | speedup | +|---|---:|---:|---:|---:| +| E4M3FN | 1 | 0.225511 | 0.809266 | **3.588588×** | +| E4M3FN | 16 | 0.227955 | 0.858246 | **3.764980×** | +| E4M3FN | 128 | 0.323871 | 0.938118 | **2.896579×** | +| E5M2 | 1 | 0.077159 | 0.274031 | **3.551511×** | +| E5M2 | 16 | 0.077034 | 0.332744 | **4.319443×** | +| E5M2 | 128 | 0.186894 | 0.332231 | **1.777644×** | + +E4M3FN 平均 **3.416716×**,E5M2 平均 **3.216199×**,六个 case 平均 **3.316458×**。 + +### 10.2 天数智芯天垓150 + +| FP8 dtype | M | submission (ms) | baseline (ms) | speedup | +|---|---:|---:|---:|---:| +| E4M3FN | 1 | 0.290570 | 0.431914 | **1.486437×** | +| E4M3FN | 16 | 0.284554 | 0.525686 | **1.847403×** | +| E4M3FN | 128 | 0.504879 | 0.607184 | **1.202633×** | +| E5M2 | 1 | 0.289866 | 0.432237 | **1.491161×** | +| E5M2 | 16 | 0.290177 | 0.520649 | **1.794246×** | +| E5M2 | 128 | 0.506346 | 0.621988 | **1.228385×** | + +E4M3FN 平均 **1.512158×**,E5M2 平均 **1.504597×**,六个 case 平均 **1.508378×**。 + +### 10.3 跨平台合并 + +按海光/天数各 50% 合并: + +| 汇总项 | speedup | +|---|---:| +| E4M3FN | **2.464437×** | +| E5M2 | **2.360398×** | +| 12 个平台测试点总体算术平均 | **2.412418×** | + + +## 11. 基线可执行性说明 + +赛题写明“优先 PyTorch,其次 vLLM”。本作品遵循该优先级先探测目标镜像: + +1. PyTorch `_scaled_mm` 接口存在,但两平台无法执行当前 block scaling 语义; +2. 上游 vLLM 的 block-scaled FP8 Triton 路径在厂商工具链上不能同时满足编译与正确性; +3. 因此使用 vLLM 源码结构的 portable compatibility baseline,且将兼容 cast 对 baseline 与 submission 按各自工具链语义独立应用; +4. eager reference 从不进入 timed 区间。 + +`vllm_portable` 的作用是获得可解释的性能证据。 + +## 12. 工程质量、兼容性与适用边界 + +### 12.1 已覆盖能力 + +- E4M3FN/E5M2 输入; +- BF16/FP16 输出; +- 三种 block scaling layout; +- 可选 bias; +- 非 128 整除边界; +- M=1 到较大 M 的静态 shape; +- CoreX/DTK fallback 和支持 native FP8 dot 的上游 Triton 保持路径。 + +### 12.2 已知限制 + +- 当前不支持 FNUZ FP8; +- 当前不输出 FP8,因此 `scale_result` 只做接口兼容校验; +- `input` 需 row-major contiguous,`mat2` 需 column-major; +- 当前性能表只覆盖 1×128/128×128 scale layout,其他两种布局已有正确性测试但尚无正式性能表; +- 两套目标工具链使用 FP16/BF16 dot fallback,尚未利用未来硬件/编译器可能提供的原生 FP8 dot 峰值; +- 当前 baseline 为 vLLM-derived portable compatibility baseline,不是未经修改的上游二进制; + +### 12.3 第三方代码 + +`benchmarks/vllm_portable_scaled_mm.py` 参考 vLLM `fp8_utils.py` 中的 `w8a8_triton_block_scaled_mm`,遵循 Apache-2.0。来源: + +- [vLLM GitHub](https://github.com/vllm-project/vllm) +- [vLLM LICENSE](https://github.com/vllm-project/vllm/blob/main/LICENSE) + +该第三方适配只存在于 benchmark 目录,submission 算子不调用它。 + +## 13. 复现与证据映射 + +一键命令、缓存隔离、输出文件和判定标准见 `docs/KERNELSWIFT_T2_REPRODUCE.md`。结果汇总见本报告第 10 节。服务器原始证据根目录:`/t2/`。 diff --git a/docs/KERNELSWIFT_T2_REPRODUCE.md b/docs/KERNELSWIFT_T2_REPRODUCE.md new file mode 100644 index 0000000..434ea9c --- /dev/null +++ b/docs/KERNELSWIFT_T2_REPRODUCE.md @@ -0,0 +1,191 @@ +# KernelSwift T2 一键构建与评测说明 + +## 1. 适用范围 + +本文档用于复现 T2 Block-scaled FP8 矩阵乘的统一 A+B 作品: + +- A 部分:`ntops` 中的 NineToothed 算子表达、数据布局、分块策略、缩放融合与 PyTorch 兼容入口; +- B 部分:`ninetoothed` 中的通用 SSA/Triton lowering、厂商工具链能力驱动的 FP8 dot 合法化,以及预验证低开销 launch; +- 正确性参考:FP32 分块反量化与矩阵乘的 PyTorch eager 实现; +- 性能基线:优先支持可执行的 PyTorch `_scaled_mm`,否则使用明确标注的 vLLM 路径。 + +PyTorch eager 只生成正确性参考值,不参与 latency 或 speedup。 + +统一复现版本为本 PR 最终 `ntops` HEAD 与 `ninetoothed@6b79203`。T2 使用的可移植 FP8 dot 合法化能力由 `4ea752e` 引入,最终正确性、回归和性能记录均使用 `6b79203`。 + +## 2. 基线选择与边界 + +一键脚本支持四种基线: + +| 参数 | 含义 | +|---|---| +| `pytorch` | 调用当前镜像的 `torch._scaled_mm`,不修改其 timed kernel | +| `vllm_triton` | 调用当前镜像的 vLLM block-scaled FP8 Triton 算子 | +| `vllm_vendor` | 调用当前镜像由 vLLM 暴露的厂商算子 | +| `vllm_portable` | vLLM `fp8_utils.py` 的 Apache-2.0 兼容移植,默认值 | + +当前两台目标服务器不能直接执行语义一致的前三条路径: + +- 天数智芯 PyTorch `_scaled_mm` 报 `Invalid scaling configuration`; +- 海光 BW 的 PyTorch `_scaled_mm` 要求 CUDA CC 9.0+ 或 ROCm MI300+,而 BW 为 `gfx936`; +- 未修改的 vLLM Triton 路径在两套厂商 Triton 上分别出现数值错误或 FP8 dot 编译失败。 + +因此当前性能证据使用 `vllm_portable`。它保持 vLLM 的 kernel 结构、数据布局和默认 launch 配置,只加入厂商 Triton 缺少原生 FP8 `tl.dot` 时必需的 operand cast:CoreX 使用 BF16,DTK 使用 FP16。该文件只用于 benchmark,不被 `ntops` 算子导入。 + +这组结果应准确表述为“相对 vLLM-derived portable compatibility baseline 的 A+B speedup”。 + +## 3. 目录和环境要求 + +将两个仓库放在同一级目录: + +```text +workspace/ +├── ntops/ +└── ninetoothed/ +``` + +要求: + +- Python 3.10 或更高版本; +- 厂商镜像自带且可用的 PyTorch 与 Triton; +- 使用 vLLM 基线时,镜像中应安装 vLLM; +- 一个可见的海光 BW 或天数智芯 GPU; +- `pytest`、`git` 和 `sha256sum`; +- 两个仓库均处于报告记录的可追溯版本。 + +脚本通过 `PYTHONPATH` 直接使用两个仓库的源码,不会用 `pip` 覆盖厂商 PyTorch、Triton 或 vLLM。 + +## 4. 一条命令完成构建与评测 + +NineToothed/Triton 在首次调用时 JIT 构建 kernel,因此首次正确性测试同时完成编译与执行验证。每次正式复现应使用新的输出目录;脚本为正确性、编译器回归和性能评测分别创建 NineToothed/Triton 缓存。 + +```bash +cd /path/to/workspace/ntops +bash scripts/run_kernelswift_t2.sh \ + --ninetoothed-dir /path/to/workspace/ninetoothed \ + --output-dir /path/to/results/t2_run_001 \ + --baseline vllm_portable \ + --mode all +``` + +以下命令均应在远程 Linux GPU 主机执行,`/path/to/remote/workspace` 仅表示该主机上的工作目录。 + +### 海光 BW/DTK 25.04 示例 + +```bash +cd /path/to/remote/workspace/validation/ntops +bash scripts/run_kernelswift_t2.sh \ + --ninetoothed-dir /path/to/remote/workspace/validation/ninetoothed \ + --output-dir /path/to/remote/workspace/results/t2_bw_dtk2504 \ + --baseline vllm_portable \ + --mode all +``` + +### 天数智芯天垓150示例 + +```bash +cd /path/to/remote/workspace/validation/ntops +bash scripts/run_kernelswift_t2.sh \ + --ninetoothed-dir /path/to/remote/workspace/validation/ninetoothed \ + --output-dir /path/to/remote/workspace/results/t2_tiangai150 \ + --baseline vllm_portable \ + --mode all +``` + +## 5. 可单独执行的阶段 + +```bash +# 仅记录环境、仓库 HEAD/dirty 状态和关键文件 SHA256 +bash scripts/run_kernelswift_t2.sh --mode environment + +# T2 算子接口、布局、边界、dtype、bias 与数值测试 +bash scripts/run_kernelswift_t2.sh --mode correctness + +# T2 依赖的 FP8 dot、SSA、静态布局和运行时相关编译器回归 +bash scripts/run_kernelswift_t2.sh --mode regression + +# 六个 case:M=1/16/128,N=K=4096,E4M3FN/E5M2 +bash scripts/run_kernelswift_t2.sh \ + --mode benchmark \ + --baseline vllm_portable +``` + +如目标平台已经支持 PyTorch block scaling,可执行: + +```bash +bash scripts/run_kernelswift_t2.sh \ + --mode benchmark \ + --baseline pytorch +``` + +也可以直接探测一个基线路径。以下命令先验证正确性,只有通过后才计时: + +```bash +export PYTHONPATH=/path/to/ntops/src:/path/to/ninetoothed/src:${PYTHONPATH:-} +python benchmarks/probe_official_scaled_mm.py \ + --mode pytorch \ + --m 1 --n 4096 --k 4096 \ + --fp8-dtype e4m3fn +``` + +## 6. 输出文件 + +`--mode all` 生成: + +```text +/ +├── environment.log +├── correctness.log +├── compiler_regression.log +├── benchmark_summary.log +├── benchmark_raw/ +├── cache_correctness/ +├── cache_regression/ +├── cache_benchmark/ +├── triton_cache_correctness/ +├── triton_cache_regression/ +└── triton_cache_benchmark/ +``` + +判定标准: + +- `correctness.log` 中 T2 测试全部通过; +- `compiler_regression.log` 无失败; +- `benchmark_raw/` 中 submission 与 baseline 的每次调用均先通过正确性; +- `speedup = baseline_median_ms / submission_median_ms`; +- 每个性能 case 的 `speedup > 1`。 + +若选择的 PyTorch/vLLM 路径在目标硬件不可执行,脚本会保留错误日志并以非零状态退出,不会自动拿 eager latency 冒充 speedup。 + +## 7. 计时方法 + +- shape 为 `(M,N,K)=(1/16/128,4096,4096)`; +- 覆盖 `float8_e4m3fn` 和 `float8_e5m2`; +- scale layout 为 `A: 1×128`、`B: 128×128`,输出为 BF16; +- 每个 candidate 每轮先完成首次 JIT 与正确性检查,再预热 20 次、计时 100 次; +- 共 3 轮,奇数轮 submission 先测,偶数轮 baseline 先测; +- 每条路径在计时区间前后执行 GPU 同步; +- 报告三轮 latency 的中位数; +- submission 与 baseline 使用同一随机种子、shape、dtype、scale layout 和数学语义; +- FP32 eager reference 不计时。 + +可通过 `--warmup`、`--iterations` 和 `--rounds` 增大统计强度,但修改参数后应在报告中同时更新。 + +## 8. 当前已保存的结果 + +结果汇总见 `docs/KERNELSWIFT_T2_REPORT.md` 第 10 节,原始服务器日志保存在 `/t2/logs/`。 + +关键日志: + +```text +bw_t2_final_matrix_prevalidated_v1.log +tiangai_t2_final_matrix_prevalidated_v1.log +bw_t2_final_tests_v1.log +tiangai_launch_plan_tests_v1.log +bw_backend_fp8_fallback_final_v1.log +tiangai_backend_fp8_fallback_final_v1.log +``` + +本 PR 最终 `ntops` HEAD 与 `ninetoothed@6b79203` 的 12 个跨平台测试点全部通过计时前、baseline 和计时后正确性,且 speedup 全部大于 1。六个 case 在海光平台的总体均值为 **3.316458×**,天数平台总体均值为 **1.508378×**;两平台各 50% 合并为 **2.412418×**。 + +一键脚本已在天垓150的最终本地源码 validation 副本上做过独立 smoke 验证:shell 语法检查通过,T2 为 `30 passed in 23.50s`,脚本所列扩展编译器回归为 `117 passed in 9.59s`,benchmark 汇总输出成功生成。benchmark smoke 使用 1 轮、5 次预热、20 次计时,只验证编排与聚合逻辑,不替代上表三轮正式结果。 diff --git a/docs/KERNELSWIFT_T3_REPORT.md b/docs/KERNELSWIFT_T3_REPORT.md new file mode 100644 index 0000000..4e1a5a9 --- /dev/null +++ b/docs/KERNELSWIFT_T3_REPORT.md @@ -0,0 +1,199 @@ +# KernelSwift T3:Gated RMSNorm 融合算子—编译协同优化 + +**报告日期**:2026-08-31 + +**赛题**:T3 Gated RMSNorm 融合算子 + +**作品形态**:A(NineToothed 算子)+ B(通用编译器/平台后端)统一构建与评测 + +**当前状态**:统一源码使用本 PR 的最终 `ntops` HEAD 与 `ninetoothed@6b79203`,已在两平台通过 T3 正确性、相关编译器回归和六个 vLLM 性能 case;全部 `speedup > 1` + +## 1. 摘要 + +T3 面向 KDA、门控线性 Attention 和新型序列模型中的 Gated RMSNorm。朴素实现需要分别执行门控激活、逐元素乘、平方归约、倒平方根、归一化与 affine 缩放,容易产生多个 kernel launch 和中间张量流量。本作品使用 NineToothed 的张量元编程表达将上述步骤融合为一个 kernel,并通过静态布局、统一 SSA application-block lowering、能力驱动的 Triton 合法化和预验证低开销 launch 降低小算子的编译与运行时开销。 + +在同一份源码上,T3 正确性测试在海光 BW 与天数智芯天垓150均为 `32 passed`,T3 相关编译器回归均为 `94 passed`。相对未修改 vLLM 基线,海光三个 case 的平均 speedup 为 **1.2626×**,天数三个 case 的平均 speedup 为 **1.3436×**;六个 case 等权平均为 **1.3031×**。 + +## 2. 代码版本与复现锚点 + +结果对应本次 PR 中的提交链。以下记录官方基线 commit、本题提交和统一验证锚点,便于从 PR 历史复现。 + +| 仓库 | 官方基线 commit | 本题提交/验证锚点 | +|---|---|---| +| ntops | `9ae4166ad342e4745f0eed13a5a20d069e994fc0` | 本 PR 的 T3 题目 commit;统一验证使用本 PR 最终 HEAD | +| ninetoothed | `b77f930dc6c8b016e09adf33570d55a7bc8376c1` | 通用 SSA/运行时能力 `18c50cd`、`5b6abe8`;统一验证锚点 `6b79203` | + +## 3. 数学语义与接口 + +设输入为 (x),门控为 (g),激活为 (a(g)),可选权重为 (w),按隐藏维每 `group_size` 个元素分组。`norm_before_gate=False` 时: + +$$ +v=x\odot a(g),\qquad +y=\frac{v}{\sqrt{\operatorname{mean}_{group}(v^2)+\epsilon}}\odot w. +$$ +`norm_before_gate=True` 时: + +$$ +y=\frac{x}{\sqrt{\operatorname{mean}_{group}(x^2)+\epsilon}}\odot a(g)\odot w. +$$ + +实现提供: + +```python +ntops.torch.rms_norm_gated( + input, + gate, + weight=None, + eps=1e-5, + group_size=None, + norm_before_gate=False, + activation="swish", +) +``` + +支持 `silu`/`swish` 与 `sigmoid`,支持有/无 affine weight、归一化前/后门控和多组归一化。输入、gate、weight 的形状、设备、dtype、末维连续性和 group 可整除性在 Python wrapper 中检查。公开接口只新增一个 T3 算子入口;NineToothed `Tensor` 的 `strides` 是可选参数,旧调用保持兼容。 + +## 4. 修改内容与动机 + +### 4.1 A 部分:NineToothed 算子实现 + +涉及文件: + +- `src/ntops/kernels/rms_norm_gated.py` +- `src/ntops/torch/rms_norm_gated.py` +- `src/ntops/kernels/__init__.py` +- `src/ntops/torch/__init__.py` + +主要修改: + +1. 将 gate 激活、门控乘法、组内平方和归约、RMS 归一化和 affine 缩放表达为单个 NineToothed application,避免物化完整中间张量。 +2. 输入先映射为 `[-1, group_size]` 的逻辑二维视图,一行对应一个独立归一化组;组间并行,组内沿隐藏维向量化和归约。 +3. 输入、gate 和 weight 在寄存器计算前转为 FP32,平方和与归一化用 FP32 累加,最后写回输入 dtype。 +4. 为 `activation × norm_before_gate × has_weight` 生成语义明确的静态变体,避免运行期在 kernel 内分支。 +5. 对最常用的“二维输入 + 单组”路径保留原二维张量和一维 weight,不创建 stride-zero expanded view,也不做多余 reshape。 +6. wrapper 提供必要的参数校验,并把实际 shape/stride 传给编译器建立静态契约。 + +### 4.2 B 部分:编译器与后端 + +下表只列 T3 实际依赖且有测试覆盖的能力,不把 `gather` 等 T4 能力归因于 T3。最终 ntops PR 的 T3 commit 只保留 RMSNorm 路径。 + +| 层次 | 修改 | T3 作用 | 架构归属 | +|---|---|---|---| +| 通用 Tensor/运行时 | 可选静态 source strides,并在 launch 时校验 | 删除重复 stride 参数和动态索引准备,同时防止错误复用 | 通用编译器层 | +| 通用布局分析 | 静态区间证明与 mask 简化 | 完整 tile 可去掉冗余边界 predicate,尾块仍保留 mask | 通用编译器层 | +| 统一 SSA emitter | 合法静态 application block、标量按值传递、静态循环策略 | 让 gate、归约、归一化和缩放在一个程序中生成;`eps` 不被误当指针 | 通用编译器层 | +| Triton materializer | 预验证 no-alias invocation plan 缓存 | wrapper 已检查契约后跳过重复 Python ABI、alias 和绑定准备 | 通用运行时能力,私有入口 | +| Triton 能力合法化 | 对 `backend=cuda && warp_size=64` 固定合法 `num_stages=1` | 适配 CoreX 现有软件流水能力 | 硬件能力驱动的后端逻辑 | +| HIP target 恢复 | 厂商 Triton ABI 不匹配时从 PyTorch 读取 gfx/warp target | 保证 BW 可编译,不改变数学语义 | HIP 后端兼容逻辑 | + +预验证 launch 是私有能力 `_launch_prevalidated_noalias`。公开 kernel handle 仍执行完整参数、layout 和 alias 校验;只有 T3 wrapper 在每次调用已经验证 shape、stride、dtype、device,且 output 为新分配不别名张量时才使用快速路径。若后端不提供该能力,wrapper 自动回退到公开 handle。 + +## 5. 关键设计取舍 + +### 5.1 数据布局与并行策略 + +- 逻辑布局为“行/组 × group_size”;每行独立,天然无跨程序同步。 +- 一次读取 input、gate 和可选 weight;中间值留在程序内,不写全局中间张量。 +- weight 在单组常见路径保持一维广播;多组路径才生成对应的逻辑 group view。 +- 静态 shape/stride 只在 wrapper 能证明契约时启用;不满足契约的输入在 launch 前报错,而不是静默产生错误索引。 + +### 5.2 融合与数值精度 + +所有非线性和 RMS 统计在 FP32 中执行,降低 BF16/FP16 平方和的误差风险。测试容差为 `rtol=2e-2, atol=2e-2`,与低精度推理算子的常见误差范围一致。最终输出保持输入 dtype。 + +### 5.3 通用优化与平台特化边界 + +静态布局证明、统一 SSA application block、标量 ABI 和预验证 runtime 都位于通用层,可被其他逐行归约/融合算子复用。平台相关逻辑不按赛题 shape 或 case ID 分支,而是依据 backend、warp size、gfx 架构和目标能力做合法化。实现没有公开 shape 答案表、隐藏 case 读取或外部闭源 kernel 绕行。 + +## 6. 实验环境与方法 + +| 项目 | 海光 | 天数智芯 | +|---|---|---| +| GPU | BW,64 GiB,gfx936,warp 64 | 天垓150 / BI-V150,32 GiB,warp 64 | +| Python | 3.10.12 | 3.12.3 | +| PyTorch | 2.5.1,HIP 6.3.25405 | 2.7.1,CUDA-compatible 10.2 | +| Triton | 3.1 | 3.1.0 | +| vLLM | 0.9.2 | 0.11.2 | +| 基线入口 | `Mixer2RMSNormGated.forward_cuda` | `rms_norm_gated` direct op | + +T3 没有语义等价的公开 PyTorch 融合接口,且赛题对齐 vLLM `RMSNormGated`,因此按照“优先 PyTorch、其次 vLLM”采用 vLLM。PyTorch eager 只用于正确性 reference,不进入计时。 + +每个 case 交替预热 50 次,随后进行 7 轮配对计时,每轮每条路径执行 500 次;轮间交替基线与提交实现的先后顺序,使用 GPU 同步包围 wall-clock 区间,报告 7 个样本的中位数。 + +## 7. 正确性与性能结果 + +### 7.1 正确性与回归 + +| 平台 | T3 算子测试 | T3 相关编译器回归 | 结果 | +|---|---:|---:|---| +| 海光 BW | 32 passed | 94 passed | 通过 | +| 天垓150 | 32 passed | 94 passed | 通过 | + +32 个 T3 case 来自 4 个 shape/group 组合 × 2 种 activation × 2 种门控顺序 × 2 种 affine 配置。相关编译器回归覆盖统一 SSA lowering、Triton 调优/运行时、标量参数、静态 bounds 和静态 strides。 + +### 7.2 A+B speedup + +| 平台 | Shape | vLLM ms | A+B ms | speedup | +|---|---:|---:|---:|---:| +| 海光 BW | 64×128 | 0.084763 | 0.067287 | **1.2597×** | +| 海光 BW | 128×512 | 0.084719 | 0.068306 | **1.2403×** | +| 海光 BW | 16×4096 | 0.085418 | 0.066326 | **1.2878×** | +| 天垓150 | 64×128 | 0.094104 | 0.070783 | **1.3295×** | +| 天垓150 | 128×512 | 0.097470 | 0.071967 | **1.3544×** | +| 天垓150 | 16×4096 | 0.097530 | 0.072408 | **1.3469×** | + +- 海光算术平均:**1.2626×**; +- 天数算术平均:**1.3436×**; +- 六个 case 等权平均:**1.3031×**; +- 最低 case:**1.2403×**,六个 case 均大于 1。 + +性能结果见上表。原始日志目录: + +- 海光:`/t3/bw/` +- 天数:`/t3/tiangai150/` + +## 8. 消融与优化来源 + +为隔离 B 部分的运行时贡献,保持同一生成 kernel、同一输入和固定 output,仅比较公开完整检查 launch 与 wrapper 可使用的预验证 no-alias launch。该实验不与 vLLM 比较,因此不作为赛事 speedup。 + +| 平台 | Shape | 完整检查 ms | 预验证 ms | 运行时微消融 speedup | +|---|---:|---:|---:|---:| +| 海光 BW | 64×128 | 0.043819 | 0.031003 | 1.4134× | +| 海光 BW | 128×512 | 0.044870 | 0.030885 | 1.4528× | +| 海光 BW | 16×4096 | 0.044710 | 0.031623 | 1.4138× | +| 天垓150 | 64×128 | 0.056039 | 0.043395 | 1.2914× | +| 天垓150 | 128×512 | 0.055744 | 0.043033 | 1.2954× | +| 天垓150 | 16×4096 | 0.056353 | 0.043113 | 1.3071× | + +这说明对几十微秒级融合 kernel,重复 Python ABI/绑定/alias 检查是可观开销,预验证运行时能力对端到端结果有直接贡献。静态布局、融合表达和运行时优化之间存在耦合。 + +## 9. 工程质量、兼容性与适用边界 + +### 9.1 已完成 + +- T3 语义测试覆盖高维输入、分组、两种激活、两种门控顺序和有/无 weight; +- 统一 SSA、静态布局、标量 ABI、runtime cache 和 target 合法化有独立回归; +- 两个平台使用相同关键源码哈希、独立缓存和统一一键脚本; +- benchmark 同进程配对、交替顺序并记录完整样本; +- 公共接口变化最小,快速 launch 保持私有并有安全回退; +- 没有复制第三方 kernel;vLLM 只作为未修改 baseline,PyTorch 只用于张量与正确性参考。 + +### 9.2 已知限制 + +1. 当前正式性能预评测只有 3 个公开代表 shape,未知隐藏 case 可能包含不同 group_size、dtype、stride 或 batch 结构。 +2. 正确性主矩阵使用 BF16,性能矩阵使用 BF16 + swish + `norm_before_gate=False` + affine weight;FP16/FP32及更广参数范围仍应扩测。 +3. 相关编译器回归为 94 个定向测试。 +4. 预验证快速路径要求 wrapper 每次重验 shape/stride/dtype/device,并保证新 output 不别名;不能由任意调用者无条件使用。 +5. BW vLLM 0.9.2 镜像重启后可能同时暴露 ROCm 与 NVML 兼容发现。基准脚本只在平台解析阶段屏蔽伪 CUDA 计数,选择正确 ROCm 平台;该处理不修改或替换被计时的 vLLM kernel。 + +## 10. 一键复现 + +完整命令、目录约束、输出文件和判定方法见 `docs/KERNELSWIFT_T3_REPRODUCE.md`。核心命令为: + +```bash +cd /path/to/ntops +bash scripts/run_kernelswift_t3.sh \ + --ninetoothed-dir /path/to/ninetoothed \ + --output-dir /path/to/fresh/results \ + --mode all +``` diff --git a/docs/KERNELSWIFT_T3_REPRODUCE.md b/docs/KERNELSWIFT_T3_REPRODUCE.md new file mode 100644 index 0000000..874e47b --- /dev/null +++ b/docs/KERNELSWIFT_T3_REPRODUCE.md @@ -0,0 +1,134 @@ +# KernelSwift T3 一键构建与评测说明 + +## 1. 适用范围 + +本文档用于复现 T3 Gated RMSNorm 的统一 A+B 作品: + +- A 部分:`ntops` 中的 NineToothed 算子表达、布局、并行与融合实现; +- B 部分:`ninetoothed` 中的通用 SSA lowering、静态布局分析、Triton 物化与低开销运行时路径; +- 正式性能基线:未修改的 vLLM `RMSNormGated` 调用链; +- PyTorch eager 只生成独立正确性参考值,不参与 latency 或 speedup。 + +统一复现版本为本 PR 最终 `ntops` HEAD 与 `ninetoothed@6b79203`。T3 复用 `18c50cd`、`5b6abe8` 引入的通用 SSA、静态布局和低开销运行时能力,最终正确性、回归和性能记录均使用 `6b79203`。 + +## 2. 目录和环境要求 + +将两个仓库放在同一级目录: + +```text +workspace/ +├── ntops/ +└── ninetoothed/ +``` + +要求: + +- Python 3.10 或更高版本; +- 厂商镜像自带并能使用的 PyTorch、Triton 和 vLLM; +- 一个可见的海光 BW 或天数智芯 GPU; +- `pytest`; +- `ntops` 使用本 PR 最终 HEAD,`ninetoothed` 固定为 `6b79203`。 + +脚本通过 `PYTHONPATH` 直接使用源码,不会用 `pip` 覆盖厂商 PyTorch、Triton 或 vLLM。 + +## 3. 一条命令完成构建与评测 + +NineToothed/Triton 在第一次调用时 JIT 构建 kernel,因此首次正确性测试同时完成构建验证。每次正式复现都应指定全新的输出目录,脚本会为正确性、编译器回归、正式性能和消融实验分别创建独立缓存。 + +```bash +cd /path/to/workspace/ntops +bash scripts/run_kernelswift_t3.sh \ + --ninetoothed-dir /path/to/workspace/ninetoothed \ + --output-dir /path/to/results/t3_run_001 \ + --mode all +``` + +以下命令均应在远程 Linux GPU 主机执行,`/path/to/remote/workspace` 仅表示该主机上的工作目录。 + +### 海光 BW/DTK 25.04 示例 + +```bash +source /opt/dtk-25.04.2/env.sh +cd /path/to/remote/workspace/ntops +bash scripts/run_kernelswift_t3.sh \ + --ninetoothed-dir /path/to/remote/workspace/ninetoothed \ + --output-dir /path/to/remote/workspace/results/t3_final_bw \ + --mode all +``` + +### 天数智芯天垓150示例 + +```bash +export KS_ROOT=/path/to/remote/workspace +cd "${KS_ROOT}/ntops" +bash scripts/run_kernelswift_t3.sh \ + --ninetoothed-dir "${KS_ROOT}/ninetoothed" \ + --output-dir "${KS_ROOT}/results/t3_final_biv150" \ + --mode all +``` + +## 4. 可单独执行的阶段 + +```bash +# 仅记录环境、版本和关键文件 SHA256 +bash scripts/run_kernelswift_t3.sh --mode environment + +# 32 个 T3 语义组合(当前测试矩阵) +bash scripts/run_kernelswift_t3.sh --mode correctness + +# T3 所依赖的通用 SSA、布局、运行时和后端回归 +bash scripts/run_kernelswift_t3.sh --mode regression + +# vLLM 基线对 A+B 统一实现的正式 speedup +bash scripts/run_kernelswift_t3.sh --mode benchmark + +# 仅隔离已检查 launch 与预验证低开销 launch 的运行时微消融 +bash scripts/run_kernelswift_t3.sh --mode ablation +``` + +也可以直接执行核心命令: + +```bash +export PYTHONPATH=/path/to/ntops/src:/path/to/ninetoothed/src:${PYTHONPATH:-} +export NINETOOTHED_CACHE_DIR=/path/to/a/new/cache +python -m pytest /path/to/ntops/tests/test_rms_norm_gated.py -q +python /path/to/ntops/benchmarks/benchmark_rms_norm_gated.py +``` + +## 5. 输出文件 + +`--mode all` 生成: + +```text +/ +├── environment.log +├── correctness.log +├── compiler_regression.log +├── benchmark.log +├── runtime_ablation.log +├── cache_correctness/ +├── cache_regression/ +├── cache_benchmark/ +└── cache_ablation/ +``` + +判定标准: + +- `correctness.log` 末尾为全部通过; +- `compiler_regression.log` 无失败; +- `speedup = baseline_ms / submission_ms`; +- 每个正式 case 的 `speedup > 1`。 + +`runtime_ablation.log` 是 B 部分运行时开销的微消融。 + +## 6. 计时方法 + +- 每个 shape 先交替预热基线和提交实现 50 次; +- 每轮各执行 500 次; +- 共 7 轮,基线与提交实现交替先后顺序; +- 每条路径在计时区间前后执行 GPU 同步; +- 报告 7 轮 wall-clock 均值样本的中位数; +- 两条路径使用同一进程、同一输入、同一 dtype 和同一数学语义; +- 计时前分别对基线、提交实现和独立 PyTorch 参考值执行正确性检查。 + +BW 的 vLLM 0.9.2 通过 `Mixer2RMSNormGated.forward_cuda` 调用未修改算子;天垓150的 vLLM 0.11.2 直接调用其公开 `rms_norm_gated` op。BW 镜像若同时暴露 ROCm SMI 和 NVML 兼容层,基准脚本只在 vLLM 平台发现阶段屏蔽伪 CUDA 设备计数,确保选择 ROCm;vLLM kernel 和计时路径本身不做修改。 diff --git a/docs/KERNELSWIFT_T4_REPORT.md b/docs/KERNELSWIFT_T4_REPORT.md new file mode 100644 index 0000000..5eac370 --- /dev/null +++ b/docs/KERNELSWIFT_T4_REPORT.md @@ -0,0 +1,305 @@ +# KernelSwift T4:MLA RoPE 与压缩 KV Cache 写入融合算子—编译协同优化 + +**报告日期**:2026-08-31 + +**赛题**:T4 MLA RoPE 与压缩 KV Cache 写入融合 + +**作品形态**:A(NineToothed 算子)+ B(通用编译器/平台后端)统一构建与评测 + +**当前状态**:统一源码使用本 PR 的最终 `ntops` HEAD 与 `ninetoothed@6b79203`,已在海光 BW 与天数智芯天垓150通过 T4 正确性、相关编译器回归和 12 个 vLLM 性能 case;全部 `speedup > 1` + +## 1. 摘要 + +T4 面向 MLA 解码和长上下文推理,将 query/key 的 RoPE 与压缩 KV Cache 写入融合为一个 NineToothed 算子。公开接口保持为: + +```python +ntops.torch.mla_rope_cache( + q_pe, + k_pe, + kv_c, + positions, + cos_sin_cache, + slot_mapping, + kv_cache, +) +``` + +`q_pe` 和 `kv_cache` 原地更新,数学语义、输入输出和原接口保持不变。实现没有读取 case 标识或隐藏数据,没有建立公开 shape 答案表,也没有绕过 NineToothed 编译链调用未申报闭源算子。 + +当前统一 A+B 结果为: + +- 天数旧版天垓150:T4 正确性 `26 passed`、编译器/后端定向回归 `122 passed`,6 个正式 case 全部 + `speedup > 1`,平均 speedup `1.5766×`; +- 海光 BW DTK25.04:T4 正确性 `26 passed`、编译器/后端定向回归 `122 passed`,6 个正式 case 全部 + `speedup > 1`,平均 speedup `2.199483×`; +- 两个平台均使用最终统一提交完成独立结果目录和 SHA-256 记录; + +### 1.1 代码版本与复现锚点 + +结果对应本次 PR 中的提交链。以下记录官方基线 commit、本题提交和统一验证锚点,便于从 PR 历史复现。 + +| 仓库 | 官方基线 commit | 本题提交/验证锚点 | +|---|---|---| +| ntops | `9ae4166` | 本 PR 的 T4 题目 commit;统一验证使用本 PR 最终 HEAD | +| ninetoothed | `b77f930` | T4 MLA cache 后端支持 `0dbe0e9`;统一验证锚点 `6b79203` | + +## 2. 修改内容与动机 + +### 2.1 A 部分:NineToothed 算子实现 + +| 文件 | 修改 | 动机 | +|---|---|---| +| `src/ntops/kernels/mla_rope_cache.py` | 保留低启动成本的融合 scalar-lane 路径 | decode 和短 prefill 主要受 launch 开销影响 | +| `src/ntops/kernels/mla_rope_cache_pair.py` | 新增 one-thread-per-RoPE-pair 布局 | 大 query 域复用同一 pair 的 cos/sin,并减少线程数 | +| `src/ntops/torch/mla_rope_cache_pair.py` | 构造 stride-2 even/odd view,缓存稳定 launch | 避免完整中间张量和重复 ABI 准备 | +| `src/ntops/torch/mla_rope_cache.py` | 按后端能力和工作量统一分派 | 同时保护小 workload 启动延迟和大 workload 吞吐 | + +pair 路径只在以下条件成立时启用: + +```text +NINETOOTHED_BACKEND=cuda +q_pe.numel() >= 2^18 +q_pairs_per_token >= cache_width +``` + +条件来自后端能力、query 工作量和 cache 行宽,不包含 case 编号、公开 shape 身份或预计算答案。其他情况自动回退到原始融合路径。 + +### 2.2 B 部分:通用 SSA 与目标后端 + +| 文件 | 层次 | 修改 | +|---|---|---| +| `ninetoothed/backends/emitters/ssa.py` | 通用 SSA emitter | 对 C-style masked store 做 producer sinking,并统一 mixed-dtype `where` 分支类型 | +| `ninetoothed/backends/materializers/cuda.py` | CUDA 目标后端 | 预校验后缓存 device pointer 和标量 ABI,每次调用只重新获取当前 PyTorch stream 并启动 kernel | +| `ninetoothed/backends/materializers/cuda.py` | CoreX 相关后端处理 | 检测 `torch.corex` 的外部 kernel 空 CUDA Graph 行为,回退到正确的快速直接 launch | +| `ninetoothed/backends/toolchain.py` | 通用工具链接口 | 允许通过环境变量配置 CUDA 兼容编译器和源语言,使 CoreX 使用 clang/ivcore | + +通用分析放在 SSA emitter,硬件运行时差异放在 CUDA materializer,编译器路径通过通用环境接口配置。代码没有把某个 T4 shape 直接写进海光或天数后端。 + +## 3. 方案与实现 + +### 3.1 融合数据流 + +单次 kernel 完成: + +1. 根据 `positions` 加载 token 对应的 cos/sin; +2. 对所有 query heads 执行 interleaved RoPE,并原地写回 `q_pe`; +3. 对单个 key head 执行 RoPE; +4. 将 `kv_c` 与旋转后的 key 拼成压缩 cache 行; +5. 根据 `slot_mapping` 写入目标 block/slot; +6. `slot_mapping=-1` 时屏蔽 cache 写入。 + +旋转后的 key 不落到独立全局中间张量,因此减少一次 kernel launch 和中间内存流量。 + +### 3.2 Pair layout 与别名安全 + +大 workload 将连续的 RoPE 通道拆成两个 stride-2 view: + +```text +q_even = q[..., 0::2] +q_odd = q[..., 1::2] +``` + +一个线程同时负责 even/odd 通道并复用 cos/sin。因为两个输出 view 与输入别名,必须在任一 store 前物化两个输入 SSA 值: + +```python +original_even = q_even + 0.0 +original_odd = q_odd + 0.0 +``` + +否则第二个通道可能重新读取已经更新的第一个通道。动态索引测试和 BF16/FP16 全矩阵验证了该处理。 + +### 3.3 Masked producer sinking + +原 C-style emitter 先生成 store value,再计算/应用 mask。融合 query 与窄 cache 行时,超出 cache 行的 lane 虽不能写入,仍会执行 key、cos/sin load 和算术。 + +修改后先物化 mask,并将纯 producer slice 与 store 一起放进同一条件块。该优化位于通用 SSA emitter,对所有具有同类 masked-store 结构的 C-style 后端生效,不依赖 T4 名称。 + +### 3.4 低开销 CUDA launch + +推理循环通常重复使用同一批 tensor 对象。公开入口首次调用仍执行完整契约检查,随后绑定并缓存: + +- tensor device pointer; +- 标量 ABI 转换; +- keepalive 对象; +- device index。 + +每次调用只查询当前 PyTorch stream 并调用生成的 host launcher,既保留 stream 语义,也消除 Python ABI 热路径开销。若后端不提供绑定能力,则回退到原 checked launch。 + +CoreX 4.4 对外部 CUDA kernel 的 graph capture 会报告成功但生成空图,因此不能把空 graph replay 当作正确执行。该平台通过 `torch.corex` 能力标记禁用外部 graph replay,改用上述预校验快速 launch。 + +### 3.5 CoreX 工具链 + +天数旧版镜像同时存在 NVIDIA 兼容 `nvcc 10.2` 和 CoreX 原生 clang。系统 `nvcc` 可能返回零但不产生可加载 `.so`,因此统一构建显式使用: + +```text +CUDA_HOME=/usr/local/corex +NINETOOTHED_CUDA_COMPILER=/usr/local/corex/bin/clang++ +NINETOOTHED_CUDA_LANGUAGE=ivcore +NINETOOTHED_BACKEND=cuda +``` + +代码只新增通用可配置接口,不把 `/usr/local/corex` 硬编码进编译器实现。 + +## 4. 实验与效果 + +### 4.1 天数环境 + +```text +GPU:Iluvatar BI-V150(32 GiB) +IX-ML:4.4.0 +PyTorch:2.7.1 +Triton:3.1.0 +vLLM:0.11.2 +CoreX clang:18.1.8(IX-ML 4.4.0) +``` + +### 4.2 正式基线与计时 + +未修改 vLLM 基线: + +```text +rope.forward_cuda + ops.concat_and_cache_mla +``` + +NineToothed 提交路径: + +```text +ntops.torch.mla_rope_cache +``` + +两条路径使用相同输入、BF16 dtype 和数学语义,并在计时前分别检查结果。计时交替预热 100 次,每轮 1000 次,共 9 轮,分别报告 wall-clock 样本中位数: + +| Case | vLLM (ms) | NineToothed (ms) | 正式 speedup | +|---|---:|---:|---:| +| seq1 / 32 heads | 0.010055 | 0.005535 | 1.8165× | +| seq16 / 32 heads | 0.010477 | 0.006512 | 1.6090× | +| seq128 / 32 heads | 0.015783 | 0.008670 | 1.8204× | +| seq1 / 128 heads | 0.010707 | 0.005885 | 1.8194× | +| seq16 / 128 heads | 0.011055 | 0.009491 | 1.1648× | +| seq128 / 128 heads | 0.017988 | 0.014631 | 1.2295× | + +以上均为相对 vLLM 的正式 speedup,不是 eager 对比。 + +### 4.3 分派消融 + +同一台天数主机上的设计空间对照: + +| Case | 强制 scalar | 强制 pair | 统一分派 | +|---|---:|---:|---:| +| seq1 / 32 heads | 1.8174× | 0.8190× | 1.8482× | +| seq16 / 32 heads | 1.6044× | 0.8481× | 1.5850× | +| seq128 / 32 heads | 1.1917× | 1.2676× | 1.7932× | +| seq1 / 128 heads | 1.8336× | 0.8433× | 1.8000× | +| seq16 / 128 heads | 1.1458× | 0.9005× | 1.1668× | +| seq128 / 128 heads | 0.8693× | 1.2126× | 1.2228× | + +强制 pair 会伤害小 workload,强制 scalar 在最大 case 低于 1。统一分派使 6 个 case 全部大于 1,说明双路径是必要的工程取舍。 + +### 4.4 Launch 消融 + +32-head 路径禁用可复用快速 launch 后,三个 case 的 speedup 分别为: + +```text +0.2435× / 0.2453× / 0.3751× +``` + +这说明微秒级融合算子的主要瓶颈之一是运行时 launch/ABI 开销,B 部分的预校验绑定是端到端收益的关键来源。 + +### 4.5 海光 BW DTK25.04 最终统一复测 + +最终统一提交在 BW DTK25.04 的全新结果目录中完成复测: + +```text +ntops@PR-final-HEAD +ninetoothed@6b79203 +``` + +环境为 BW/gfx936、Python 3.10.12、PyTorch 2.5.1、HIP 6.3.25405、Triton 3.1、vLLM 0.9.2。 +结果如下: + +| Case | vLLM (ms) | NineToothed (ms) | 正式 speedup | +|---|---:|---:|---:| +| seq1 / 32 heads | 0.023896 | 0.010660 | 2.2417× | +| seq16 / 32 heads | 0.023658 | 0.011924 | 1.9842× | +| seq128 / 32 heads | 0.047728 | 0.010604 | 4.5007× | +| seq1 / 128 heads | 0.022710 | 0.015884 | 1.4298× | +| seq16 / 128 heads | 0.022176 | 0.015493 | 1.4313× | +| seq128 / 128 heads | 0.035463 | 0.022038 | 1.6092× | + +本轮 T4 正确性为 `26 passed in 46.08s`,NineToothed 编译器回归为 +`122 passed in 193.52s`,ntops 和 ninetoothed 的 Ruff 均通过。6 个正式 case 全部 +`correctness=pass`,BW 平台 speedup 算术平均为 `2.199483×`。 + +完整证据位于: + +```text +/t4/bw/ +``` + +### 4.6 双平台性能汇总 + +天数旧版天垓150的 6 个正式 case 平均 speedup 为 `1.5766×`,BW DTK25.04 的 6 个正式 case +平均 speedup 为 `2.199483×`。 + +## 5. 工程质量与适用边界 + +### 5.1 测试覆盖 + +T4 `26 passed` 覆盖: + +- BF16/FP16; +- INT32/INT64 positions 和 slot mapping; +- 1/16/128 tokens; +- 32/128 query heads; +- padding slot; +- 重复调用和动态运行时索引。 + +NineToothed `122 passed` 覆盖 SSA lowering、masked producer sinking、混合 dtype `where`、运行时缓存、标量 ABI、静态布局/stride、backend registry 和 Triton runtime。T4 直接相关代码 ruff 全部通过。 + +### 5.2 接口与架构边界 + +- 公共算子接口和数学语义未修改; +- pair 路径是内部实现,不新增用户必传参数; +- producer sinking 位于通用 SSA 层; +- 可配置编译器/语言位于通用 toolchain; +- launch 优化和 CoreX graph 差异位于 CUDA materializer; +- 平台选择基于后端能力,不基于赛题 case 身份。 + +### 5.3 已知限制 + +- pair 路径要求 CUDA 兼容后端、连续输入、偶数 RoPE width 和 FP16/BF16; +- 非匹配 workload 回退原 scalar 融合路径; +- 当前 CoreX 阈值来自天数实测,换硬件后应通过 profile 重新验证; +- BW 最终复测使用 DTK25.04 的 Triton 路径;DTK26.04 的默认环境缺少 Triton/vLLM,未作为本次正式 + BW 结果环境; + +### 5.4 第三方来源 + +实现未复制第三方 kernel 源码。vLLM 仅作为公开接口语义和正式性能基线,PyTorch 用于 tensor/runtime 接口;各自许可证随其原项目。 + +## 6. 复现与结果证据 + +一键入口: + +```bash +bash benchmarks/run_kernelswift_t4.sh /path/to/fresh/result-dir +``` + +详细说明见 `docs/KERNELSWIFT_T4_REPRODUCE.md`。本轮天数原始日志位于: + +```text +/t4/tiangai150/ +``` + +关键文件位于 `/t4/tiangai150/`,包括 `FORMAL_SPEEDUP_SUMMARY`、`SHA256SUMS` +和 `logs/`。 + +BW DTK25.04 最终统一复测结果位于: + +```text +/t4/bw/ +``` + +其中 `FORMAL_SPEEDUP_SUMMARY`、`SHA256SUMS` 和 `logs/` 分别记录正式 speedup、参与测试的 +源码摘要以及环境、正确性、编译器回归和两个 head 配置的完整输出。复现时必须为每次运行 +指定新的结果目录;脚本会拒绝复用已有目录和缓存。 diff --git a/docs/KERNELSWIFT_T4_REPRODUCE.md b/docs/KERNELSWIFT_T4_REPRODUCE.md new file mode 100644 index 0000000..dbee69e --- /dev/null +++ b/docs/KERNELSWIFT_T4_REPRODUCE.md @@ -0,0 +1,191 @@ +# KernelSwift T4 一键构建与评测说明 + +## 1. 适用范围 + +本说明用于 T4“MLA RoPE 与压缩 KV Cache 写入融合”的统一 A+B 构建和评测。 + +正式性能基线为未修改 vLLM 的: + +```text +RoPE.forward_cuda + concat_and_cache_mla +``` + +脚本不修改 vLLM、测试、计时逻辑或基线代码,不报告 eager 对比。 + +统一复现版本为本 PR 最终 `ntops` HEAD 与 `ninetoothed@6b79203`。T4 使用的 MLA cache 后端支持由 `0dbe0e9` 引入,最终正确性、回归和性能记录均使用 `6b79203`。 + +## 2. 目录和依赖 + +将两个仓库放在同一父目录: + +```text +workspace/ +├── ninetoothed/ +└── ntops/ +``` + +远程环境需要: + +- 可用的海光或天数智芯 GPU; +- Python、PyTorch、vLLM、Triton、pytest 和 ruff; +- 本 PR 最终 `ntops` HEAD 与 `ninetoothed@6b79203`; +- 天数 CoreX 环境中的 `/usr/local/corex/bin/clang++`,或通过环境变量指定等价编译器。 + +仓库根目录的 `.gitattributes` 将 shell 脚本声明为 `eol=lf`。在 Windows 上编辑或打包后,应确认 +`benchmarks/run_kernelswift_t4.sh` 仍为 LF 行尾,以保证 Linux Bash 能直接执行。 + +NineToothed 为 JIT 编译。首次正确性调用会从 SSA 生成并编译目标 kernel,因此不需要绕过九齿编译链调用外部闭源算子。 + +## 3. 一键执行 + +在远程 GPU 主机执行: + +```bash +cd /path/to/workspace/ntops +bash benchmarks/run_kernelswift_t4.sh /path/to/fresh/t4-result-dir +``` + +如果两个仓库不是同级目录: + +```bash +cd /path/to/ntops +NINETOOTHED_ROOT=/path/to/ninetoothed \ + bash benchmarks/run_kernelswift_t4.sh /path/to/fresh/t4-result-dir +``` + +脚本拒绝复用已存在的结果目录,以避免不同赛题、A/B 构建或不同平台共享生成文件和缓存。 + +### 天数 CoreX + +脚本检测到 `torch.corex` 后默认配置: + +```bash +NINETOOTHED_BACKEND=cuda +CUDA_HOME=/usr/local/corex +NINETOOTHED_CUDA_COMPILER=/usr/local/corex/bin/clang++ +NINETOOTHED_CUDA_LANGUAGE=ivcore +``` + +如正式镜像路径不同,可在命令前覆盖这些变量。不要退回系统 NVIDIA `nvcc`;旧版天数镜像中的兼容 `nvcc 10.2` 可能返回成功但不生成 CoreX 可加载的 `.so`。 + +### 海光 BW + +海光默认使用 NineToothed Triton 后端: + +```bash +NINETOOTHED_BACKEND=triton \ + bash benchmarks/run_kernelswift_t4.sh /path/to/fresh/bw-t4-result-dir +``` + +## 4. 脚本执行内容 + +脚本依次完成: + +1. GPU 分配与同步健康检查; +2. 记录 Python、PyTorch、Triton、vLLM、设备和平台信息; +3. 对 T4 直接代码运行 ruff; +4. 运行 T4 26 项正确性矩阵; +5. 运行 120 项 SSA、materializer、布局和运行时定向回归; +6. 对 32 和 128 query heads 各运行 3 个正式 case; +7. 保存统一代码 SHA-256 和正式 speedup 汇总。 + +关键输出: + +```text +/ +├── FORMAL_SPEEDUP_SUMMARY +├── SHA256SUMS +├── ninetoothed-cache/ +└── logs/ + ├── environment.log + ├── correctness.log + ├── compiler_regression.log + ├── ruff_ntops.log + ├── ruff_ninetoothed.log + ├── benchmark_heads32.log + └── benchmark_heads128.log +``` + +## 5. 正确性判定 + +正式 benchmark 在计时前分别校验 vLLM 路径和 NineToothed 路径。测试矩阵额外覆盖: + +- BF16 和 FP16; +- `positions`/`slot_mapping` 的 INT32 和 INT64; +- 1、16、128 tokens; +- 32、128 query heads; +- 16、64 block size; +- `slot_mapping=-1` 的 padding 语义; +- 重复调用时动态更新 position/slot,防止运行时索引被错误固化。 + +只有正确性、编译器回归和全部正式 case 均通过,结果才可写入报告。 + +## 6. 性能计算 + +每个 case 使用: + +```text +speedup = vLLM_latency / NineToothed_latency +``` + +计时过程为交替预热 100 次、每轮 1000 次、共 9 轮,分别取两条路径 wall-clock 样本中位数。脚本运行的是默认统一分派路径,不强制实验性的 scalar-only 或 pair-only 实现。 + +## 7. 本轮天数复现结果 + +验证目录: + +```text +/t4/tiangai150 +``` + +环境:Iluvatar BI-V150、IX-ML 4.4.0、PyTorch 2.7.1、Triton 3.1.0、vLLM 0.11.2、CoreX clang 18.1.8。 + +结果: + +```text +T4 correctness: 26 passed +backend regression: 122 passed +ruff: passed +``` + +6 个正式 case 的最新完整复测均 `correctness=pass` 且 `formal_speedup > 1`。原始日志为: + +```text +/t4/tiangai150/FORMAL_SPEEDUP_SUMMARY +``` + +## 8. 本轮海光 BW DTK25.04 复测结果 + +验证目录: + +```text +/t4/bw +``` + +环境:BW/gfx936、Python 3.10.12、PyTorch 2.5.1、HIP 6.3.25405、Triton 3.1、vLLM 0.9.2。 +本轮使用本 PR 最终统一验证锚点 `ntops` HEAD 与 `ninetoothed@6b79203`,baseline 仍为未修改的 +vLLM `RoPE.forward_cuda + concat_and_cache_mla`。 + +```text +T4 correctness: 26 passed in 46.08s +compiler regression: 122 passed in 193.52s +ruff: passed +``` + +六个正式 case 均为 `correctness=pass`: + +| Case | vLLM ms | NineToothed ms | formal speedup | +|---|---:|---:|---:| +| seq1 / 32 heads | 0.023896 | 0.010660 | 2.2417× | +| seq16 / 32 heads | 0.023658 | 0.011924 | 1.9842× | +| seq128 / 32 heads | 0.047728 | 0.010604 | 4.5007× | +| seq1 / 128 heads | 0.022710 | 0.015884 | 1.4298× | +| seq16 / 128 heads | 0.022176 | 0.015493 | 1.4313× | +| seq128 / 128 heads | 0.035463 | 0.022038 | 1.6092× | + +BW 六个 case 的 speedup 算术平均为 `2.199483×`。 + +## 9. 双平台结果汇总 + +天数旧版天垓150和海光 BW DTK25.04 均完成同一 T4 测试矩阵。天数六个 case 的平均 speedup +为 `1.5766×`,BW 六个 case 的平均 speedup 为 `2.199483×`。 diff --git a/scripts/run_kernelswift_t1.sh b/scripts/run_kernelswift_t1.sh new file mode 100644 index 0000000..573e417 --- /dev/null +++ b/scripts/run_kernelswift_t1.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/run_kernelswift_t1.sh [options] + +Options: + --ninetoothed-dir PATH NineToothed source tree (default: ../ninetoothed) + --output-dir PATH Logs, caches, and CSV output directory + --mode MODE all|environment|lint|correctness|regression|benchmark + -h, --help Show this help + +The script imports both repositories directly through PYTHONPATH. It does not +replace the vendor PyTorch, Triton, or vLLM installation. Use a new output +directory for every clean evaluation so JIT products remain isolated. +EOF +} + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +NTOPS_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +NINETOOTHED_DIR="${NINETOOTHED_DIR:-${NTOPS_DIR}/../ninetoothed}" +OUTPUT_DIR="${OUTPUT_DIR:-${NTOPS_DIR}/t1_results/run_$(date +%Y%m%d_%H%M%S)}" +MODE="all" + +while [[ $# -gt 0 ]]; do + case "$1" in + --ninetoothed-dir) + NINETOOTHED_DIR="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --mode) + MODE="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +case "${MODE}" in + all|environment|lint|correctness|regression|benchmark) ;; + *) + echo "Unsupported mode: ${MODE}" >&2 + exit 2 + ;; +esac + +NINETOOTHED_DIR="$(cd -- "${NINETOOTHED_DIR}" && pwd)" +mkdir -p "${OUTPUT_DIR}" +OUTPUT_DIR="$(cd -- "${OUTPUT_DIR}" && pwd)" + +export PYTHONPATH="${NTOPS_DIR}/src:${NINETOOTHED_DIR}/src:${PYTHONPATH:-}" + +record_repository() { + local name="$1" + local directory="$2" + + echo "${name}_path=${directory}" + if git -C "${directory}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "${name}_head=$(git -C "${directory}" rev-parse HEAD)" + echo "${name}_branch=$(git -C "${directory}" branch --show-current)" + if [[ -n "$(git -C "${directory}" status --porcelain)" ]]; then + echo "${name}_dirty=true" + else + echo "${name}_dirty=false" + fi + else + echo "${name}_head=unavailable-source-snapshot" + echo "${name}_branch=unavailable-source-snapshot" + echo "${name}_dirty=unknown" + fi +} + +record_environment() { + { + echo "timestamp=$(date --iso-8601=seconds)" + echo "hostname=$(hostname)" + uname -a + record_repository ntops "${NTOPS_DIR}" + record_repository ninetoothed "${NINETOOTHED_DIR}" + python - <<'PY' +import platform + +import torch + +print(f"python={platform.python_version()}") +print(f"torch={torch.__version__}") +print(f"torch_cuda={torch.version.cuda}") +print(f"torch_hip={torch.version.hip}") + +try: + import triton +except Exception as error: # noqa: BLE001 + print(f"triton_error={type(error).__name__}: {error}") +else: + print(f"triton={triton.__version__}") + +try: + import vllm +except Exception as error: # noqa: BLE001 + print(f"vllm_error={type(error).__name__}: {error}") +else: + print(f"vllm={vllm.__version__}") + +print(f"accelerator_available={torch.cuda.is_available()}") +if torch.cuda.is_available(): + properties = torch.cuda.get_device_properties(0) + print(f"accelerator_name={properties.name}") + print(f"accelerator_memory={properties.total_memory}") + print(f"accelerator_warp_size={getattr(properties, 'warp_size', None)}") + print(f"accelerator_arch={getattr(properties, 'gcnArchName', None)}") +PY + printf 'sha256 %s\n' "T1 and compiler files" + sha256sum \ + "${NTOPS_DIR}/src/ntops/kernels/mxfp4_grouped_gemm.py" \ + "${NTOPS_DIR}/src/ntops/torch/mxfp4_grouped_gemm.py" \ + "${NTOPS_DIR}/tests/test_mxfp4_grouped_gemm.py" \ + "${NTOPS_DIR}/benchmarks/benchmark_mxfp4_grouped_gemm_reference.py" \ + "${NTOPS_DIR}/scripts/run_kernelswift_t1.sh" \ + "${NINETOOTHED_DIR}/src/ninetoothed/backends/emitters/ssa.py" \ + "${NINETOOTHED_DIR}/src/ninetoothed/backends/emitters/triton.py" \ + "${NINETOOTHED_DIR}/src/ninetoothed/backends/materializers/triton.py" \ + "${NINETOOTHED_DIR}/src/ninetoothed/frontend/python.py" \ + "${NINETOOTHED_DIR}/src/ninetoothed/frontend/types.py" + } 2>&1 | tee "${OUTPUT_DIR}/environment.log" +} + +prepare_cache() { + local name="$1" + local cache_dir="${OUTPUT_DIR}/cache_${name}" + + mkdir -p "${cache_dir}/ninetoothed" "${cache_dir}/triton" "${cache_dir}/xdg" + export NINETOOTHED_CACHE_DIR="${cache_dir}/ninetoothed" + export TRITON_CACHE_DIR="${cache_dir}/triton" + export XDG_CACHE_HOME="${cache_dir}/xdg" +} + +run_lint() { + { + cd "${NINETOOTHED_DIR}" + ruff format --check \ + src/ninetoothed/backends/emitters/base.py \ + src/ninetoothed/backends/emitters/ssa.py \ + src/ninetoothed/backends/emitters/triton.py \ + src/ninetoothed/backends/materializers/triton.py \ + src/ninetoothed/frontend/python.py \ + src/ninetoothed/frontend/types.py \ + tests/test_ssa_validation.py \ + tests/test_triton_runtime_auto_tuning.py \ + tests/test_triton_emitter_target.py + ruff check \ + src/ninetoothed/backends/emitters/base.py \ + src/ninetoothed/backends/emitters/ssa.py \ + src/ninetoothed/backends/emitters/triton.py \ + src/ninetoothed/backends/materializers/triton.py \ + src/ninetoothed/frontend/python.py \ + src/ninetoothed/frontend/types.py \ + tests/test_ssa_validation.py \ + tests/test_triton_runtime_auto_tuning.py \ + tests/test_triton_emitter_target.py + python scripts/check_contributing_style.py + + cd "${NTOPS_DIR}" + ruff format --check \ + src/ntops/kernels/mxfp4_grouped_gemm.py \ + src/ntops/torch/mxfp4_grouped_gemm.py \ + tests/test_mxfp4_grouped_gemm.py \ + benchmarks/benchmark_mxfp4_grouped_gemm_reference.py + ruff check \ + src/ntops/kernels/mxfp4_grouped_gemm.py \ + src/ntops/torch/mxfp4_grouped_gemm.py \ + tests/test_mxfp4_grouped_gemm.py \ + benchmarks/benchmark_mxfp4_grouped_gemm_reference.py + } 2>&1 | tee "${OUTPUT_DIR}/lint.log" +} + +run_correctness() { + prepare_cache correctness + ( + cd "${NTOPS_DIR}" + python -m pytest tests/test_mxfp4_grouped_gemm.py -q + ) 2>&1 | tee "${OUTPUT_DIR}/correctness.log" +} + +run_regression() { + prepare_cache regression + ( + cd "${NINETOOTHED_DIR}" + python -m pytest -q \ + tests/test_ssa_first_backend_lowering.py \ + tests/test_triton_runtime_auto_tuning.py \ + tests/test_ssa_validation.py \ + tests/test_triton_emitter_target.py + ) 2>&1 | tee "${OUTPUT_DIR}/compiler_regression.log" +} + +run_benchmark() { + prepare_cache benchmark + local benchmark="${NTOPS_DIR}/benchmarks/benchmark_mxfp4_grouped_gemm_reference.py" + local csv="${OUTPUT_DIR}/benchmark.csv" + + ( + cd "${NTOPS_DIR}" + python "${benchmark}" --experts 1 --n 16 --k 32 --counts 1 \ + --warmup 50 --iterations 500 --rounds 9 --csv "${csv}" + python "${benchmark}" --experts 2 --n 32 --k 64 --counts 1,3 \ + --warmup 50 --iterations 500 --rounds 9 --csv "${csv}" + python "${benchmark}" --experts 2 --n 64 --k 128 --counts 5,2 \ + --warmup 50 --iterations 500 --rounds 9 --csv "${csv}" + python "${benchmark}" --experts 4 --n 128 --k 256 --counts 1,0,17,5 \ + --warmup 50 --iterations 500 --rounds 9 --csv "${csv}" + python "${benchmark}" --experts 4 --n 256 --k 256 --counts 1,3,5,7 \ + --warmup 50 --iterations 500 --rounds 9 --csv "${csv}" + ) 2>&1 | tee "${OUTPUT_DIR}/benchmark.log" +} + +record_environment + +case "${MODE}" in + all) + run_lint + run_correctness + run_regression + run_benchmark + ;; + environment) ;; + lint) run_lint ;; + correctness) run_correctness ;; + regression) run_regression ;; + benchmark) run_benchmark ;; +esac + +echo "T1 run complete: ${OUTPUT_DIR}" diff --git a/scripts/run_kernelswift_t2.sh b/scripts/run_kernelswift_t2.sh new file mode 100644 index 0000000..489258c --- /dev/null +++ b/scripts/run_kernelswift_t2.sh @@ -0,0 +1,383 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/run_kernelswift_t2.sh [options] + +Options: + --ninetoothed-dir PATH NineToothed source tree (default: ../ninetoothed) + --output-dir PATH Logs, caches, and CSV output directory + --mode MODE all|correctness|regression|benchmark|environment + --baseline NAME pytorch|vllm_triton|vllm_vendor|vllm_portable + (default: vllm_portable) + --warmup N Warm-up calls per measurement (default: 20) + --iterations N Timed calls per measurement (default: 100) + --rounds N Independent measurement rounds (default: 3) + -h, --help Show this help + +The script imports both repositories directly through PYTHONPATH. It does not +replace the vendor PyTorch, Triton, or vLLM installation. The +`vllm_portable` option is a separately labelled vLLM-derived compatibility +baseline; it is not an unmodified upstream vLLM binary. +EOF +} + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +NTOPS_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +NINETOOTHED_DIR="${NINETOOTHED_DIR:-${NTOPS_DIR}/../ninetoothed}" +OUTPUT_DIR="${OUTPUT_DIR:-${NTOPS_DIR}/t2_results/run_$(date +%Y%m%d_%H%M%S)}" +MODE="all" +BASELINE="vllm_portable" +WARMUP=20 +ITERATIONS=100 +ROUNDS=3 + +while [[ $# -gt 0 ]]; do + case "$1" in + --ninetoothed-dir) + NINETOOTHED_DIR="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --mode) + MODE="$2" + shift 2 + ;; + --baseline) + BASELINE="$2" + shift 2 + ;; + --warmup) + WARMUP="$2" + shift 2 + ;; + --iterations) + ITERATIONS="$2" + shift 2 + ;; + --rounds) + ROUNDS="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +case "${MODE}" in + all|correctness|regression|benchmark|environment) ;; + *) + echo "Unsupported mode: ${MODE}" >&2 + exit 2 + ;; +esac + +case "${BASELINE}" in + pytorch|vllm_triton|vllm_vendor|vllm_portable) ;; + *) + echo "Unsupported baseline: ${BASELINE}" >&2 + exit 2 + ;; +esac + +require_positive_integer() { + local name="$1" + local value="$2" + + if [[ ! "${value}" =~ ^[1-9][0-9]*$ ]]; then + echo "${name} must be a positive integer, got: ${value}" >&2 + exit 2 + fi +} + +require_positive_integer --warmup "${WARMUP}" +require_positive_integer --iterations "${ITERATIONS}" +require_positive_integer --rounds "${ROUNDS}" + +NINETOOTHED_DIR="$(cd -- "${NINETOOTHED_DIR}" && pwd)" +mkdir -p "${OUTPUT_DIR}" +OUTPUT_DIR="$(cd -- "${OUTPUT_DIR}" && pwd)" + +export PYTHONPATH="${NTOPS_DIR}/src:${NINETOOTHED_DIR}/src:${PYTHONPATH:-}" + +record_repository() { + local name="$1" + local directory="$2" + + echo "${name}_path=${directory}" + if git -C "${directory}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "${name}_head=$(git -C "${directory}" rev-parse HEAD)" + echo "${name}_branch=$(git -C "${directory}" branch --show-current)" + if [[ -n "$(git -C "${directory}" status --porcelain)" ]]; then + echo "${name}_dirty=true" + else + echo "${name}_dirty=false" + fi + else + echo "${name}_head=unavailable-source-snapshot" + echo "${name}_branch=unavailable-source-snapshot" + echo "${name}_dirty=unknown" + fi +} + +record_environment() { + { + echo "timestamp=$(date --iso-8601=seconds)" + echo "hostname=$(hostname)" + uname -a + echo "mode=${MODE}" + echo "baseline=${BASELINE}" + echo "warmup=${WARMUP}" + echo "iterations=${ITERATIONS}" + echo "rounds=${ROUNDS}" + record_repository ntops "${NTOPS_DIR}" + record_repository ninetoothed "${NINETOOTHED_DIR}" + python - <<'PY' +import platform + +import torch + +print(f"python={platform.python_version()}") +print(f"torch={torch.__version__}") +print(f"torch_cuda={torch.version.cuda}") +print(f"torch_hip={torch.version.hip}") + +try: + import triton +except Exception as error: # noqa: BLE001 + print(f"triton_error={type(error).__name__}: {error}") +else: + print(f"triton={triton.__version__}") + +try: + import vllm +except Exception as error: # noqa: BLE001 + print(f"vllm_error={type(error).__name__}: {error}") +else: + print(f"vllm={vllm.__version__}") + +print(f"accelerator_available={torch.cuda.is_available()}") +if torch.cuda.is_available(): + properties = torch.cuda.get_device_properties(0) + print(f"accelerator_name={properties.name}") + print(f"accelerator_memory={properties.total_memory}") + print(f"accelerator_warp_size={getattr(properties, 'warp_size', None)}") + print(f"accelerator_arch={getattr(properties, 'gcnArchName', None)}") +PY + printf 'sha256 %s\n' "T2 operator, evidence, and compiler files" + sha256sum \ + "${NTOPS_DIR}/src/ntops/kernels/scaled_mm.py" \ + "${NTOPS_DIR}/src/ntops/torch/scaled_mm.py" \ + "${NTOPS_DIR}/tests/test_scaled_mm.py" \ + "${NTOPS_DIR}/benchmarks/probe_official_scaled_mm.py" \ + "${NTOPS_DIR}/benchmarks/vllm_portable_scaled_mm.py" \ + "${NTOPS_DIR}/scripts/run_kernelswift_t2.sh" \ + "${NINETOOTHED_DIR}/src/ninetoothed/backends/emitters/base.py" \ + "${NINETOOTHED_DIR}/src/ninetoothed/backends/emitters/ssa.py" \ + "${NINETOOTHED_DIR}/src/ninetoothed/backends/emitters/triton.py" \ + "${NINETOOTHED_DIR}/src/ninetoothed/backends/triton.py" \ + "${NINETOOTHED_DIR}/src/ninetoothed/backends/materializers/triton.py" \ + "${NINETOOTHED_DIR}/src/ninetoothed/compiler/runtime.py" \ + "${NINETOOTHED_DIR}/tests/test_triton_fp8_dot_fallback.py" + } 2>&1 | tee "${OUTPUT_DIR}/environment.log" +} + +run_correctness() { + export NINETOOTHED_CACHE_DIR="${OUTPUT_DIR}/cache_correctness" + export TRITON_CACHE_DIR="${OUTPUT_DIR}/triton_cache_correctness" + mkdir -p "${NINETOOTHED_CACHE_DIR}" "${TRITON_CACHE_DIR}" + ( + cd "${NTOPS_DIR}" + python -m pytest tests/test_scaled_mm.py -q + ) 2>&1 | tee "${OUTPUT_DIR}/correctness.log" +} + +run_regression() { + export NINETOOTHED_CACHE_DIR="${OUTPUT_DIR}/cache_regression" + export TRITON_CACHE_DIR="${OUTPUT_DIR}/triton_cache_regression" + mkdir -p "${NINETOOTHED_CACHE_DIR}" "${TRITON_CACHE_DIR}" + ( + cd "${NINETOOTHED_DIR}" + python -m pytest -q \ + tests/test_triton_fp8_dot_fallback.py \ + tests/test_ssa_first_backend_lowering.py \ + tests/test_triton_runtime_auto_tuning.py \ + tests/test_ssa_scalar_argument_emission.py \ + tests/test_static_layout_bounds.py \ + tests/test_static_tensor_strides.py + ) 2>&1 | tee "${OUTPUT_DIR}/compiler_regression.log" +} + +run_probe() { + local candidate="$1" + local dtype="$2" + local m="$3" + local round="$4" + local log_file="${OUTPUT_DIR}/benchmark_raw/${dtype}_m${m}_round${round}_${candidate}.log" + + ( + cd "${NTOPS_DIR}" + python benchmarks/probe_official_scaled_mm.py \ + --mode "${candidate}" \ + --m "${m}" \ + --n 4096 \ + --k 4096 \ + --fp8-dtype "${dtype}" \ + --warmup "${WARMUP}" \ + --iterations "${ITERATIONS}" + ) 2>&1 | tee "${log_file}" +} + +aggregate_benchmark() { + python - "${OUTPUT_DIR}/benchmark_raw" "${OUTPUT_DIR}/benchmark.csv" \ + "${BASELINE}" "${ROUNDS}" <<'PY' +import csv +import pathlib +import re +import statistics +import sys + + +raw_dir = pathlib.Path(sys.argv[1]) +csv_path = pathlib.Path(sys.argv[2]) +baseline_name = sys.argv[3] +expected_rounds = int(sys.argv[4]) +filename = re.compile( + r"(?Pe4m3fn|e5m2)_m(?P\d+)_round(?P\d+)_" + r"(?P[A-Za-z0-9_]+)\.log" +) +latency = re.compile( + r"stage=latency (?:submission|baseline)=[A-Za-z0-9_]+ " + r"latency_ms=(?P[0-9.]+)" +) +groups = {} + +for path in sorted(raw_dir.glob("*.log")): + match = filename.fullmatch(path.name) + if match is None: + continue + text = path.read_text(encoding="utf-8", errors="replace") + if "stage=correctness passed=True" not in text: + raise SystemExit(f"Correctness did not pass: {path}") + latency_match = latency.search(text) + if latency_match is None: + raise SystemExit(f"Latency is missing: {path}") + key = (match["dtype"], int(match["m"])) + groups.setdefault(key, {}).setdefault(match["candidate"], []).append( + float(latency_match["latency"]) + ) + +fieldnames = ( + "fp8_dtype", + "m", + "n", + "k", + "scaling_layout", + "out_dtype", + "baseline", + "rounds", + "submission_median_ms", + "baseline_median_ms", + "speedup", + "correctness", +) +rows = [] + +for (dtype, m), candidates in sorted(groups.items()): + submission = candidates.get("ntops", []) + baseline = candidates.get(baseline_name, []) + if len(submission) != expected_rounds or len(baseline) != expected_rounds: + raise SystemExit( + f"Incomplete rounds for dtype={dtype}, m={m}: " + f"submission={len(submission)}, baseline={len(baseline)}" + ) + submission_median = statistics.median(submission) + baseline_median = statistics.median(baseline) + rows.append( + { + "fp8_dtype": dtype, + "m": m, + "n": 4096, + "k": 4096, + "scaling_layout": "1x128-128x128", + "out_dtype": "bfloat16", + "baseline": baseline_name, + "rounds": expected_rounds, + "submission_median_ms": f"{submission_median:.6f}", + "baseline_median_ms": f"{baseline_median:.6f}", + "speedup": f"{baseline_median / submission_median:.6f}", + "correctness": "pass", + } + ) + +if len(rows) != 6: + raise SystemExit(f"Expected six benchmark rows, found {len(rows)}") + +with csv_path.open("w", encoding="utf-8", newline="") as output: + writer = csv.DictWriter(output, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + +for row in rows: + print( + "stage=summary " + f"dtype={row['fp8_dtype']} m={row['m']} " + f"submission_ms={row['submission_median_ms']} " + f"baseline_ms={row['baseline_median_ms']} " + f"speedup={row['speedup']} correctness=pass" + ) +PY +} + +run_benchmark() { + export NINETOOTHED_CACHE_DIR="${OUTPUT_DIR}/cache_benchmark" + export TRITON_CACHE_DIR="${OUTPUT_DIR}/triton_cache_benchmark" + mkdir -p \ + "${NINETOOTHED_CACHE_DIR}" \ + "${TRITON_CACHE_DIR}" \ + "${OUTPUT_DIR}/benchmark_raw" + + for dtype in e4m3fn e5m2; do + for m in 1 16 128; do + for round in $(seq 1 "${ROUNDS}"); do + if (( round % 2 == 1 )); then + run_probe ntops "${dtype}" "${m}" "${round}" + run_probe "${BASELINE}" "${dtype}" "${m}" "${round}" + else + run_probe "${BASELINE}" "${dtype}" "${m}" "${round}" + run_probe ntops "${dtype}" "${m}" "${round}" + fi + done + done + done + + aggregate_benchmark 2>&1 | tee "${OUTPUT_DIR}/benchmark_summary.log" +} + +record_environment + +case "${MODE}" in + all) + run_correctness + run_regression + run_benchmark + ;; + correctness) run_correctness ;; + regression) run_regression ;; + benchmark) run_benchmark ;; + environment) ;; +esac + +echo "T2 run complete: ${OUTPUT_DIR}" diff --git a/scripts/run_kernelswift_t3.sh b/scripts/run_kernelswift_t3.sh new file mode 100644 index 0000000..26d0edd --- /dev/null +++ b/scripts/run_kernelswift_t3.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/run_kernelswift_t3.sh [options] + +Options: + --ninetoothed-dir PATH NineToothed source tree (default: ../ninetoothed) + --output-dir PATH Logs, caches, and CSV output directory + --mode MODE all|correctness|regression|benchmark|ablation|environment + -h, --help Show this help + +The script imports both repositories directly through PYTHONPATH. It does not +replace the vendor PyTorch, Triton, or vLLM installation. +EOF +} + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +NTOPS_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +NINETOOTHED_DIR="${NINETOOTHED_DIR:-${NTOPS_DIR}/../ninetoothed}" +OUTPUT_DIR="${OUTPUT_DIR:-${NTOPS_DIR}/t3_results/run_$(date +%Y%m%d_%H%M%S)}" +MODE="all" + +while [[ $# -gt 0 ]]; do + case "$1" in + --ninetoothed-dir) + NINETOOTHED_DIR="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --mode) + MODE="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +case "${MODE}" in + all|correctness|regression|benchmark|ablation|environment) ;; + *) + echo "Unsupported mode: ${MODE}" >&2 + exit 2 + ;; +esac + +NINETOOTHED_DIR="$(cd -- "${NINETOOTHED_DIR}" && pwd)" +mkdir -p "${OUTPUT_DIR}" +OUTPUT_DIR="$(cd -- "${OUTPUT_DIR}" && pwd)" + +export PYTHONPATH="${NTOPS_DIR}/src:${NINETOOTHED_DIR}/src:${PYTHONPATH:-}" + +record_repository() { + local name="$1" + local directory="$2" + + echo "${name}_path=${directory}" + if git -C "${directory}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "${name}_head=$(git -C "${directory}" rev-parse HEAD)" + echo "${name}_branch=$(git -C "${directory}" branch --show-current)" + if [[ -n "$(git -C "${directory}" status --porcelain)" ]]; then + echo "${name}_dirty=true" + else + echo "${name}_dirty=false" + fi + else + echo "${name}_head=unavailable-source-snapshot" + echo "${name}_branch=unavailable-source-snapshot" + echo "${name}_dirty=unknown" + fi +} + +record_environment() { + { + echo "timestamp=$(date --iso-8601=seconds)" + echo "hostname=$(hostname)" + uname -a + record_repository ntops "${NTOPS_DIR}" + record_repository ninetoothed "${NINETOOTHED_DIR}" + python - <<'PY' +import platform + +import torch + +print(f"python={platform.python_version()}") +print(f"torch={torch.__version__}") +print(f"torch_cuda={torch.version.cuda}") +print(f"torch_hip={torch.version.hip}") + +try: + import triton +except Exception as error: # noqa: BLE001 + print(f"triton_error={type(error).__name__}: {error}") +else: + print(f"triton={triton.__version__}") + +try: + import vllm +except Exception as error: # noqa: BLE001 + print(f"vllm_error={type(error).__name__}: {error}") +else: + print(f"vllm={vllm.__version__}") + +print(f"accelerator_available={torch.cuda.is_available()}") +if torch.cuda.is_available(): + properties = torch.cuda.get_device_properties(0) + print(f"accelerator_name={properties.name}") + print(f"accelerator_memory={properties.total_memory}") + print(f"accelerator_warp_size={getattr(properties, 'warp_size', None)}") + print(f"accelerator_arch={getattr(properties, 'gcnArchName', None)}") +PY + printf 'sha256 %s\n' "T3 and compiler files" + sha256sum \ + "${NTOPS_DIR}/src/ntops/kernels/rms_norm_gated.py" \ + "${NTOPS_DIR}/src/ntops/torch/rms_norm_gated.py" \ + "${NTOPS_DIR}/tests/test_rms_norm_gated.py" \ + "${NTOPS_DIR}/benchmarks/benchmark_rms_norm_gated.py" \ + "${NTOPS_DIR}/benchmarks/benchmark_rms_norm_gated_runtime_ablation.py" \ + "${NTOPS_DIR}/scripts/run_kernelswift_t3.sh" \ + "${NINETOOTHED_DIR}/src/ninetoothed/backends/emitters/ssa.py" \ + "${NINETOOTHED_DIR}/src/ninetoothed/backends/emitters/triton.py" \ + "${NINETOOTHED_DIR}/src/ninetoothed/backends/materializers/triton.py" \ + "${NINETOOTHED_DIR}/src/ninetoothed/compiler/runtime.py" \ + "${NINETOOTHED_DIR}/src/ninetoothed/frontend/layout.py" \ + "${NINETOOTHED_DIR}/src/ninetoothed/tensor.py" + } 2>&1 | tee "${OUTPUT_DIR}/environment.log" +} + +run_correctness() { + export NINETOOTHED_CACHE_DIR="${OUTPUT_DIR}/cache_correctness" + mkdir -p "${NINETOOTHED_CACHE_DIR}" + ( + cd "${NTOPS_DIR}" + python -m pytest tests/test_rms_norm_gated.py -q + ) 2>&1 | tee "${OUTPUT_DIR}/correctness.log" +} + +run_regression() { + export NINETOOTHED_CACHE_DIR="${OUTPUT_DIR}/cache_regression" + mkdir -p "${NINETOOTHED_CACHE_DIR}" + ( + cd "${NINETOOTHED_DIR}" + python -m pytest -q \ + tests/test_ssa_first_backend_lowering.py \ + tests/test_triton_runtime_auto_tuning.py \ + tests/test_ssa_scalar_argument_emission.py \ + tests/test_static_layout_bounds.py \ + tests/test_static_tensor_strides.py + ) 2>&1 | tee "${OUTPUT_DIR}/compiler_regression.log" +} + +run_benchmark() { + export NINETOOTHED_CACHE_DIR="${OUTPUT_DIR}/cache_benchmark" + mkdir -p "${NINETOOTHED_CACHE_DIR}" + ( + cd "${NTOPS_DIR}" + python benchmarks/benchmark_rms_norm_gated.py \ + --csv "${OUTPUT_DIR}/benchmark.csv" + ) 2>&1 | tee "${OUTPUT_DIR}/benchmark.log" +} + +run_ablation() { + export NINETOOTHED_CACHE_DIR="${OUTPUT_DIR}/cache_ablation" + mkdir -p "${NINETOOTHED_CACHE_DIR}" + ( + cd "${NTOPS_DIR}" + python benchmarks/benchmark_rms_norm_gated_runtime_ablation.py + ) 2>&1 | tee "${OUTPUT_DIR}/runtime_ablation.log" +} + +record_environment + +case "${MODE}" in + all) + run_correctness + run_regression + run_benchmark + run_ablation + ;; + correctness) run_correctness ;; + regression) run_regression ;; + benchmark) run_benchmark ;; + ablation) run_ablation ;; + environment) ;; +esac + +echo "T3 run complete: ${OUTPUT_DIR}" diff --git a/src/ntops/kernels/__init__.py b/src/ntops/kernels/__init__.py index 12d337b..51cd82c 100644 --- a/src/ntops/kernels/__init__.py +++ b/src/ntops/kernels/__init__.py @@ -37,11 +37,13 @@ quantile, relu, rms_norm, + rms_norm_gated, rot90, rotary_position_embedding, round, rsqrt, scaled_dot_product_attention, + scaled_mm, select_copy, sgn, sigmoid, @@ -74,6 +76,8 @@ lp_pool2d, lp_pool3d, max, + mxfp4_grouped_gemm, + mla_rope_cache, ) __all__ = [ @@ -115,11 +119,13 @@ "quantile", "relu", "rms_norm", + "rms_norm_gated", "rot90", "rotary_position_embedding", "round", "rsqrt", "scaled_dot_product_attention", + "scaled_mm", "select_copy", "sgn", "sigmoid", @@ -152,4 +158,6 @@ "lp_pool2d", "lp_pool3d", "max", + "mxfp4_grouped_gemm", + "mla_rope_cache", ] diff --git a/src/ntops/kernels/mla_rope_cache.py b/src/ntops/kernels/mla_rope_cache.py new file mode 100644 index 0000000..514ff82 --- /dev/null +++ b/src/ntops/kernels/mla_rope_cache.py @@ -0,0 +1,180 @@ +"""Fused MLA RoPE and compressed paged-KV-cache write.""" + +import functools + +import ninetoothed.language as ntl +from ninetoothed import Tensor + +# A runtime warp sweep is counterproductive for this launch-bound fused +# operator: all supported shapes use the same one-row-per-program schedule, +# while CoreX may spend minutes benchmarking otherwise equivalent candidates. +# Two warps is legal on both target backends and was the best or statistically +# tied configuration across the representative decode/prefill shapes on BW. +NUM_WARPS = 2 +NUM_STAGES = 1 + + +def arrangement( + q_pe, + k_pe, + kv_c, + positions, + cos_sin_cache, + slot_mapping, + kv_cache, + *, + q_width, + rope_dim, + kv_lora_rank, +): + def row(tensor, width): + tiled = tensor.tile((1, width)) + tiled.dtype = tiled.dtype.squeeze(0) + return tiled + + def padded_row(tensor, width, padding): + """Pad the per-program value tile while preserving the token grid.""" + tiled = tensor.tile((1, width)) + tiled.dtype = tiled.dtype.squeeze(0).pad((padding,)) + return tiled + + def scalar(tensor): + tiled = tensor.tile((1,)) + tiled.dtype = tiled.dtype.squeeze(0) + return tiled + + # Align every value tile with the already existing q application domain. + # Triton block tensors require power-of-two dimensions; keeping a separate + # 576-wide cache value would otherwise force an illegal ``arange(0, 576)`` + # when the backend maps one complete token to one program. The physical + # cache is still 576 elements wide and its source-layout bounds mask drops + # all right-padding lanes. + cache_width = kv_lora_rank + rope_dim + alignment_padding = q_width - cache_width + + if alignment_padding < 0: + raise ValueError("q_width must cover the compressed MLA cache row.") + + k_row = padded_row(k_pe, rope_dim, (kv_lora_rank, alignment_padding)) + kv_c_row = padded_row( + kv_c, + kv_lora_rank, + (0, rope_dim + alignment_padding), + ) + cache_row = padded_row(kv_cache, cache_width, (0, alignment_padding)) + return ( + row(q_pe, q_width), + k_row, + kv_c_row, + scalar(positions), + row(cos_sin_cache, rope_dim), + scalar(slot_mapping), + cache_row, + ) + + +def application( + q_pe, + k_pe, + kv_c, + positions, + cos_sin_cache, + slot_mapping, + kv_cache, +): + rope_dim = cos_sin_cache.shape[0] + half_rope_dim = rope_dim // 2 + # ``kv_cache`` is logically padded to the q width by the arrangement; + # derive the real compressed rank from the physical kv_c source instead. + kv_lora_rank = kv_c.source.shape[-1] + + # The application sees one value tile per token. ``offsets(-1)`` selects + # the original feature dimension; ``offsets(0)`` would select the outer + # token dimension and incorrectly rotate adjacent tokens instead of + # adjacent RoPE channels. + q_offsets = q_pe.offsets(-1) + q_pair_offsets = q_offsets ^ 1 + q_frequency = (q_offsets % rope_dim) // 2 + q_pair = ntl.gather(q_pe, q_pair_offsets, 0) + q_cos = cos_sin_cache.source[positions, q_frequency] + q_sin = cos_sin_cache.source[positions, q_frequency + half_rope_dim] + q_sign = ntl.where(q_offsets % 2 == 0, -1.0, 1.0) + q_pe = q_pe * q_cos + q_pair * q_sin * q_sign # noqa: F841 + + # The left-padded k view maps logical lanes [512, 576) to source offsets + # [0, 64). Its feature offset is therefore already local to k_pe. + k_offsets = k_pe.offsets(-1) + k_local_offsets = ntl.maximum(k_offsets, 0) + k_pair_offsets = ntl.minimum( + (k_local_offsets ^ 1) + kv_lora_rank, + kv_cache.shape[0] - 1, + ) + k_frequency = k_local_offsets // 2 + k_pair = ntl.gather(k_pe, k_pair_offsets, 0) + k_cos = cos_sin_cache.source[positions, k_frequency] + k_sin = cos_sin_cache.source[positions, k_frequency + half_rope_dim] + k_sign = ntl.where(k_local_offsets % 2 == 0, -1.0, 1.0) + rotated_k = k_pe * k_cos + k_pair * k_sin * k_sign + + combined = ntl.where( + kv_cache.offsets(-1) < kv_lora_rank, + kv_c, + rotated_k, + ) + kv_cache.source[slot_mapping] = combined + + +def premake( + q_shape, + k_shape, + kv_c_shape, + positions_shape, + cos_sin_shape, + slots_shape, + cache_shape, + q_dtype, + k_dtype, + kv_c_dtype, + positions_dtype, + cos_sin_dtype, + slots_dtype, + cache_dtype, + q_strides, + k_strides, + kv_c_strides, + positions_strides, + cos_sin_strides, + slots_strides, + cache_strides, +): + q_width = q_shape[1] + rope_dim = k_shape[1] + kv_lora_rank = kv_c_shape[1] + arrangement_ = functools.partial( + arrangement, + q_width=q_width, + rope_dim=rope_dim, + kv_lora_rank=kv_lora_rank, + ) + + def tensor(shape, strides, dtype, *, other=None): + return Tensor( + shape=tuple(shape), + strides=tuple(strides), + dtype=dtype, + other=other, + ) + + tensors = ( + tensor(q_shape, q_strides, q_dtype, other=0), + tensor(k_shape, k_strides, k_dtype, other=0), + tensor(kv_c_shape, kv_c_strides, kv_c_dtype, other=0), + tensor(positions_shape, positions_strides, positions_dtype), + tensor(cos_sin_shape, cos_sin_strides, cos_sin_dtype, other=0), + tensor(slots_shape, slots_strides, slots_dtype), + tensor(cache_shape, cache_strides, cache_dtype), + ) + return arrangement_, application, tensors + + +__all__ = ["premake"] diff --git a/src/ntops/kernels/mla_rope_cache_pair.py b/src/ntops/kernels/mla_rope_cache_pair.py new file mode 100644 index 0000000..3e9edaa --- /dev/null +++ b/src/ntops/kernels/mla_rope_cache_pair.py @@ -0,0 +1,192 @@ +"""Pair-layout candidate for the fused MLA RoPE/cache kernel. + +This module is intentionally kept separate while the layout is being +validated on vendor hardware. It stores the even and odd RoPE channels in +two stride-2 views and assigns both views from one pair lane, so each CUDA +thread reuses the pair inputs and frequency values without an in-place race. +""" + +import functools + +import ninetoothed.language as ntl +from ninetoothed import Tensor + +NUM_WARPS = 2 +NUM_STAGES = 1 + + +def _row(tensor, width): + arranged = tensor.tile((1, width)) + arranged.dtype = arranged.dtype.squeeze(0) + return arranged + + +def _padded_row(tensor, width, padding): + arranged = tensor.tile((1, width)) + arranged.dtype = arranged.dtype.squeeze(0).pad((padding,)) + return arranged + + +def _scalar(tensor): + arranged = tensor.tile((1,)) + arranged.dtype = arranged.dtype.squeeze(0) + return arranged + + +def arrangement( + q_even, + q_odd, + k_even, + k_odd, + kv_c, + positions, + cos_sin_cache, + slot_mapping, + kv_cache, + *, + q_pairs, + rope_dim, + kv_lora_rank, +): + cache_width = kv_lora_rank + rope_dim + padding = q_pairs - cache_width + k_padding = q_pairs - kv_lora_rank - rope_dim // 2 + if padding < 0: + raise ValueError("q pair width must cover the MLA cache row.") + if k_padding < 0: + raise ValueError("q pair width must cover the padded RoPE pair row.") + + return ( + _row(q_even, q_pairs), + _row(q_odd, q_pairs), + _padded_row( + k_even, + rope_dim // 2, + (kv_lora_rank, k_padding), + ), + _padded_row( + k_odd, + rope_dim // 2, + (kv_lora_rank, k_padding), + ), + _padded_row(kv_c, kv_lora_rank, (0, q_pairs - kv_lora_rank)), + _scalar(positions), + _row(cos_sin_cache, rope_dim), + _scalar(slot_mapping), + _padded_row(kv_cache, cache_width, (0, padding)), + ) + + +def application( + q_even, + q_odd, + k_even, + k_odd, + kv_c, + positions, + cos_sin_cache, + slot_mapping, + kv_cache, +): + rope_dim = cos_sin_cache.shape[0] + half_rope_dim = rope_dim // 2 + kv_lora_rank = kv_c.source.shape[-1] + + q_offsets = q_even.offsets(-1) + q_frequency = q_offsets % half_rope_dim + q_cos = cos_sin_cache.source[positions, q_frequency] + q_sin = cos_sin_cache.source[positions, q_frequency + half_rope_dim] + # Materialize both source values in SSA before either aliased stride-2 + # output view is written. A plain Python alias would let the second store + # reload the already updated even channel from memory. + original_even = q_even + 0.0 + original_odd = q_odd + 0.0 + q_even = original_even * q_cos - original_odd * q_sin # noqa: F841 + q_odd = original_odd * q_cos + original_even * q_sin # noqa: F841 + + cache_offsets = kv_cache.offsets(-1) + cache_pair = ntl.maximum( + (cache_offsets - kv_lora_rank) // 2, + 0, + ) + cache_k_index = cache_pair + kv_lora_rank + raw_even = ntl.gather(k_even, cache_k_index, 0) + raw_odd = ntl.gather(k_odd, cache_k_index, 0) + cache_frequency = cache_pair % half_rope_dim + cache_cos = cos_sin_cache.source[positions, cache_frequency] + cache_sin = cos_sin_cache.source[ + positions, cache_frequency + half_rope_dim + ] + rotated_even = raw_even * cache_cos - raw_odd * cache_sin + rotated_odd = raw_odd * cache_cos + raw_even * cache_sin + rotated = ntl.where( + cache_offsets % 2 == 0, + rotated_even, + rotated_odd, + ) + combined = ntl.where(cache_offsets < kv_lora_rank, kv_c, rotated) + kv_cache.source[slot_mapping] = combined + + +def premake( + q_even_shape, + q_odd_shape, + k_even_shape, + k_odd_shape, + kv_c_shape, + positions_shape, + cos_sin_shape, + slots_shape, + cache_shape, + q_even_dtype, + q_odd_dtype, + k_even_dtype, + k_odd_dtype, + kv_c_dtype, + positions_dtype, + cos_sin_dtype, + slots_dtype, + cache_dtype, + q_even_strides, + q_odd_strides, + k_even_strides, + k_odd_strides, + kv_c_strides, + positions_strides, + cos_sin_strides, + slots_strides, + cache_strides, +): + q_pairs = q_even_shape[-1] + rope_dim = k_even_shape[-1] * 2 + kv_lora_rank = kv_c_shape[-1] + arrangement_ = functools.partial( + arrangement, + q_pairs=q_pairs, + rope_dim=rope_dim, + kv_lora_rank=kv_lora_rank, + ) + + def tensor(shape, strides, dtype, *, other=None): + return Tensor( + shape=tuple(shape), + strides=tuple(strides), + dtype=dtype, + other=other, + ) + + tensors = ( + tensor(q_even_shape, q_even_strides, q_even_dtype, other=0), + tensor(q_odd_shape, q_odd_strides, q_odd_dtype, other=0), + tensor(k_even_shape, k_even_strides, k_even_dtype, other=0), + tensor(k_odd_shape, k_odd_strides, k_odd_dtype, other=0), + tensor(kv_c_shape, kv_c_strides, kv_c_dtype, other=0), + tensor(positions_shape, positions_strides, positions_dtype), + tensor(cos_sin_shape, cos_sin_strides, cos_sin_dtype, other=0), + tensor(slots_shape, slots_strides, slots_dtype), + tensor(cache_shape, cache_strides, cache_dtype), + ) + return arrangement_, application, tensors + + +__all__ = ["NUM_WARPS", "NUM_STAGES", "premake"] diff --git a/src/ntops/kernels/mxfp4_grouped_gemm.py b/src/ntops/kernels/mxfp4_grouped_gemm.py new file mode 100644 index 0000000..0abdc43 --- /dev/null +++ b/src/ntops/kernels/mxfp4_grouped_gemm.py @@ -0,0 +1,208 @@ +"""MXFP4 W4A16 grouped expert matrix multiplication.""" + +import enum +import functools + +import ninetoothed +import ninetoothed.language as ntl +from ninetoothed import Tensor + +MXFP4_BLOCK_SIZE = 32 +PACKED_VALUES_PER_BYTE = 2 +PACKED_VALUES_PER_SCALE = MXFP4_BLOCK_SIZE // PACKED_VALUES_PER_BYTE + +NUM_WARPS = (4, 8) +NUM_STAGES = (1,) + +BLOCK_SIZE_M = ninetoothed.block_size(lower_bound=16, upper_bound=64) +BLOCK_SIZE_N = ninetoothed.block_size(lower_bound=16, upper_bound=128) + + +class ComputeDtypeVariant(enum.IntEnum): + FLOAT16 = enum.auto() + + BFLOAT16 = enum.auto() + + +def _decode_e2m1(packed): + """Decode E2M1 nibbles to FP32 values.""" + packed_i32 = packed.to(ntl.int32) + exponent = (packed_i32 >> 1) & 0x3 + mantissa = packed_i32 & 0x1 + sign = packed_i32 >> 3 + shift = ntl.where(exponent == 0, 0, exponent - 1) + magnitude_i32 = ntl.where(exponent == 0, mantissa, mantissa + 2) << shift + magnitude = magnitude_i32.to(ntl.float32) * 0.5 + + return ntl.where(sign != 0, -magnitude, magnitude) + + +def _decode_e8m0(scale): + """Decode an E8M0 scale byte to an FP32 multiplier.""" + scale_i32 = scale.to(ntl.int32) + bits = ntl.where(scale_i32 == 0, 0x00400000, scale_i32 << 23) + + return ntl.cast(bits, ntl.float32, bitcast=True) + + +def _arrange_activation(input, output, block_size_m): + arranged = input.tile((1, block_size_m, MXFP4_BLOCK_SIZE)) + arranged = arranged.tile((1, 1, -1)) + arranged = arranged.expand((-1, -1, output.shape[2])) + arranged.dtype = arranged.dtype.squeeze((0, 1)) + arranged.dtype.dtype = arranged.dtype.dtype.squeeze(0) + + return arranged + + +def _arrange_weight(weight_mxfp4, output, block_size_n): + arranged = weight_mxfp4.permute((0, 2, 1)) + arranged = arranged.tile((1, PACKED_VALUES_PER_SCALE, block_size_n)) + arranged = arranged.tile((1, -1, 1)) + arranged = arranged.expand((-1, output.shape[1], -1)) + arranged.dtype = arranged.dtype.squeeze((0, 2)) + arranged.dtype.dtype = arranged.dtype.dtype.squeeze(0) + + return arranged + + +def _arrange_scale(weight_scale, output, block_size_n): + arranged = weight_scale.permute((0, 2, 1)) + arranged = arranged.tile((1, 1, block_size_n)) + arranged = arranged.tile((1, -1, 1)) + arranged = arranged.expand((-1, output.shape[1], -1)) + arranged.dtype = arranged.dtype.squeeze((0, 2)) + arranged.dtype.dtype = arranged.dtype.dtype.squeeze((0, 1)) + + return arranged + + +def arrangement( + input, + weight_mxfp4, + weight_scale, + output, + block_size_m=None, + block_size_n=None, +): + """Map one launch grid to expert, token, and output-column tiles.""" + if block_size_m is None: + block_size_m = BLOCK_SIZE_M + + if block_size_n is None: + block_size_n = BLOCK_SIZE_N + + output_arranged = output.tile((1, block_size_m, block_size_n)) + output_arranged.dtype = output_arranged.dtype.squeeze(0) + + return ( + _arrange_activation(input, output_arranged, block_size_m), + _arrange_weight(weight_mxfp4, output_arranged, block_size_n), + _arrange_scale(weight_scale, output_arranged, block_size_n), + output_arranged, + ) + + +def bfloat16_application( + input, + weight_mxfp4, + weight_scale, + output, +): + """Accumulate the grouped GEMM with BF16 dot operands.""" + accumulator = ntl.zeros(output.shape, dtype=ntl.float32) + + for k_idx in range(input.shape[0]): + packed = weight_mxfp4[k_idx].to(ntl.int32) + scale = _decode_e8m0(weight_scale[k_idx]) + weight_even = _decode_e2m1(packed & 0x0F).to(ntl.bfloat16) + weight_odd = _decode_e2m1(packed >> 4).to(ntl.bfloat16) + weight = ntl.trans( + ntl.interleave(ntl.trans(weight_even), ntl.trans(weight_odd)) + ) + accumulator += ntl.dot(input[k_idx].to(ntl.bfloat16), weight) * scale + + output[:] = accumulator.to(output.dtype) + + +def float16_application( + input, + weight_mxfp4, + weight_scale, + output, +): + """Accumulate the grouped GEMM with FP16 dot operands.""" + accumulator = ntl.zeros(output.shape, dtype=ntl.float32) + + for k_idx in range(input.shape[0]): + packed = weight_mxfp4[k_idx].to(ntl.int32) + scale = _decode_e8m0(weight_scale[k_idx]) + weight_even = _decode_e2m1(packed & 0x0F).to(ntl.float16) + weight_odd = _decode_e2m1(packed >> 4).to(ntl.float16) + weight = ntl.trans( + ntl.interleave(ntl.trans(weight_even), ntl.trans(weight_odd)) + ) + accumulator += ntl.dot(input[k_idx].to(ntl.float16), weight) * scale + + output[:] = accumulator.to(output.dtype) + + +def premake( + num_experts, + k, + n, + input_dtype, + output_dtype, + compute_dtype, + block_size_m=None, + block_size_n=None, +): + """Create the grouped jagged kernel definition.""" + arrangement_ = functools.partial( + arrangement, + block_size_m=block_size_m, + block_size_n=block_size_n, + ) + + output_tensor = Tensor( + shape=(num_experts, None, n), + dtype=output_dtype, + jagged_dim=1, + ) + tensors = ( + Tensor( + shape=(num_experts, None, k), + dtype=input_dtype, + jagged_dim=1, + ), + Tensor(shape=(num_experts, n, k // 2), dtype="uint8"), + Tensor( + shape=(num_experts, n, k // MXFP4_BLOCK_SIZE), + dtype="uint8", + ), + output_tensor, + ) + + application_ = ( + bfloat16_application + if compute_dtype == ComputeDtypeVariant.BFLOAT16 + else float16_application + ) + + return arrangement_, application_, tensors + + +__all__ = [ + "BLOCK_SIZE_M", + "BLOCK_SIZE_N", + "ComputeDtypeVariant", + "MXFP4_BLOCK_SIZE", + "NUM_STAGES", + "NUM_WARPS", + "PACKED_VALUES_PER_BYTE", + "PACKED_VALUES_PER_SCALE", + "arrangement", + "bfloat16_application", + "float16_application", + "premake", +] diff --git a/src/ntops/kernels/rms_norm_gated.py b/src/ntops/kernels/rms_norm_gated.py new file mode 100644 index 0000000..3cf2f12 --- /dev/null +++ b/src/ntops/kernels/rms_norm_gated.py @@ -0,0 +1,199 @@ +import enum +import functools + +import ninetoothed +import ninetoothed.language as ntl +from ninetoothed import Tensor + +NUM_WARPS = (1, 2, 4, 8) +NUM_STAGES = 1 + + +class ActivationVariant(enum.IntEnum): + SILU = enum.auto() + SIGMOID = enum.auto() + + +def arrangement(*tensors, block_size): + return tuple( + tensor.tile((1, block_size)) if tensor.ndim == 2 else tensor + for tensor in tensors + ) + + +def silu_after_gate_application( + input, gate, weight, eps, output +): + input_value = input.to(ntl.float32) + gate_value = gate.to(ntl.float32) + value = input_value * gate_value / (1 + ntl.exp(-gate_value)) + variance = ntl.sum(value * value, axis=1) / input.shape[1] + output = ( # noqa: F841 + value + * ntl.rsqrt(variance[:, None] + eps) + * weight.to(ntl.float32) + ) + + +def silu_before_gate_application( + input, gate, weight, eps, output +): + input_value = input.to(ntl.float32) + gate_value = gate.to(ntl.float32) + activated_gate = gate_value / (1 + ntl.exp(-gate_value)) + variance = ntl.sum(input_value * input_value, axis=1) / input.shape[1] + output = ( # noqa: F841 + input_value + * ntl.rsqrt(variance[:, None] + eps) + * activated_gate + * weight.to(ntl.float32) + ) + + +def sigmoid_after_gate_application( + input, gate, weight, eps, output +): + input_value = input.to(ntl.float32) + gate_value = gate.to(ntl.float32) + activated_gate = 1 / (1 + ntl.exp(-gate_value)) + value = input_value * activated_gate + variance = ntl.sum(value * value, axis=1) / input.shape[1] + output = ( # noqa: F841 + value + * ntl.rsqrt(variance[:, None] + eps) + * weight.to(ntl.float32) + ) + + +def sigmoid_before_gate_application( + input, gate, weight, eps, output +): + input_value = input.to(ntl.float32) + gate_value = gate.to(ntl.float32) + activated_gate = 1 / (1 + ntl.exp(-gate_value)) + variance = ntl.sum(input_value * input_value, axis=1) / input.shape[1] + output = ( # noqa: F841 + input_value + * ntl.rsqrt(variance[:, None] + eps) + * activated_gate + * weight.to(ntl.float32) + ) + + +def silu_after_gate_no_weight_application( + input, gate, eps, output +): + input_value = input.to(ntl.float32) + gate_value = gate.to(ntl.float32) + value = input_value * gate_value / (1 + ntl.exp(-gate_value)) + variance = ntl.sum(value * value, axis=1) / input.shape[1] + output = value * ntl.rsqrt(variance[:, None] + eps) # noqa: F841 + + +def silu_before_gate_no_weight_application( + input, gate, eps, output +): + input_value = input.to(ntl.float32) + gate_value = gate.to(ntl.float32) + activated_gate = gate_value / (1 + ntl.exp(-gate_value)) + variance = ntl.sum(input_value * input_value, axis=1) / input.shape[1] + output = ( # noqa: F841 + input_value * ntl.rsqrt(variance[:, None] + eps) * activated_gate + ) + + +def sigmoid_after_gate_no_weight_application( + input, gate, eps, output +): + input_value = input.to(ntl.float32) + gate_value = gate.to(ntl.float32) + activated_gate = 1 / (1 + ntl.exp(-gate_value)) + value = input_value * activated_gate + variance = ntl.sum(value * value, axis=1) / input.shape[1] + output = value * ntl.rsqrt(variance[:, None] + eps) # noqa: F841 + + +def sigmoid_before_gate_no_weight_application( + input, gate, eps, output +): + input_value = input.to(ntl.float32) + gate_value = gate.to(ntl.float32) + activated_gate = 1 / (1 + ntl.exp(-gate_value)) + variance = ntl.sum(input_value * input_value, axis=1) / input.shape[1] + output = ( # noqa: F841 + input_value * ntl.rsqrt(variance[:, None] + eps) * activated_gate + ) + + +_APPLICATIONS = { + (ActivationVariant.SILU, False, True): silu_after_gate_application, + (ActivationVariant.SILU, True, True): silu_before_gate_application, + (ActivationVariant.SIGMOID, False, True): sigmoid_after_gate_application, + (ActivationVariant.SIGMOID, True, True): sigmoid_before_gate_application, + (ActivationVariant.SILU, False, False): silu_after_gate_no_weight_application, + (ActivationVariant.SILU, True, False): silu_before_gate_no_weight_application, + ( + ActivationVariant.SIGMOID, + False, + False, + ): sigmoid_after_gate_no_weight_application, + ( + ActivationVariant.SIGMOID, + True, + False, + ): sigmoid_before_gate_no_weight_application, +} + + +def premake( + ndim, + group_size, + activation_variant, + norm_before_gate, + input_dtype=None, + gate_dtype=None, + weight_dtype=None, + output_dtype=None, + block_size=None, + input_shape=None, + gate_shape=None, + weight_shape=None, + output_shape=None, + input_strides=None, + gate_strides=None, + weight_strides=None, + output_strides=None, +): + arrangement_ = functools.partial(arrangement, block_size=block_size) + has_weight = weight_dtype is not None + application = _APPLICATIONS[ + (activation_variant, norm_before_gate, has_weight) + ] + + def tensor(dtype, shape, strides, *, other=None): + if shape is None: + return Tensor(ndim, other=other, dtype=dtype) + return Tensor( + shape=tuple(shape), + strides=None if strides is None else tuple(strides), + other=other, + dtype=dtype, + ) + + tensors = [ + tensor(input_dtype, input_shape, input_strides, other=0), + tensor(gate_dtype, gate_shape, gate_strides, other=0), + ] + if has_weight: + tensors.append(tensor(weight_dtype, weight_shape, weight_strides)) + tensors.extend( + ( + Tensor(0, dtype=ninetoothed.float64), + tensor(output_dtype, output_shape, output_strides), + ) + ) + + return arrangement_, application, tuple(tensors) + + +__all__ = ["ActivationVariant", "premake"] diff --git a/src/ntops/kernels/scaled_mm.py b/src/ntops/kernels/scaled_mm.py new file mode 100644 index 0000000..ddb82fe --- /dev/null +++ b/src/ntops/kernels/scaled_mm.py @@ -0,0 +1,277 @@ +import enum +import functools + +import ninetoothed +import ninetoothed.language as ntl +from ninetoothed import Tensor + + +SCALE_BLOCK_SIZE = 128 +NUM_WARPS = (4, 8) +NUM_STAGES = (1, 2) + +BLOCK_SIZE_M = ninetoothed.block_size(lower_bound=16, upper_bound=128) +BLOCK_SIZE_N = ninetoothed.block_size(lower_bound=16, upper_bound=128) +MIN_SPECIALIZED_BLOCK_SIZE = 16 +MAX_SPECIALIZED_BLOCK_SIZE_M = 64 + + +class ScalingVariant(enum.IntEnum): + BLOCK_WISE_1X128_128X128 = enum.auto() + + BLOCK_WISE_1X128_1X128 = enum.auto() + + BLOCK_WISE_128X128_1X128 = enum.auto() + + +def _largest_divisible_block_size(extent, maximum=SCALE_BLOCK_SIZE): + block_size = maximum + + while block_size > MIN_SPECIALIZED_BLOCK_SIZE and extent % block_size: + block_size //= 2 + + return block_size + + +def specialized_block_sizes(scaling_variant, m, n): + """Choose concrete power-of-two tiles for a shape-specialized kernel.""" + block_size_m = ( + MIN_SPECIALIZED_BLOCK_SIZE + if m == 1 + else SCALE_BLOCK_SIZE + if scaling_variant == ScalingVariant.BLOCK_WISE_128X128_1X128 + else _largest_divisible_block_size(m, MAX_SPECIALIZED_BLOCK_SIZE_M) + ) + block_size_n = ( + SCALE_BLOCK_SIZE + if scaling_variant == ScalingVariant.BLOCK_WISE_1X128_128X128 + else _largest_divisible_block_size(n) + ) + + return block_size_m, block_size_n + + +def _block_sizes(scaling_variant, block_size_m, block_size_n): + if block_size_m is None: + block_size_m = ( + SCALE_BLOCK_SIZE + if scaling_variant == ScalingVariant.BLOCK_WISE_128X128_1X128 + else BLOCK_SIZE_M + ) + + if block_size_n is None: + block_size_n = ( + SCALE_BLOCK_SIZE + if scaling_variant == ScalingVariant.BLOCK_WISE_1X128_128X128 + else BLOCK_SIZE_N + ) + + return block_size_m, block_size_n + + +def arrangement( + input, + mat2, + scale_a, + scale_b, + output, + scaling_variant, + block_size_m=None, + block_size_n=None, + input_block_size_m=None, +): + block_size_m, block_size_n = _block_sizes( + scaling_variant, + block_size_m, + block_size_n, + ) + + if input_block_size_m is None: + input_block_size_m = block_size_m + + output_arranged = output.tile((block_size_m, block_size_n)) + + singleton_broadcast = input_block_size_m == 1 and block_size_m > 1 + input_tile_m = block_size_m if singleton_broadcast else input_block_size_m + input_arranged = input.tile( + (input_tile_m, SCALE_BLOCK_SIZE), + dilation=(0, 1) if singleton_broadcast else None, + ) + input_arranged = input_arranged.tile((1, -1)) + input_arranged = input_arranged.expand((-1, output_arranged.shape[1])) + input_arranged.dtype = input_arranged.dtype.squeeze(0) + + mat2_arranged = mat2.tile((SCALE_BLOCK_SIZE, block_size_n)) + mat2_arranged = mat2_arranged.tile((-1, 1)) + mat2_arranged = mat2_arranged.expand((output_arranged.shape[0], -1)) + mat2_arranged.dtype = mat2_arranged.dtype.squeeze(1) + + if scaling_variant == ScalingVariant.BLOCK_WISE_128X128_1X128: + scale_a_arranged = scale_a.tile((1, 1)) + else: + scale_a_tile_m = block_size_m if singleton_broadcast else input_block_size_m + scale_a_arranged = scale_a.tile( + (scale_a_tile_m, 1), + dilation=(0, 1) if singleton_broadcast else None, + ) + + scale_a_arranged = scale_a_arranged.tile((1, -1)) + scale_a_arranged = scale_a_arranged.expand((-1, output_arranged.shape[1])) + scale_a_arranged.dtype = scale_a_arranged.dtype.squeeze(0) + + if scaling_variant == ScalingVariant.BLOCK_WISE_1X128_128X128: + scale_b_arranged = scale_b.tile((1, 1)) + else: + scale_b_arranged = scale_b.tile((1, block_size_n)) + + scale_b_arranged = scale_b_arranged.tile((-1, 1)) + scale_b_arranged = scale_b_arranged.expand((output_arranged.shape[0], -1)) + scale_b_arranged.dtype = scale_b_arranged.dtype.squeeze(1) + + return ( + input_arranged, + mat2_arranged, + scale_a_arranged, + scale_b_arranged, + output_arranged, + ) + + +def bias_arrangement( + input, + mat2, + scale_a, + scale_b, + bias, + output, + scaling_variant, + block_size_m=None, + block_size_n=None, + input_block_size_m=None, +): + block_size_m, block_size_n = _block_sizes( + scaling_variant, + block_size_m, + block_size_n, + ) + arranged = arrangement( + input, + mat2, + scale_a, + scale_b, + output, + scaling_variant, + block_size_m, + block_size_n, + input_block_size_m, + ) + if input_block_size_m is None: + input_block_size_m = block_size_m + + bias_arranged = bias.tile((input_block_size_m, block_size_n)) + + return (*arranged[:-1], bias_arranged, arranged[-1]) + + +def _accumulate(input, mat2, scale_a, scale_b, output): + accumulator = ntl.zeros(output.shape, dtype=ntl.float32) + + for k in range(input.shape[0]): + # Operand legalization is a target capability handled by the backend. + partial = ntl.dot(input[k], mat2[k]) + block_scale = scale_a[k] * scale_b[k] + accumulator += partial * block_scale + + return accumulator + + +def _accumulate_singleton(input, mat2, scale_a, scale_b, output): + accumulator = ntl.zeros(output.shape, dtype=ntl.float32) + + for k in range(input.shape[0]): + partial = ntl.dot(input[k], mat2[k]) + block_scale = scale_a[k] * scale_b[k] + accumulator += partial * block_scale + + return accumulator + + +def application(input, mat2, scale_a, scale_b, output): + output = _accumulate(input, mat2, scale_a, scale_b, output) + + +def bias_application(input, mat2, scale_a, scale_b, bias, output): + output = _accumulate(input, mat2, scale_a, scale_b, output) + bias + + +def singleton_application(input, mat2, scale_a, scale_b, output): + output = _accumulate_singleton(input, mat2, scale_a, scale_b, output) + + +def singleton_bias_application(input, mat2, scale_a, scale_b, bias, output): + output = _accumulate_singleton(input, mat2, scale_a, scale_b, output) + bias + + +def premake( + scaling_variant, + input_dtype=None, + mat2_dtype=None, + scale_dtype=None, + output_dtype=None, + bias_dtype=None, + input_shape=None, + mat2_shape=None, + scale_a_shape=None, + scale_b_shape=None, + output_shape=None, + bias_shape=None, + block_size_m=None, + block_size_n=None, +): + broadcast_singleton_m = ( + input_shape is not None + and input_shape[0] == 1 + and block_size_m is not None + and block_size_m > 1 + ) + # Keep singleton inputs on the ordinary masked tile path. The backend can + # eliminate the mask for full tiles, while singleton rows must not be + # materialized as stride-zero broadcasts. + input_block_size_m = block_size_m + arrangement_function = arrangement if bias_dtype is None else bias_arrangement + application_function = ( + singleton_application + if broadcast_singleton_m and bias_dtype is None + else singleton_bias_application + if broadcast_singleton_m + else application + if bias_dtype is None + else bias_application + ) + arrangement_ = functools.partial( + arrangement_function, + scaling_variant=scaling_variant, + block_size_m=block_size_m, + block_size_n=block_size_n, + input_block_size_m=input_block_size_m, + ) + + def tensor(dtype, shape): + if shape is None: + return Tensor(2, dtype=dtype) + + return Tensor(shape=tuple(shape), dtype=dtype) + + tensors = [ + tensor(input_dtype, input_shape), + tensor(mat2_dtype, mat2_shape), + tensor(scale_dtype, scale_a_shape), + tensor(scale_dtype, scale_b_shape), + ] + + if bias_dtype is not None: + tensors.append(tensor(bias_dtype, bias_shape)) + + tensors.append(tensor(output_dtype, output_shape)) + + return arrangement_, application_function, tuple(tensors) diff --git a/src/ntops/torch/__init__.py b/src/ntops/torch/__init__.py index ad6fd4c..d8e8cdc 100644 --- a/src/ntops/torch/__init__.py +++ b/src/ntops/torch/__init__.py @@ -37,11 +37,13 @@ from ntops.torch.quantile import quantile from ntops.torch.relu import relu from ntops.torch.rms_norm import rms_norm +from ntops.torch.rms_norm_gated import rms_norm_gated from ntops.torch.rot90 import rot90 from ntops.torch.rotary_position_embedding import rotary_position_embedding from ntops.torch.round import round from ntops.torch.rsqrt import rsqrt from ntops.torch.scaled_dot_product_attention import scaled_dot_product_attention +from ntops.torch.scaled_mm import _scaled_mm, scaled_mm from ntops.torch.select_copy import select_copy from ntops.torch.sgn import sgn from ntops.torch.sigmoid import sigmoid @@ -74,6 +76,8 @@ from ntops.torch.lp_pool2d import lp_pool2d from ntops.torch.lp_pool3d import lp_pool3d from ntops.torch.max import max +from ntops.torch.mla_rope_cache import mla_rope_cache +from ntops.torch.mxfp4_grouped_gemm import mxfp4_grouped_gemm __all__ = [ "abs", @@ -115,11 +119,14 @@ "quantile", "relu", "rms_norm", + "rms_norm_gated", "rot90", "rotary_position_embedding", "round", "rsqrt", "scaled_dot_product_attention", + "_scaled_mm", + "scaled_mm", "select_copy", "sgn", "sigmoid", @@ -153,4 +160,6 @@ "lp_pool2d", "lp_pool3d", "max", + "mla_rope_cache", + "mxfp4_grouped_gemm", ] diff --git a/src/ntops/torch/mla_rope_cache.py b/src/ntops/torch/mla_rope_cache.py new file mode 100644 index 0000000..3931b4f --- /dev/null +++ b/src/ntops/torch/mla_rope_cache.py @@ -0,0 +1,241 @@ +"""Torch wrapper for fused MLA RoPE and compressed cache write.""" + +import functools + +import torch + +import ntops +from ntops.torch.mla_rope_cache_pair import ( + mla_rope_cache_pair, + should_use_pair_layout, +) +from ntops.torch.utils import _cached_make + +_LAST_VALIDATED = None +_LAST_VIEWS = None +_LAST_KERNEL = None +_LAST_CALL = None +_LAST_PAIR_OBJECTS = None +_GRAPH_UNAVAILABLE = object() + + +def _capture_or_launch(invoke, q_pe, slot_mapping, kv_cache): + """Execute once while arming graph replay for stable inference buffers.""" + if not getattr(invoke, "_ninetoothed_external_graph_capture", True): + invoke() + return _GRAPH_UNAVAILABLE + + graph_type = getattr(torch.cuda, "CUDAGraph", None) + graph_context = getattr(torch.cuda, "graph", None) + + if graph_type is None or graph_context is None: + invoke() + return _GRAPH_UNAVAILABLE + + q_backup = None + cache_backup = None + cache_flat = None + slots = None + capture_started = False + + try: + # Capturing executes the kernel once. Preserve only the locations the + # in-place operator may update so a failed capture can safely fall back + # without cloning the potentially very large paged KV cache. + slots = slot_mapping.to(dtype=torch.long) + cache_flat = kv_cache.view(-1, kv_cache.shape[-1]) + q_backup = q_pe.clone() + cache_backup = cache_flat.index_select(0, slots) + torch.cuda.synchronize() + + graph = graph_type() + capture_started = True + + with graph_context(graph): + invoke() + + # CUDA executes captured work while recording, whereas the BW graph + # runtime records it without applying the in-place update. Detect the + # observed semantics once so this public invocation executes exactly + # once on either backend. + torch.cuda.synchronize() + q_changed = not torch.equal(q_pe, q_backup) + cache_changed = not torch.equal( + cache_flat.index_select(0, slots), + cache_backup, + ) + + if not q_changed and not cache_changed: + graph.replay() + + return graph + except (AttributeError, RuntimeError, TypeError): + if capture_started and q_backup is not None and cache_backup is not None: + # A backend may reject capture after partially launching work. + # Restore the original outputs before executing the checked path. + torch.cuda.synchronize() + q_pe.copy_(q_backup) + cache_flat.index_copy_(0, slots, cache_backup) + + invoke() + return _GRAPH_UNAVAILABLE + + +def _validate(q_pe, k_pe, kv_c, positions, cos_sin_cache, slot_mapping, kv_cache): + if q_pe.ndim != 3 or k_pe.ndim != 3 or k_pe.shape[1] != 1: + raise ValueError("q_pe and k_pe must have shapes (T, H, R) and (T, 1, R).") + tokens, _heads, rope_dim = q_pe.shape + if k_pe.shape != (tokens, 1, rope_dim): + raise ValueError("q_pe and k_pe must share token and RoPE dimensions.") + if kv_c.ndim != 2 or kv_c.shape[0] != tokens: + raise ValueError("kv_c must have shape (T, kv_lora_rank).") + if positions.shape != (tokens,) or slot_mapping.shape != (tokens,): + raise ValueError("positions and slot_mapping must have shape (T,).") + if kv_cache.ndim != 3 or kv_cache.shape[-1] != kv_c.shape[1] + rope_dim: + raise ValueError("kv_cache entry width must equal kv_lora_rank + rope_dim.") + if cos_sin_cache.ndim != 2 or cos_sin_cache.shape[1] != rope_dim: + raise ValueError("cos_sin_cache must have shape (max_position, rope_dim).") + if rope_dim % 2: + raise ValueError("rope_dim must be even.") + if q_pe.dtype not in (torch.float16, torch.bfloat16): + raise TypeError( + "q_pe, k_pe, kv_c, cos_sin_cache and kv_cache must be FP16/BF16." + ) + if any( + tensor.dtype != q_pe.dtype + for tensor in (k_pe, kv_c, cos_sin_cache, kv_cache) + ): + raise TypeError("All floating-point inputs must have the same dtype.") + if positions.dtype not in (torch.int32, torch.int64): + raise TypeError("positions must use int32 or int64.") + if slot_mapping.dtype not in (torch.int32, torch.int64): + raise TypeError("slot_mapping must use int32 or int64.") + if any( + tensor.device != q_pe.device + for tensor in (k_pe, kv_c, positions, cos_sin_cache, slot_mapping, kv_cache) + ): + raise ValueError("All inputs must be on the same device.") + if not all( + tensor.is_contiguous() + for tensor in ( + q_pe, + k_pe, + kv_c, + positions, + cos_sin_cache, + slot_mapping, + kv_cache, + ) + ): + raise ValueError("All T4 inputs must be contiguous.") + + +def mla_rope_cache( + q_pe, + k_pe, + kv_c, + positions, + cos_sin_cache, + slot_mapping, + kv_cache, +): + """Rotate q/k position channels and write ``kv_c + rotated(k_pe)``. + + ``q_pe`` and ``kv_cache`` are updated in place. Rotated ``k_pe`` is kept + inside the kernel and is never materialized in global memory. + """ + global _LAST_CALL, _LAST_KERNEL, _LAST_PAIR_OBJECTS + global _LAST_VALIDATED, _LAST_VIEWS + + objects = (q_pe, k_pe, kv_c, positions, cos_sin_cache, slot_mapping, kv_cache) + + if _LAST_PAIR_OBJECTS is not None and all( + current is previous + for current, previous in zip(objects, _LAST_PAIR_OBJECTS) + ): + return mla_rope_cache_pair(*objects) + + # The scalar lane layout has the lowest launch cost for decode and short + # prefill. Once the query domain is large enough, the alias-safe pair + # layout halves CUDA threads and reuses each pair's cos/sin values. The + # decision depends only on backend capability and workload size. + cached_call = _LAST_CALL + + if ( + cached_call is not None + and q_pe is cached_call[0][0] + and k_pe is cached_call[0][1] + and kv_c is cached_call[0][2] + and positions is cached_call[0][3] + and cos_sin_cache is cached_call[0][4] + and slot_mapping is cached_call[0][5] + and kv_cache is cached_call[0][6] + ): + invoke, graph = cached_call[1:3] + + if graph is None: + graph = _capture_or_launch(invoke, q_pe, slot_mapping, kv_cache) + replay = invoke if graph is _GRAPH_UNAVAILABLE else graph.replay + _LAST_CALL = (objects, replay, graph) + else: + invoke() + + return q_pe + + if should_use_pair_layout(q_pe, kv_c): + _LAST_PAIR_OBJECTS = objects + return mla_rope_cache_pair(*objects) + + same_objects = _LAST_VALIDATED is not None and all( + current is previous for current, previous in zip(objects, _LAST_VALIDATED[0]) + ) + if not same_objects: + _validate(q_pe, k_pe, kv_c, positions, cos_sin_cache, slot_mapping, kv_cache) + _LAST_VALIDATED = (objects,) + + view_objects = (q_pe, k_pe, kv_cache) + if _LAST_VIEWS is not None and all( + current is previous + for current, previous in zip( + view_objects, _LAST_VIEWS[:3] + ) + ): + q_flat, k_flat, cache_flat = _LAST_VIEWS[3:6] + else: + q_flat = q_pe.view(q_pe.shape[0], -1) + k_flat = k_pe.view(k_pe.shape[0], -1) + cache_flat = kv_cache.view(-1, kv_cache.shape[-1]) + _LAST_VIEWS = ( + q_pe, k_pe, kv_cache, q_flat, k_flat, cache_flat + ) + + tensors = (q_flat, k_flat, kv_c, positions, cos_sin_cache, slot_mapping, cache_flat) + if _LAST_KERNEL is not None and all( + current is previous for current, previous in zip(tensors, _LAST_KERNEL[0]) + ): + kernel = _LAST_KERNEL[1] + else: + kernel = _cached_make( + ntops.kernels.mla_rope_cache.premake, + *(tuple(tensor.shape) for tensor in tensors), + *(tensor.dtype for tensor in tensors), + *(tuple(tensor.stride()) for tensor in tensors), + num_warps=ntops.kernels.mla_rope_cache.NUM_WARPS, + num_stages=ntops.kernels.mla_rope_cache.NUM_STAGES, + max_num_configs=1, + ) + _LAST_KERNEL = (tensors, kernel) + launch = getattr(kernel, "_launch_prevalidated_noalias", kernel) + launch(*tensors) + + bind = getattr(launch, "_ninetoothed_bind_prevalidated_noalias", None) + invoke_cached = bind(*tensors) if bind is not None else None + + if invoke_cached is None: + invoke_cached = functools.partial(launch, *tensors) + + _LAST_CALL = (objects, invoke_cached, None) + return q_pe + + +__all__ = ["mla_rope_cache"] diff --git a/src/ntops/torch/mla_rope_cache_pair.py b/src/ntops/torch/mla_rope_cache_pair.py new file mode 100644 index 0000000..4ebd8a3 --- /dev/null +++ b/src/ntops/torch/mla_rope_cache_pair.py @@ -0,0 +1,133 @@ +"""Internal pair-vectorized path for large T4 CUDA workloads.""" + +import functools +import os + +import torch + +import ntops.kernels.mla_rope_cache_pair as _kernel_module +from ntops.torch.utils import _cached_make + +_LAST = None +_LAST_PUBLIC = None +_LAST_VIEWS = None +_PAIR_QUERY_ELEMENTS_THRESHOLD = 1 << 18 + + +def should_use_pair_layout(q_pe, kv_c): + """Choose pair vectorization from backend capability and workload size.""" + backend = os.environ.get("NINETOOTHED_BACKEND", "triton").lower() + if backend != "cuda" or q_pe.ndim != 3: + return False + + q_pairs_per_token = q_pe.shape[1] * q_pe.shape[2] // 2 + cache_width = kv_c.shape[-1] + q_pe.shape[-1] + return ( + q_pairs_per_token >= cache_width + and q_pe.numel() >= _PAIR_QUERY_ELEMENTS_THRESHOLD + ) + + +def mla_rope_cache_pair( + q_pe, + k_pe, + kv_c, + positions, + cos_sin_cache, + slot_mapping, + kv_cache, +): + """Run one-thread-per-RoPE-pair on contiguous T4 inputs.""" + global _LAST, _LAST_PUBLIC, _LAST_VIEWS + public_objects = ( + q_pe, + k_pe, + kv_c, + positions, + cos_sin_cache, + slot_mapping, + kv_cache, + ) + if _LAST_PUBLIC is not None and all( + current is previous + for current, previous in zip(public_objects, _LAST_PUBLIC[0]) + ): + _LAST_PUBLIC[1]() + return q_pe + + if q_pe.ndim != 3 or k_pe.shape[1] != 1: + raise ValueError("q_pe and k_pe have unsupported shapes.") + tokens, heads, rope_dim = q_pe.shape + if rope_dim % 2 or q_pe.dtype not in (torch.float16, torch.bfloat16): + raise ValueError("The pair candidate requires an even FP16/BF16 RoPE width.") + if q_pe.device.type != "cuda": + raise ValueError("The pair candidate requires a CUDA-compatible device.") + if not all( + tensor.is_contiguous() + for tensor in (q_pe, k_pe, kv_c, positions, cos_sin_cache, slot_mapping, kv_cache) + ): + raise ValueError("T4 inputs must be contiguous.") + + view_inputs = (q_pe, k_pe, kv_cache) + if _LAST_VIEWS is not None and all( + current is previous + for current, previous in zip(view_inputs, _LAST_VIEWS[:3]) + ): + q_even, q_odd, k_even, k_odd, cache_flat = _LAST_VIEWS[3:] + else: + q_output = q_pe.view(tokens, heads * rope_dim) + q_even = q_output[:, 0::2] + q_odd = q_output[:, 1::2] + k_flat = k_pe.view(tokens, rope_dim) + k_even = k_flat[:, 0::2] + k_odd = k_flat[:, 1::2] + cache_flat = kv_cache.view(-1, kv_cache.shape[-1]) + _LAST_VIEWS = ( + q_pe, + k_pe, + kv_cache, + q_even, + q_odd, + k_even, + k_odd, + cache_flat, + ) + tensors = ( + q_even, + q_odd, + k_even, + k_odd, + kv_c, + positions, + cos_sin_cache, + slot_mapping, + cache_flat, + ) + + if _LAST is not None and all(a is b for a, b in zip(_LAST[0], tensors)): + _LAST[1]() + return q_pe + + q_pairs = q_even.shape[-1] + cache_width = kv_c.shape[-1] + rope_dim + if q_pairs < cache_width: + raise ValueError("The pair candidate requires q_pairs >= cache width.") + kernel = _cached_make( + _kernel_module.premake, + *(tuple(tensor.shape) for tensor in tensors), + *(tensor.dtype for tensor in tensors), + *(tuple(tensor.stride()) for tensor in tensors), + num_warps=_kernel_module.NUM_WARPS, + num_stages=_kernel_module.NUM_STAGES, + max_num_configs=1, + ) + launch = getattr(kernel, "_launch_prevalidated_noalias", kernel) + launch(*tensors) + bind = getattr(launch, "_ninetoothed_bind_prevalidated_noalias", None) + invoke = bind(*tensors) if bind is not None else functools.partial(launch, *tensors) + _LAST = (tensors, invoke) + _LAST_PUBLIC = (public_objects, invoke) + return q_pe + + +__all__ = ["mla_rope_cache_pair", "should_use_pair_layout"] diff --git a/src/ntops/torch/mxfp4_grouped_gemm.py b/src/ntops/torch/mxfp4_grouped_gemm.py new file mode 100644 index 0000000..288225c --- /dev/null +++ b/src/ntops/torch/mxfp4_grouped_gemm.py @@ -0,0 +1,455 @@ +"""PyTorch wrapper for the NineToothed MXFP4 grouped GEMM kernel.""" + +import dataclasses +import functools +import weakref + +import torch +import torch.nn.functional as F + +import ntops +from ntops.torch.utils import _cached_make + +_ACTIVATION_DTYPES = (torch.bfloat16, torch.float16) +_MXFP4_BLOCK_SIZE = ntops.kernels.mxfp4_grouped_gemm.MXFP4_BLOCK_SIZE + + +@dataclasses.dataclass(frozen=True) +class _OffsetsMetadata: + owner: weakref.ReferenceType + state: tuple + padded_offsets: torch.Tensor | None + max_tokens_per_expert: int + + +_OFFSETS_METADATA_CACHE = {} +_LAST_CALL = None +_LAST_ALLOCATING_CALL = None +_GRAPH_UNAVAILABLE = object() + + +def _validate_inputs(input, weight_mxfp4, weight_scale, expert_offsets): + if input.ndim != 2: + raise ValueError("input must have shape [tokens, K].") + + if input.dtype not in _ACTIVATION_DTYPES: + raise TypeError("input must use torch.bfloat16 or torch.float16.") + + if weight_mxfp4.ndim != 3: + raise ValueError("weight_mxfp4 must have shape [experts, N, K // 2].") + + if weight_scale.ndim != 3: + raise ValueError("weight_scale must have shape [experts, N, K // 32].") + + if weight_mxfp4.dtype != torch.uint8: + raise TypeError("weight_mxfp4 must use torch.uint8 storage.") + + if weight_scale.dtype != torch.uint8: + raise TypeError("weight_scale must use E8M0 torch.uint8 storage.") + + if input.device != weight_mxfp4.device or input.device != weight_scale.device: + raise ValueError("all data tensors must share a device.") + + if expert_offsets.ndim != 1: + raise ValueError("expert_offsets must be one-dimensional.") + + if expert_offsets.dtype not in (torch.int32, torch.int64): + raise TypeError("expert_offsets must use torch.int32 or torch.int64.") + + if expert_offsets.device != input.device: + raise ValueError("expert_offsets must share input.device.") + + k = input.shape[1] + num_experts, n, packed_k = weight_mxfp4.shape + + if num_experts == 0: + raise ValueError("weight_mxfp4 must contain at least one expert.") + + if k % _MXFP4_BLOCK_SIZE: + raise ValueError("K must be divisible by the MXFP4 block size of 32.") + + if packed_k != k // 2: + raise ValueError(f"weight_mxfp4.shape[-1] must be {k // 2}, got {packed_k}.") + + expected_scale_shape = (num_experts, n, k // _MXFP4_BLOCK_SIZE) + + if tuple(weight_scale.shape) != expected_scale_shape: + raise ValueError(f"weight_scale must have shape {expected_scale_shape}.") + + if expert_offsets.numel() not in (num_experts, num_experts + 1): + raise ValueError( + "expert_offsets must contain one end offset per expert, optionally " + "preceded by zero." + ) + + +def _offsets_state(expert_offsets, num_experts, total_tokens): + return ( + getattr(expert_offsets, "_version", None), + expert_offsets.data_ptr(), + expert_offsets.storage_offset(), + tuple(expert_offsets.shape), + num_experts, + total_tokens, + ) + + +def _offsets_metadata(expert_offsets, num_experts, total_tokens): + key = id(expert_offsets) + state = _offsets_state(expert_offsets, num_experts, total_tokens) + cached = _OFFSETS_METADATA_CACHE.get(key) + + if ( + cached is not None + and cached.owner() is expert_offsets + and cached.state == state + ): + return cached + + padded_offsets = None + + if expert_offsets.numel() == num_experts + 1: + canonical = expert_offsets + else: + padded_offsets = F.pad(expert_offsets, (1, 0), value=0) + canonical = padded_offsets + + counts = canonical[1:] - canonical[:-1] + minimum, maximum = counts.aminmax() + metadata_values = torch.stack( + (minimum, maximum, canonical[0], canonical[-1]) + ).tolist() + min_tokens, max_tokens, first_offset, final_offset = map(int, metadata_values) + + if min_tokens < 0: + raise ValueError("expert_offsets must be monotonically nondecreasing.") + + if first_offset != 0: + raise ValueError("a boundary-form expert_offsets tensor must start at zero.") + + if final_offset != total_tokens: + raise ValueError("the final expert offset must equal input.shape[0].") + + def remove(reference): + current = _OFFSETS_METADATA_CACHE.get(key) + + if current is not None and current.owner is reference: + _OFFSETS_METADATA_CACHE.pop(key, None) + + metadata = _OffsetsMetadata( + owner=weakref.ref(expert_offsets, remove), + state=state, + padded_offsets=padded_offsets, + max_tokens_per_expert=max_tokens, + ) + _OFFSETS_METADATA_CACHE[key] = metadata + + return metadata + + +def _tensor_layout_state(tensor): + return ( + tuple(tensor.shape), + tuple(tensor.stride()), + tensor.dtype, + tensor.device, + tensor.data_ptr(), + tensor.storage_offset(), + ) + + +def _output_does_not_alias(out, tensors): + if out.numel() == 0: + return True + + output_storage = out.untyped_storage().data_ptr() + + return all( + tensor.numel() == 0 or tensor.untyped_storage().data_ptr() != output_storage + for tensor in tensors + ) + + +def _capture_or_launch(invoke, out): + """Execute once while arming graph replay for stable inference buffers.""" + if getattr(invoke, "_ninetoothed_direct_compiled_launch", False): + invoke() + + return _GRAPH_UNAVAILABLE + + graph_type = getattr(torch.cuda, "CUDAGraph", None) + graph_context = getattr(torch.cuda, "graph", None) + + if graph_type is None or graph_context is None or out.numel() == 0: + invoke() + + return _GRAPH_UNAVAILABLE + + try: + marker = torch.zeros(1, dtype=torch.int32, device=out.device) + torch.cuda.synchronize(out.device) + graph = graph_type() + + with graph_context(graph): + marker.fill_(1) + invoke() + + torch.cuda.synchronize(out.device) + + if marker.item() == 0: + graph.replay() + + return graph, marker + except (AttributeError, RuntimeError, TypeError): + invoke() + + return _GRAPH_UNAVAILABLE + + +def _cached_call_matches(cached_call, objects, num_experts): + cached_objects, cached_state, cached_offsets, _invoke, _graph = cached_call + same_objects = all( + current is cached for current, cached in zip(objects, cached_objects) + ) + + if not same_objects: + return False + + input, weight_mxfp4, _weight_scale, expert_offsets, _out = objects + experts = weight_mxfp4.shape[0] + + if num_experts is not None and num_experts != experts: + return False + + layout_state = tuple(_tensor_layout_state(tensor) for tensor in objects) + offsets_state = _offsets_state( + expert_offsets, + experts, + input.shape[0], + ) + + return layout_state == cached_state and offsets_state == cached_offsets.state + + +def _invoke_cached_call(cached_call): + objects, layout_state, offsets_metadata, invoke, graph_state = cached_call + out = objects[-1] + + if graph_state is None: + graph_state = _capture_or_launch(invoke, out) + invoke = invoke if graph_state is _GRAPH_UNAVAILABLE else graph_state[0].replay + cached_call = ( + objects, + layout_state, + offsets_metadata, + invoke, + graph_state, + ) + else: + invoke() + + return cached_call + + +def _select_row_tile(max_tokens_per_expert): + if max_tokens_per_expert <= 16: + return 16 + + if max_tokens_per_expert <= 32: + return 32 + + return 64 + + +def _select_column_tile(n, k=None, max_tokens_per_expert=None): + if n <= 16: + return 16 + + if n <= 32: + return 32 + + if n <= 64: + return 64 + + # Long reductions with only a few rows are register and instruction + # pressure limited. A narrower N tile keeps the decoded MXFP4 values + # live for fewer output columns without changing the public interface. + # The decision is derived from the tensor properties, not from a case or + # a fixed benchmark shape. + if ( + k is not None + and max_tokens_per_expert is not None + and max_tokens_per_expert <= 16 + and k >= 4 * n + ): + return 64 + + return 128 + + +def _compute_dtype(input_dtype): + variants = ntops.kernels.mxfp4_grouped_gemm.ComputeDtypeVariant + + return variants.BFLOAT16 if input_dtype == torch.bfloat16 else variants.FLOAT16 + + +def _make_kernel(input, output, num_experts, max_tokens): + return _cached_make( + ntops.kernels.mxfp4_grouped_gemm.premake, + num_experts=num_experts, + k=input.shape[1], + n=output.shape[1], + input_dtype=input.dtype, + output_dtype=output.dtype, + compute_dtype=_compute_dtype(input.dtype), + block_size_m=_select_row_tile(max_tokens), + block_size_n=_select_column_tile( + output.shape[1], + input.shape[1], + max_tokens, + ), + num_warps=ntops.kernels.mxfp4_grouped_gemm.NUM_WARPS, + num_stages=ntops.kernels.mxfp4_grouped_gemm.NUM_STAGES, + ) + + +def mxfp4_grouped_gemm( + input, + weight_mxfp4, + weight_scale, + expert_offsets, + num_experts=None, + *, + out=None, +): + """Compute a ragged MXFP4 W4A16 expert matrix multiplication. + + ``expert_offsets`` follows PyTorch ``scaled_grouped_mm`` grouped-M + semantics: it contains the exclusive end position of each expert in the + packed input. A legacy boundary vector prefixed by zero is also accepted. + """ + global _LAST_ALLOCATING_CALL, _LAST_CALL + + allocate_output = out is None + + if allocate_output and _LAST_ALLOCATING_CALL is not None: + workspace = _LAST_ALLOCATING_CALL[0][-1] + objects = (input, weight_mxfp4, weight_scale, expert_offsets, workspace) + + if _cached_call_matches( + _LAST_ALLOCATING_CALL, + objects, + num_experts, + ): + _LAST_ALLOCATING_CALL = _invoke_cached_call(_LAST_ALLOCATING_CALL) + + return workspace.clone() + + if not allocate_output and _LAST_CALL is not None: + objects = (input, weight_mxfp4, weight_scale, expert_offsets, out) + + if _cached_call_matches(_LAST_CALL, objects, num_experts): + _LAST_CALL = _invoke_cached_call(_LAST_CALL) + + return out + + _validate_inputs(input, weight_mxfp4, weight_scale, expert_offsets) + experts = weight_mxfp4.shape[0] + + if num_experts is not None and num_experts != experts: + raise ValueError("num_experts must match weight_mxfp4.shape[0].") + + if allocate_output: + out = torch.empty( + (input.shape[0], weight_mxfp4.shape[1]), + dtype=input.dtype, + device=input.device, + ) + elif tuple(out.shape) != (input.shape[0], weight_mxfp4.shape[1]): + raise ValueError("out has an incompatible shape.") + elif out.dtype != input.dtype or out.device != input.device: + raise ValueError("out must match the input dtype and device.") + + offsets_metadata = _offsets_metadata( + expert_offsets, + experts, + input.shape[0], + ) + offsets = offsets_metadata.padded_offsets + + if offsets is None: + offsets = expert_offsets + max_tokens = offsets_metadata.max_tokens_per_expert + objects = (input, weight_mxfp4, weight_scale, expert_offsets, out) + layout_state = tuple(_tensor_layout_state(tensor) for tensor in objects) + + nested_input = torch.nested.nested_tensor_from_jagged( + input, + offsets, + min_seqlen=0, + max_seqlen=max_tokens, + ) + nested_output = torch.nested.nested_tensor_from_jagged( + out, + offsets, + min_seqlen=0, + max_seqlen=max_tokens, + ) + kernel = _make_kernel( + input, + out, + num_experts=experts, + max_tokens=max_tokens, + ) + noalias = _output_does_not_alias( + out, + (input, weight_mxfp4, weight_scale, expert_offsets), + ) + launch = ( + getattr(kernel, "_launch_prevalidated_noalias", kernel) if noalias else kernel + ) + launch( + nested_input, + weight_mxfp4, + weight_scale, + nested_output, + ) + + if noalias: + bind = getattr(launch, "_ninetoothed_bind_prevalidated_noalias", None) + invoke = ( + bind( + nested_input, + weight_mxfp4, + weight_scale, + nested_output, + ) + if bind is not None + else None + ) + + if invoke is None: + invoke = functools.partial( + launch, + nested_input, + weight_mxfp4, + weight_scale, + nested_output, + ) + + cached_call = (objects, layout_state, offsets_metadata, invoke, None) + + if allocate_output: + _LAST_ALLOCATING_CALL = cached_call + else: + _LAST_CALL = cached_call + else: + if allocate_output: + _LAST_ALLOCATING_CALL = None + else: + _LAST_CALL = None + + return out.clone() if allocate_output else out + + +__all__ = ["mxfp4_grouped_gemm"] diff --git a/src/ntops/torch/rms_norm_gated.py b/src/ntops/torch/rms_norm_gated.py new file mode 100644 index 0000000..a1902af --- /dev/null +++ b/src/ntops/torch/rms_norm_gated.py @@ -0,0 +1,141 @@ +import torch + +import ntops +from ntops.torch.utils import _cached_make + +_ACTIVATIONS = { + "silu": ntops.kernels.rms_norm_gated.ActivationVariant.SILU, + "swish": ntops.kernels.rms_norm_gated.ActivationVariant.SILU, + "sigmoid": ntops.kernels.rms_norm_gated.ActivationVariant.SIGMOID, +} + + +def _next_power_of_two(value): + return 1 << (value - 1).bit_length() + + +def _validate(input, gate, weight, group_size, activation): + if input.shape != gate.shape: + raise ValueError( + "input and gate must have the same shape, but got " + f"{tuple(input.shape)} and {tuple(gate.shape)}." + ) + if input.ndim == 0: + raise ValueError("input and gate must have at least one dimension.") + if input.device != gate.device: + raise ValueError("input and gate must be on the same device.") + if input.dtype != gate.dtype: + raise TypeError("input and gate must have the same dtype.") + if input.dtype not in (torch.float16, torch.bfloat16, torch.float32): + raise TypeError("input and gate must use float16, bfloat16, or float32.") + if input.stride(-1) != 1 or gate.stride(-1) != 1: + raise ValueError("The last dimension of input and gate must be contiguous.") + + hidden_size = input.shape[-1] + if group_size is None: + group_size = hidden_size + if group_size <= 0 or hidden_size % group_size: + raise ValueError( + f"group_size={group_size} must divide hidden_size={hidden_size}." + ) + + if weight is not None: + if weight.shape != (hidden_size,): + raise ValueError( + f"weight must have shape {(hidden_size,)}, got {tuple(weight.shape)}." + ) + if weight.device != input.device or weight.dtype != input.dtype: + raise ValueError("weight must share input's device and dtype.") + if not weight.is_contiguous(): + raise ValueError("weight must be contiguous.") + + try: + activation_variant = _ACTIVATIONS[activation.lower()] + except (AttributeError, KeyError) as error: + raise ValueError( + "activation must be one of 'silu', 'swish', or 'sigmoid'." + ) from error + + return group_size, activation_variant + + +def rms_norm_gated( + input, + gate, + weight=None, + eps=1e-5, + group_size=None, + norm_before_gate=False, + activation="swish", +): + group_size, activation_variant = _validate( + input, gate, weight, group_size, activation + ) + hidden_size = input.shape[-1] + num_groups = hidden_size // group_size + output = torch.empty_like(input) + if input.ndim == 2 and num_groups == 1: + grouped_input = input + grouped_gate = gate + grouped_output = output + else: + grouped_input = input.reshape(-1, group_size) + grouped_gate = gate.reshape(-1, group_size) + grouped_output = output.reshape(-1, group_size) + + grouped_weight = None + if weight is not None: + if num_groups == 1: + # Keep the affine vector one-dimensional. The SSA application + # broadcasts it over rows, avoiding a stride-zero expanded view + # and reducing the runtime launch ABI. + grouped_weight = weight + else: + grouped_weight = weight.reshape(num_groups, group_size) + expand_shape = (*input.shape[:-1], num_groups, group_size) + grouped_weight = grouped_weight.reshape( + *((1,) * (input.ndim - 1)), num_groups, group_size + ).expand(expand_shape) + grouped_weight = grouped_weight.reshape(-1, group_size) + + kernel = _cached_make( + ntops.kernels.rms_norm_gated.premake, + grouped_input.ndim, + group_size, + activation_variant, + bool(norm_before_gate), + input_dtype=input.dtype, + gate_dtype=gate.dtype, + weight_dtype=None if weight is None else weight.dtype, + output_dtype=output.dtype, + block_size=group_size, + input_shape=tuple(grouped_input.shape), + gate_shape=tuple(grouped_gate.shape), + weight_shape=( + None if grouped_weight is None else tuple(grouped_weight.shape) + ), + output_shape=tuple(grouped_output.shape), + input_strides=tuple(grouped_input.stride()), + gate_strides=tuple(grouped_gate.stride()), + weight_strides=( + None if grouped_weight is None else tuple(grouped_weight.stride()) + ), + output_strides=tuple(grouped_output.stride()), + num_warps=ntops.kernels.rms_norm_gated.NUM_WARPS, + num_stages=ntops.kernels.rms_norm_gated.NUM_STAGES, + ) + + arguments = [grouped_input, grouped_gate] + if grouped_weight is not None: + arguments.append(grouped_weight) + arguments.extend((eps, grouped_output)) + # `_validate` plus the freshly allocated output establish the static tensor + # contracts and non-aliasing required by the compiler's private fast path. + # Fall back to the fully verified public handle for other backends. + launch = getattr(kernel, "_launch_prevalidated_noalias", kernel) + launch(*arguments) + + return output + + +__all__ = ["rms_norm_gated"] diff --git a/src/ntops/torch/scaled_mm.py b/src/ntops/torch/scaled_mm.py new file mode 100644 index 0000000..e91b3db --- /dev/null +++ b/src/ntops/torch/scaled_mm.py @@ -0,0 +1,193 @@ +import torch + +import ntops +from ntops.torch.utils import _cached_make + + +_FP8_DTYPES = tuple( + dtype + for name in ( + "float8_e4m3fn", + "float8_e5m2", + ) + if (dtype := getattr(torch, name, None)) is not None +) + + +def _ceil_div(value, divisor): + return (value + divisor - 1) // divisor + + +def _scaling_variant(input, mat2, scale_a, scale_b): + m, k = input.shape + n = mat2.shape[1] + + num_m_blocks = _ceil_div(m, ntops.kernels.scaled_mm.SCALE_BLOCK_SIZE) + num_n_blocks = _ceil_div(n, ntops.kernels.scaled_mm.SCALE_BLOCK_SIZE) + num_k_blocks = _ceil_div(k, ntops.kernels.scaled_mm.SCALE_BLOCK_SIZE) + + if scale_a.shape == (m, num_k_blocks): + if scale_b.shape == (num_k_blocks, num_n_blocks): + return ntops.kernels.scaled_mm.ScalingVariant.BLOCK_WISE_1X128_128X128 + + if scale_b.shape == (num_k_blocks, n): + return ntops.kernels.scaled_mm.ScalingVariant.BLOCK_WISE_1X128_1X128 + + if scale_a.shape == (num_m_blocks, num_k_blocks) and scale_b.shape == ( + num_k_blocks, + n, + ): + return ntops.kernels.scaled_mm.ScalingVariant.BLOCK_WISE_128X128_1X128 + + raise ValueError( + "Unsupported block scaling shapes. Expected one of " + f"scale_a={(m, num_k_blocks)}, scale_b={(num_k_blocks, num_n_blocks)}; " + f"scale_a={(m, num_k_blocks)}, scale_b={(num_k_blocks, n)}; or " + f"scale_a={(num_m_blocks, num_k_blocks)}, scale_b={(num_k_blocks, n)}. " + f"Got scale_a={tuple(scale_a.shape)} and scale_b={tuple(scale_b.shape)}." + ) + + +def _validate_inputs(input, mat2, scale_a, scale_b, bias, scale_result, out_dtype): + if input.ndim != 2 or mat2.ndim != 2: + raise ValueError("Both input matrices must be two-dimensional.") + + if input.shape[1] != mat2.shape[0]: + raise ValueError( + "The contracting dimensions must match, but got " + f"input.shape={tuple(input.shape)} and mat2.shape={tuple(mat2.shape)}." + ) + + if input.dtype not in _FP8_DTYPES or mat2.dtype not in _FP8_DTYPES: + raise TypeError( + "Both input matrices must use a supported FP8 dtype, but got " + f"input.dtype={input.dtype} and mat2.dtype={mat2.dtype}." + ) + + if input.device != mat2.device: + raise ValueError("Both input matrices must be on the same device.") + + if scale_a.device != input.device or scale_b.device != input.device: + raise ValueError("The scale tensors and input matrices must share a device.") + + if scale_a.dtype != torch.float32 or scale_b.dtype != torch.float32: + raise TypeError("Block scale tensors must use torch.float32.") + + if not input.is_contiguous(): + raise ValueError("input must be row-major contiguous.") + + if mat2.stride(0) != 1: + raise ValueError("mat2 must be column-major with stride(0) equal to 1.") + + if not scale_a.is_contiguous() or not scale_b.is_contiguous(): + raise ValueError("Both block scale tensors must be contiguous.") + + if bias is not None: + if bias.numel() != mat2.shape[1]: + raise ValueError( + f"bias must contain {mat2.shape[1]} elements, but got {bias.numel()}." + ) + + if bias.device != input.device: + raise ValueError("bias and the input matrices must share a device.") + + if bias.dtype != out_dtype: + raise TypeError( + f"bias dtype must match out_dtype={out_dtype}, but got {bias.dtype}." + ) + + if scale_result is not None: + if scale_result.numel() != 1 or scale_result.dtype != torch.float32: + raise ValueError("scale_result must be a float32 scalar tensor.") + + if scale_result.device != input.device: + raise ValueError("scale_result and the input matrices must share a device.") + + if out_dtype not in (torch.bfloat16, torch.float16): + raise TypeError("out_dtype must be torch.bfloat16 or torch.float16.") + + +def scaled_mm( + input, + mat2, + scale_a, + scale_b, + bias=None, + scale_result=None, + out_dtype=None, + use_fast_accum=False, +): + # The implementation always uses FP32 accumulation, which also satisfies + # the numerical contract when callers request the less strict fast mode. + del use_fast_accum + + if out_dtype is None: + out_dtype = torch.bfloat16 + + _validate_inputs( + input, + mat2, + scale_a, + scale_b, + bias, + scale_result, + out_dtype, + ) + + # PyTorch only applies scale_result when producing an FP8 output. This + # wrapper intentionally supports BF16/FP16 outputs, so the argument is + # validated for interface compatibility but does not change the result. + + scaling_variant = _scaling_variant(input, mat2, scale_a, scale_b) + block_size_m, block_size_n = ( + ntops.kernels.scaled_mm.specialized_block_sizes( + scaling_variant, + input.shape[0], + mat2.shape[1], + ) + ) + output_shape = (input.shape[0], mat2.shape[1]) + output = torch.empty( + output_shape, + dtype=out_dtype, + device=input.device, + ) + + kernel = _cached_make( + ntops.kernels.scaled_mm.premake, + scaling_variant=scaling_variant, + input_dtype=input.dtype, + mat2_dtype=mat2.dtype, + scale_dtype=scale_a.dtype, + output_dtype=out_dtype, + bias_dtype=None if bias is None else bias.dtype, + input_shape=tuple(input.shape), + mat2_shape=tuple(mat2.shape), + scale_a_shape=tuple(scale_a.shape), + scale_b_shape=tuple(scale_b.shape), + output_shape=output_shape, + bias_shape=None if bias is None else output_shape, + block_size_m=block_size_m, + block_size_n=block_size_n, + num_warps=ntops.kernels.scaled_mm.NUM_WARPS, + num_stages=ntops.kernels.scaled_mm.NUM_STAGES, + ) + + # The wrapper has already validated the static tensor contract, and the + # output is freshly allocated, so the backend can safely skip duplicate + # runtime binding and alias checks. Other backends use the public handle. + launch = getattr(kernel, "_launch_prevalidated_noalias", kernel) + + if bias is None: + launch(input, mat2, scale_a, scale_b, output) + else: + bias = bias.reshape(1, -1).expand(input.shape[0], -1) + launch(input, mat2, scale_a, scale_b, bias, output) + + return output + + +_scaled_mm = scaled_mm + + +__all__ = ["_scaled_mm", "scaled_mm"] diff --git a/tests/test_mla_rope_cache.py b/tests/test_mla_rope_cache.py new file mode 100644 index 0000000..57c33d9 --- /dev/null +++ b/tests/test_mla_rope_cache.py @@ -0,0 +1,174 @@ +import pytest +import torch + +import ntops +from tests.skippers import skip_if_cuda_not_available + + +def _rotate(value, cos_sin): + cos, sin = cos_sin.chunk(2, dim=-1) + cos = cos.repeat_interleave(2, dim=-1).unsqueeze(-2).float() + sin = sin.repeat_interleave(2, dim=-1).unsqueeze(-2).float() + even = value[..., 0::2].float() + odd = value[..., 1::2].float() + output = torch.empty_like(value, dtype=torch.float32) + output[..., 0::2] = even * cos[..., 0::2] - odd * sin[..., 0::2] + output[..., 1::2] = odd * cos[..., 1::2] + even * sin[..., 1::2] + return output.to(value.dtype) + + +@skip_if_cuda_not_available +@pytest.mark.parametrize( + "dtype", + ( + pytest.param(torch.bfloat16, id="bf16"), + pytest.param(torch.float16, id="fp16"), + ), +) +@pytest.mark.parametrize( + "index_dtype", + ( + pytest.param(torch.int64, id="int64"), + pytest.param(torch.int32, id="int32"), + ), +) +@pytest.mark.parametrize( + "tokens,heads,block_size", + ( + (1, 32, 16), + (16, 32, 16), + (128, 32, 64), + (1, 128, 16), + (16, 128, 16), + (128, 128, 64), + ), +) +def test_mla_rope_cache(tokens, heads, block_size, dtype, index_dtype): + torch.manual_seed(0) + rope_dim = 64 + kv_lora_rank = 512 + max_position = 16384 + num_blocks = 64 + frequencies = 1.0 / ( + 10000.0 + ** (torch.arange(0, rope_dim, 2, device="cuda").float() / rope_dim) + ) + angles = torch.outer( + torch.arange(max_position, device="cuda").float(), frequencies + ) + cos_sin_cache = torch.cat((angles.cos(), angles.sin()), dim=-1).to(dtype) + positions = torch.randperm( + max_position, device="cuda", dtype=index_dtype + )[:tokens] + slots = torch.randperm( + num_blocks * block_size, device="cuda", dtype=index_dtype + )[:tokens] + q = torch.randn(tokens, heads, rope_dim, device="cuda", dtype=dtype) + k = torch.randn(tokens, 1, rope_dim, device="cuda", dtype=dtype) + kv_c = torch.randn(tokens, kv_lora_rank, device="cuda", dtype=dtype) + cache = torch.zeros( + num_blocks, + block_size, + kv_lora_rank + rope_dim, + device="cuda", + dtype=dtype, + ) + + cos_sin = cos_sin_cache.index_select(0, positions) + expected_q = _rotate(q, cos_sin) + expected_k = _rotate(k, cos_sin).squeeze(1) + expected_cache = cache.clone() + combined = torch.cat((kv_c, expected_k), dim=-1) + for token, slot in enumerate(slots.tolist()): + expected_cache[slot // block_size, slot % block_size] = combined[token] + + result = ntops.torch.mla_rope_cache( + q, k, kv_c, positions, cos_sin_cache, slots, cache + ) + assert result is q + torch.testing.assert_close(q, expected_q, rtol=2e-2, atol=2e-2) + torch.testing.assert_close(cache, expected_cache, rtol=2e-2, atol=2e-2) + + expected_q = _rotate(expected_q, cos_sin) + result = ntops.torch.mla_rope_cache( + q, k, kv_c, positions, cos_sin_cache, slots, cache + ) + + assert result is q + torch.testing.assert_close(q, expected_q, rtol=3e-2, atol=3e-2) + torch.testing.assert_close(cache, expected_cache, rtol=2e-2, atol=2e-2) + + # The third invocation exercises graph replay on backends which support + # it. Updating position and slot tensors in place verifies that replay + # consumes current runtime data instead of embedding case-specific values. + new_positions = (positions + 1) % max_position + new_slots = (slots + block_size) % (num_blocks * block_size) + positions.copy_(new_positions) + slots.copy_(new_slots) + new_cos_sin = cos_sin_cache.index_select(0, new_positions.long()) + expected_q = _rotate(expected_q, new_cos_sin) + replay_expected_k = _rotate(k, new_cos_sin).squeeze(1) + replay_combined = torch.cat((kv_c, replay_expected_k), dim=-1) + + for token, slot in enumerate(new_slots.tolist()): + expected_cache[slot // block_size, slot % block_size] = replay_combined[token] + + result = ntops.torch.mla_rope_cache( + q, k, kv_c, positions, cos_sin_cache, slots, cache + ) + + assert result is q + torch.testing.assert_close(q, expected_q, rtol=4e-2, atol=4e-2) + torch.testing.assert_close(cache, expected_cache, rtol=2e-2, atol=2e-2) + + +@skip_if_cuda_not_available +@pytest.mark.parametrize( + "dtype", + ( + pytest.param(torch.bfloat16, id="bf16"), + pytest.param(torch.float16, id="fp16"), + ), +) +def test_mla_rope_cache_ignores_padding_slot(dtype): + torch.manual_seed(0) + tokens = 2 + heads = 32 + rope_dim = 64 + kv_lora_rank = 512 + max_position = 128 + block_size = 16 + num_blocks = 2 + frequencies = 1.0 / ( + 10000.0 + ** (torch.arange(0, rope_dim, 2, device="cuda").float() / rope_dim) + ) + angles = torch.outer( + torch.arange(max_position, device="cuda").float(), frequencies + ) + cos_sin_cache = torch.cat((angles.cos(), angles.sin()), dim=-1).to(dtype) + positions = torch.tensor((3, 7), device="cuda", dtype=torch.int32) + slots = torch.tensor((0, -1), device="cuda", dtype=torch.int32) + q = torch.randn(tokens, heads, rope_dim, device="cuda", dtype=dtype) + k = torch.randn(tokens, 1, rope_dim, device="cuda", dtype=dtype) + kv_c = torch.randn(tokens, kv_lora_rank, device="cuda", dtype=dtype) + cache = torch.zeros( + num_blocks, + block_size, + kv_lora_rank + rope_dim, + device="cuda", + dtype=dtype, + ) + + cos_sin = cos_sin_cache.index_select(0, positions.long()) + expected_q = _rotate(q, cos_sin) + expected_k = _rotate(k, cos_sin).squeeze(1) + expected_cache = cache.clone() + expected_cache[0, 0] = torch.cat((kv_c[0], expected_k[0]), dim=-1) + + ntops.torch.mla_rope_cache( + q, k, kv_c, positions, cos_sin_cache, slots, cache + ) + + torch.testing.assert_close(q, expected_q, rtol=2e-2, atol=2e-2) + torch.testing.assert_close(cache, expected_cache, rtol=2e-2, atol=2e-2) diff --git a/tests/test_mxfp4_grouped_gemm.py b/tests/test_mxfp4_grouped_gemm.py new file mode 100644 index 0000000..04213d2 --- /dev/null +++ b/tests/test_mxfp4_grouped_gemm.py @@ -0,0 +1,440 @@ +"""Correctness tests for the PyTorch-compatible MXFP4 grouped GEMM.""" + +import importlib + +import pytest +import torch + +import ntops +from tests.skippers import skip_if_cuda_not_available + +_E2M1_VALUES = ( + 0.0, + 0.5, + 1.0, + 1.5, + 2.0, + 3.0, + 4.0, + 6.0, + -0.0, + -0.5, + -1.0, + -1.5, + -2.0, + -3.0, + -4.0, + -6.0, +) + + +def _decode_e2m1(packed): + table = torch.tensor(_E2M1_VALUES, dtype=torch.float32, device=packed.device) + + return table[packed.to(torch.long)] + + +def _canonical_offsets(offsets, num_experts): + if len(offsets) == num_experts + 1: + return offsets + + return (0, *offsets) + + +def _reference(input, packed_weight, weight_scale, offsets): + boundaries = _canonical_offsets(offsets, packed_weight.shape[0]) + outputs = [] + + for expert, (start, end) in enumerate(zip(boundaries, boundaries[1:])): + packed = packed_weight[expert] + unpacked = torch.empty( + packed.shape[:-1] + (packed.shape[-1] * 2,), + dtype=torch.float32, + device=packed.device, + ) + unpacked[..., 0::2] = _decode_e2m1(packed & 0x0F) + unpacked[..., 1::2] = _decode_e2m1(packed >> 4) + scales = torch.pow( + 2.0, + weight_scale[expert].to(torch.float32) - 127.0, + ) + unpacked *= scales.repeat_interleave(32, dim=-1) + outputs.append(input[start:end].float() @ unpacked.transpose(0, 1)) + + return torch.cat(outputs, dim=0).to(input.dtype) + + +@skip_if_cuda_not_available +@pytest.mark.parametrize("dtype", (torch.bfloat16, torch.float16)) +def test_mxfp4_grouped_gemm_ragged(dtype): + torch.manual_seed(7) + experts, n, k = 3, 32, 64 + offsets = torch.tensor((1, 4, 6), device="cuda", dtype=torch.int32) + input = torch.randn(6, k, device="cuda", dtype=dtype) + packed_weight = torch.randint( + 0, 256, (experts, n, k // 2), device="cuda", dtype=torch.uint8 + ) + weight_scale = ( + torch.tensor( + [[[126, 127]], [[127, 128]], [[125, 126]]], + device="cuda", + dtype=torch.uint8, + ) + .expand(experts, n, k // 32) + .contiguous() + ) + + actual = ntops.torch.mxfp4_grouped_gemm( + input, + packed_weight, + weight_scale, + offsets, + ) + expected = _reference(input, packed_weight, weight_scale, offsets.tolist()) + + torch.testing.assert_close(actual, expected, rtol=3e-2, atol=3e-2) + + +@skip_if_cuda_not_available +def test_mxfp4_grouped_gemm_accepts_legacy_boundaries(): + torch.manual_seed(11) + experts, n, k = 2, 16, 32 + input = torch.randn(3, k, device="cuda", dtype=torch.bfloat16) + packed_weight = torch.randint( + 0, 256, (experts, n, k // 2), device="cuda", dtype=torch.uint8 + ) + weight_scale = torch.full( + (experts, n, k // 32), + 127, + device="cuda", + dtype=torch.uint8, + ) + offsets = torch.tensor((0, 1, 3), device="cuda", dtype=torch.int32) + + actual = ntops.torch.mxfp4_grouped_gemm( + input, + packed_weight, + weight_scale, + offsets, + ) + expected = _reference(input, packed_weight, weight_scale, offsets.tolist()) + + torch.testing.assert_close(actual, expected, rtol=3e-2, atol=3e-2) + + +@skip_if_cuda_not_available +def test_mxfp4_grouped_gemm_zero_token_expert(): + torch.manual_seed(13) + experts, n, k = 3, 32, 64 + input = torch.randn(3, k, device="cuda", dtype=torch.bfloat16) + packed_weight = torch.randint( + 0, 256, (experts, n, k // 2), device="cuda", dtype=torch.uint8 + ) + weight_scale = torch.randint( + 124, + 130, + (experts, n, k // 32), + device="cuda", + dtype=torch.uint8, + ) + offsets = torch.tensor((0, 3, 3), device="cuda", dtype=torch.int32) + + actual = ntops.torch.mxfp4_grouped_gemm( + input, + packed_weight, + weight_scale, + offsets, + ) + expected = _reference(input, packed_weight, weight_scale, offsets.tolist()) + + torch.testing.assert_close(actual, expected, rtol=3e-2, atol=3e-2) + + +@skip_if_cuda_not_available +@pytest.mark.parametrize( + "experts,n,k,counts", + ( + (1, 16, 32, (1,)), + (2, 32, 64, (1, 3)), + (2, 64, 128, (5, 2)), + (4, 128, 256, (1, 0, 17, 5)), + ), +) +def test_mxfp4_grouped_gemm_shape_matrix(experts, n, k, counts): + torch.manual_seed(19 + n + k) + offsets = torch.tensor( + torch.tensor(counts).cumsum(0).tolist(), + device="cuda", + dtype=torch.int32, + ) + input = torch.randn(sum(counts), k, device="cuda", dtype=torch.bfloat16) + packed_weight = torch.randint( + 0, 256, (experts, n, k // 2), device="cuda", dtype=torch.uint8 + ) + weight_scale = torch.randint( + 124, + 130, + (experts, n, k // 32), + device="cuda", + dtype=torch.uint8, + ) + + actual = ntops.torch.mxfp4_grouped_gemm( + input, + packed_weight, + weight_scale, + offsets, + ) + expected = _reference(input, packed_weight, weight_scale, offsets.tolist()) + + torch.testing.assert_close(actual, expected, rtol=3e-2, atol=3e-2) + + +def test_mxfp4_encoding_contract(): + assert ntops.kernels.mxfp4_grouped_gemm.MXFP4_BLOCK_SIZE == 32 + assert ntops.kernels.mxfp4_grouped_gemm.PACKED_VALUES_PER_BYTE == 2 + assert ntops.kernels.mxfp4_grouped_gemm.PACKED_VALUES_PER_SCALE == 16 + assert _E2M1_VALUES == ( + 0.0, + 0.5, + 1.0, + 1.5, + 2.0, + 3.0, + 4.0, + 6.0, + -0.0, + -0.5, + -1.0, + -1.5, + -2.0, + -3.0, + -4.0, + -6.0, + ) + + +def test_mxfp4_column_tile_uses_shape_derived_long_reduction_path(): + wrapper = importlib.import_module("ntops.torch.mxfp4_grouped_gemm") + + assert wrapper._select_column_tile(256, 1024, 7) == 64 + assert wrapper._select_column_tile(256, 512, 7) == 128 + assert wrapper._select_column_tile(256, 1024, 32) == 128 + + +@skip_if_cuda_not_available +def test_offsets_metadata_reuses_padding_and_tracks_actual_max(): + wrapper = importlib.import_module("ntops.torch.mxfp4_grouped_gemm") + offsets = torch.tensor((1, 4, 6), device="cuda", dtype=torch.int32) + + first = wrapper._offsets_metadata(offsets, 3, 6) + second = wrapper._offsets_metadata(offsets, 3, 6) + + assert first is second + assert first.padded_offsets is second.padded_offsets + assert first.padded_offsets.tolist() == [0, 1, 4, 6] + assert first.max_tokens_per_expert == 3 + + offsets[1] = 5 + updated = wrapper._offsets_metadata(offsets, 3, 6) + + assert updated is not first + assert updated.padded_offsets.tolist() == [0, 1, 5, 6] + assert updated.max_tokens_per_expert == 4 + + +@skip_if_cuda_not_available +def test_offsets_metadata_validates_routing_contract(): + wrapper = importlib.import_module("ntops.torch.mxfp4_grouped_gemm") + decreasing = torch.tensor((2, 1), device="cuda", dtype=torch.int32) + incomplete = torch.tensor((1, 3), device="cuda", dtype=torch.int32) + missing_zero = torch.tensor((1, 2, 4), device="cuda", dtype=torch.int32) + + with pytest.raises(ValueError, match="monotonically nondecreasing"): + wrapper._offsets_metadata(decreasing, 2, 1) + + with pytest.raises(ValueError, match="input.shape"): + wrapper._offsets_metadata(incomplete, 2, 4) + + with pytest.raises(ValueError, match="start at zero"): + wrapper._offsets_metadata(missing_zero, 2, 4) + + +@skip_if_cuda_not_available +def test_mxfp4_grouped_gemm_reuses_bound_launch_with_dynamic_data(): + torch.manual_seed(23) + experts, n, k = 3, 32, 64 + offsets = torch.tensor((1, 4, 6), device="cuda", dtype=torch.int32) + input = torch.randn(6, k, device="cuda", dtype=torch.bfloat16) + packed_weight = torch.randint( + 0, 256, (experts, n, k // 2), device="cuda", dtype=torch.uint8 + ) + weight_scale = torch.randint( + 124, + 130, + (experts, n, k // 32), + device="cuda", + dtype=torch.uint8, + ) + output = torch.empty(6, n, device="cuda", dtype=torch.bfloat16) + + ntops.torch.mxfp4_grouped_gemm( + input, + packed_weight, + weight_scale, + offsets, + out=output, + ) + input.add_(0.25) + actual = ntops.torch.mxfp4_grouped_gemm( + input, + packed_weight, + weight_scale, + offsets, + out=output, + ) + expected = _reference(input, packed_weight, weight_scale, offsets.tolist()) + + torch.testing.assert_close(actual, expected, rtol=3e-2, atol=3e-2) + + input.add_(0.125) + actual = ntops.torch.mxfp4_grouped_gemm( + input, + packed_weight, + weight_scale, + offsets, + out=output, + ) + expected = _reference(input, packed_weight, weight_scale, offsets.tolist()) + + torch.testing.assert_close(actual, expected, rtol=3e-2, atol=3e-2) + + with pytest.raises(ValueError, match="num_experts"): + ntops.torch.mxfp4_grouped_gemm( + input, + packed_weight, + weight_scale, + offsets, + num_experts=experts - 1, + out=output, + ) + + offsets[0] = 2 + actual = ntops.torch.mxfp4_grouped_gemm( + input, + packed_weight, + weight_scale, + offsets, + out=output, + ) + expected = _reference(input, packed_weight, weight_scale, offsets.tolist()) + + torch.testing.assert_close(actual, expected, rtol=3e-2, atol=3e-2) + + +@skip_if_cuda_not_available +def test_mxfp4_grouped_gemm_bound_launch_uses_current_stream(): + torch.manual_seed(31) + experts, n, k = 2, 32, 64 + offsets = torch.tensor((2, 4), device="cuda", dtype=torch.int32) + input = torch.randn(4, k, device="cuda", dtype=torch.bfloat16) + packed_weight = torch.randint( + 0, 256, (experts, n, k // 2), device="cuda", dtype=torch.uint8 + ) + weight_scale = torch.randint( + 124, + 130, + (experts, n, k // 32), + device="cuda", + dtype=torch.uint8, + ) + output = torch.empty(4, n, device="cuda", dtype=torch.bfloat16) + + ntops.torch.mxfp4_grouped_gemm( + input, + packed_weight, + weight_scale, + offsets, + out=output, + ) + stream = torch.cuda.Stream() + + with torch.cuda.stream(stream): + input.add_(0.25) + actual = ntops.torch.mxfp4_grouped_gemm( + input, + packed_weight, + weight_scale, + offsets, + out=output, + ) + + stream.synchronize() + expected = _reference(input, packed_weight, weight_scale, offsets.tolist()) + + torch.testing.assert_close(actual, expected, rtol=3e-2, atol=3e-2) + + +@skip_if_cuda_not_available +def test_mxfp4_grouped_gemm_new_outputs_remain_independent(): + torch.manual_seed(29) + experts, n, k = 2, 32, 64 + offsets = torch.tensor((2, 4), device="cuda", dtype=torch.int32) + input = torch.randn(4, k, device="cuda", dtype=torch.bfloat16) + packed_weight = torch.randint( + 0, 256, (experts, n, k // 2), device="cuda", dtype=torch.uint8 + ) + weight_scale = torch.randint( + 124, + 130, + (experts, n, k // 32), + device="cuda", + dtype=torch.uint8, + ) + first = ntops.torch.mxfp4_grouped_gemm( + input, + packed_weight, + weight_scale, + offsets, + ) + expected_first = _reference( + input, + packed_weight, + weight_scale, + offsets.tolist(), + ) + first_snapshot = first.clone() + + input.add_(0.25) + second = ntops.torch.mxfp4_grouped_gemm( + input, + packed_weight, + weight_scale, + offsets, + ) + expected_second = _reference( + input, + packed_weight, + weight_scale, + offsets.tolist(), + ) + input.add_(0.125) + third = ntops.torch.mxfp4_grouped_gemm( + input, + packed_weight, + weight_scale, + offsets, + ) + expected_third = _reference( + input, + packed_weight, + weight_scale, + offsets.tolist(), + ) + + assert first.data_ptr() != second.data_ptr() + assert second.data_ptr() != third.data_ptr() + torch.testing.assert_close(first, first_snapshot) + torch.testing.assert_close(first, expected_first, rtol=3e-2, atol=3e-2) + torch.testing.assert_close(second, expected_second, rtol=3e-2, atol=3e-2) + torch.testing.assert_close(third, expected_third, rtol=3e-2, atol=3e-2) diff --git a/tests/test_rms_norm_gated.py b/tests/test_rms_norm_gated.py new file mode 100644 index 0000000..b60efaa --- /dev/null +++ b/tests/test_rms_norm_gated.py @@ -0,0 +1,83 @@ +import pytest +import torch + +import ntops +from tests.skippers import skip_if_cuda_not_available + + +def _reference( + input, + gate, + weight, + eps, + group_size, + norm_before_gate, + activation, +): + if activation in ("silu", "swish"): + activated_gate = torch.nn.functional.silu(gate.float()) + else: + activated_gate = torch.sigmoid(gate.float()) + + value = input.float() + if not norm_before_gate: + value = value * activated_gate + + grouped = value.reshape(*value.shape[:-1], -1, group_size) + variance = grouped.square().mean(dim=-1, keepdim=True) + normalized = (grouped * torch.rsqrt(variance + eps)).reshape_as(value) + if norm_before_gate: + normalized = normalized * activated_gate + if weight is not None: + normalized = normalized * weight.float() + + return normalized.to(input.dtype) + + +@skip_if_cuda_not_available +@pytest.mark.parametrize( + "shape,group_size", + ( + ((64, 128), 128), + ((128, 512), 512), + ((16, 4096), 4096), + ((2, 8, 16, 64), 32), + ), +) +@pytest.mark.parametrize("activation", ("swish", "sigmoid")) +@pytest.mark.parametrize("norm_before_gate", (False, True)) +@pytest.mark.parametrize("elementwise_affine", (False, True)) +def test_rms_norm_gated( + shape, group_size, activation, norm_before_gate, elementwise_affine +): + dtype = torch.bfloat16 + device = "cuda" + eps = 1e-5 + input = torch.randn(shape, dtype=dtype, device=device) + gate = torch.randn_like(input) + weight = ( + torch.randn(shape[-1], dtype=dtype, device=device) + if elementwise_affine + else None + ) + + actual = ntops.torch.rms_norm_gated( + input, + gate, + weight, + eps=eps, + group_size=group_size, + norm_before_gate=norm_before_gate, + activation=activation, + ) + expected = _reference( + input, + gate, + weight, + eps, + group_size, + norm_before_gate, + activation, + ) + + torch.testing.assert_close(actual, expected, rtol=2e-2, atol=2e-2) diff --git a/tests/test_scaled_mm.py b/tests/test_scaled_mm.py new file mode 100644 index 0000000..a395090 --- /dev/null +++ b/tests/test_scaled_mm.py @@ -0,0 +1,192 @@ +import pytest +import torch + +import ntops +from tests.skippers import skip_if_cuda_not_available + + +SCALE_BLOCK_SIZE = 128 + + +def _ceil_div(value, divisor): + return (value + divisor - 1) // divisor + + +def _make_fp8_matrix(shape, dtype, device): + return torch.randn(shape, dtype=torch.float32, device=device).clamp(-2, 2).to(dtype) + + +def _make_col_major_fp8_matrix(shape, dtype, device): + k, n = shape + + return _make_fp8_matrix((n, k), dtype, device).t() + + +def _reference_scaled_mm(input, mat2, scale_a, scale_b, out_dtype, bias=None): + m, k = input.shape + n = mat2.shape[1] + num_k_blocks = _ceil_div(k, SCALE_BLOCK_SIZE) + + output = torch.zeros((m, n), dtype=torch.float32, device=input.device) + + for block in range(num_k_blocks): + start = block * SCALE_BLOCK_SIZE + end = min(start + SCALE_BLOCK_SIZE, k) + + partial = input[:, start:end].float() @ mat2[start:end, :].float() + + if scale_a.shape[0] == m: + current_scale_a = scale_a[:, block] + else: + current_scale_a = scale_a[:, block].repeat_interleave(SCALE_BLOCK_SIZE)[:m] + + if scale_b.shape[1] == n: + current_scale_b = scale_b[block, :] + else: + current_scale_b = scale_b[block, :].repeat_interleave(SCALE_BLOCK_SIZE)[:n] + + output += partial * current_scale_a[:, None] * current_scale_b[None, :] + + if bias is not None: + output += bias.float() + + return output.to(out_dtype) + + +def _make_scales(m, n, k, scaling_variant, device): + num_m_blocks = _ceil_div(m, SCALE_BLOCK_SIZE) + num_n_blocks = _ceil_div(n, SCALE_BLOCK_SIZE) + num_k_blocks = _ceil_div(k, SCALE_BLOCK_SIZE) + + if scaling_variant == "1x128-128x128": + shape_a = (m, num_k_blocks) + shape_b = (num_k_blocks, num_n_blocks) + elif scaling_variant == "1x128-1x128": + shape_a = (m, num_k_blocks) + shape_b = (num_k_blocks, n) + else: + shape_a = (num_m_blocks, num_k_blocks) + shape_b = (num_k_blocks, n) + + scale_a = torch.rand(shape_a, dtype=torch.float32, device=device) + 0.25 + scale_b = torch.rand(shape_b, dtype=torch.float32, device=device) + 0.25 + + return scale_a, scale_b + + +@pytest.mark.parametrize( + "scaling_variant, m, n, expected", + ( + ("BLOCK_WISE_1X128_128X128", 1, 4096, (16, 128)), + ("BLOCK_WISE_1X128_128X128", 2, 4096, (16, 128)), + ("BLOCK_WISE_1X128_128X128", 8, 4096, (16, 128)), + ("BLOCK_WISE_1X128_128X128", 16, 4096, (16, 128)), + ("BLOCK_WISE_1X128_128X128", 128, 4096, (64, 128)), + ("BLOCK_WISE_1X128_1X128", 96, 384, (32, 128)), + ("BLOCK_WISE_128X128_1X128", 256, 96, (128, 32)), + ("BLOCK_WISE_1X128_1X128", 33, 257, (16, 16)), + ), +) +def test_specialized_block_sizes(scaling_variant, m, n, expected): + variant = getattr(ntops.kernels.scaled_mm.ScalingVariant, scaling_variant) + + assert ntops.kernels.scaled_mm.specialized_block_sizes(variant, m, n) == expected + + +@skip_if_cuda_not_available +@pytest.mark.parametrize("dtype_name", ("float8_e4m3fnuz", "float8_e5m2fnuz")) +def test_scaled_mm_rejects_platform_incompatible_fnuz_dtypes(dtype_name): + dtype = getattr(torch, dtype_name, None) + + if dtype is None: + pytest.skip(f"torch.{dtype_name} is unavailable") + + device = "cuda" + m, n, k = 16, 128, 128 + input = _make_fp8_matrix((m, k), dtype, device) + mat2 = _make_col_major_fp8_matrix((k, n), dtype, device) + scale_a, scale_b = _make_scales(m, n, k, "1x128-128x128", device) + + with pytest.raises(TypeError, match="supported FP8 dtype"): + ntops.torch.scaled_mm(input, mat2, scale_a, scale_b) + + +@skip_if_cuda_not_available +@pytest.mark.parametrize( + "m, n, k", + ( + (1, 128, 128), + (16, 128, 128), + (33, 257, 385), + ), +) +@pytest.mark.parametrize( + "scaling_variant", + ( + "1x128-128x128", + "1x128-1x128", + "128x128-1x128", + ), +) +@pytest.mark.parametrize("out_dtype", (torch.bfloat16, torch.float16)) +def test_scaled_mm(m, n, k, scaling_variant, out_dtype): + fp8_dtype = getattr(torch, "float8_e4m3fn", None) + + if fp8_dtype is None: + pytest.skip("torch.float8_e4m3fn is unavailable") + + device = "cuda" + input = _make_fp8_matrix((m, k), fp8_dtype, device) + mat2 = _make_col_major_fp8_matrix((k, n), fp8_dtype, device) + scale_a, scale_b = _make_scales(m, n, k, scaling_variant, device) + + actual = ntops.torch.scaled_mm( + input, + mat2, + scale_a, + scale_b, + out_dtype=out_dtype, + ) + expected = _reference_scaled_mm(input, mat2, scale_a, scale_b, out_dtype) + + torch.testing.assert_close(actual, expected, rtol=0.05, atol=0.05) + + +@skip_if_cuda_not_available +@pytest.mark.parametrize("out_dtype", (torch.bfloat16, torch.float16)) +def test_scaled_mm_bias_and_ignored_scale_result_for_high_precision_output( + out_dtype, +): + fp8_dtype = getattr(torch, "float8_e4m3fn", None) + + if fp8_dtype is None: + pytest.skip("torch.float8_e4m3fn is unavailable") + + device = "cuda" + m, n, k = 16, 128, 128 + input = _make_fp8_matrix((m, k), fp8_dtype, device) + mat2 = _make_col_major_fp8_matrix((k, n), fp8_dtype, device) + scale_a, scale_b = _make_scales(m, n, k, "1x128-128x128", device) + bias = torch.randn(n, dtype=out_dtype, device=device) + # PyTorch applies scale_result only when the requested output is FP8. + scale_result = torch.tensor(0.25, dtype=torch.float32, device=device) + + actual = ntops.torch.scaled_mm( + input, + mat2, + scale_a, + scale_b, + bias=bias, + scale_result=scale_result, + out_dtype=out_dtype, + ) + expected = _reference_scaled_mm( + input, + mat2, + scale_a, + scale_b, + out_dtype, + bias, + ) + + torch.testing.assert_close(actual, expected, rtol=0.05, atol=0.05)