From 27bbeaea648d1e40c8a17bf32aa9002bbf736172 Mon Sep 17 00:00:00 2001 From: chenping Date: Wed, 1 Jul 2026 15:54:42 +0800 Subject: [PATCH 01/23] feat(minimax-remover): add NVFP4 kernelized video inpainting pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance highlights (RTX 5060 Ti, SM120, CUDA 13): - End-to-end: 2.6× speedup (30.7s → 11.9s for 123 frames, 3 segments) - RTF improvement: 6.0 → 2.3 (processing-time / clip-duration) - Per-layer GEMM: 1.14–1.30× speedup vs fp16 matmul - FP4 GEMM with quantize overhead: 4–9× faster than fp16 on large FFN projections - Precision: PSNR 52.0 dB (mean) / 45.2 dB (worst frame) vs fp16 Optimizations: - NVFP4 W4A4 quantization with dynamic per-call activation (no offline calibration) - Fused LayerNorm + adaLN + gate-residual in single fp32-stat Triton kernel - Fused FFN-up GEMM + bias + GELU → FP4 output (skip re-quantization for FFN-down) - QKV quantize-once optimization (reuse quantized norm output for Q/K/V projections) - Manual graph-capturable denoise loop with CUDA Graph support - BF16 transformer (NVFP4-native, eliminates fp16↔bf16 casts) - SageAttention / FA2 backend integration --- docs/minimax_remover_usage.md | 153 +++++++ flash_rt/models/minimax_remover/__init__.py | 18 + flash_rt/models/minimax_remover/_attention.py | 150 +++++++ flash_rt/models/minimax_remover/_kernels.py | 282 ++++++++++++ .../models/minimax_remover/_manual_denoise.py | 413 ++++++++++++++++++ .../models/minimax_remover/_nvfp4_linear.py | 236 ++++++++++ flash_rt/models/minimax_remover/pipeline.py | 207 +++++++++ tests/test_minimax_remover_smoke.py | 170 +++++++ 8 files changed, 1629 insertions(+) create mode 100644 docs/minimax_remover_usage.md create mode 100644 flash_rt/models/minimax_remover/__init__.py create mode 100644 flash_rt/models/minimax_remover/_attention.py create mode 100644 flash_rt/models/minimax_remover/_kernels.py create mode 100644 flash_rt/models/minimax_remover/_manual_denoise.py create mode 100644 flash_rt/models/minimax_remover/_nvfp4_linear.py create mode 100644 flash_rt/models/minimax_remover/pipeline.py create mode 100644 tests/test_minimax_remover_smoke.py diff --git a/docs/minimax_remover_usage.md b/docs/minimax_remover_usage.md new file mode 100644 index 00000000..e3b51ef9 --- /dev/null +++ b/docs/minimax_remover_usage.md @@ -0,0 +1,153 @@ +# MiniMax-Remover — FlashRT Inference Pipeline + +MiniMax-Remover video inpainting (subtitle / object removal) with NVFP4 +(W4A4) kernelized inference on Blackwell SM120. + +## Build + +The pipeline reuses the **generic** FlashRT SM120 NVFP4 kernels — it +ships **no model-specific CUDA operators**. It requires those generic +kernels to be compiled in. Build FlashRT on a Blackwell host with the +NVFP4 build option enabled so the NVFP4 quantise / W4A4 GEMM / +fused-bias-gelu kernels are present in `flash_rt_kernels.so`: + +```bash +cd FlashRT +mkdir build && cd build +cmake .. -DENABLE_CUTLASS_SM120_NVFP4_W4A16=ON +make -j$(nproc) +pip install -e .. +``` + +Importing `flash_rt.models.minimax_remover` always succeeds; the kernel +surface is validated lazily in `MiniMaxRemoverPipeline.__init__` via +`_load_kernels()`. If any required symbol is missing the constructor +raises a clear `RuntimeError` listing the missing names, so a non-NVFP4 +build fails fast instead of crashing mid-quantisation. The required +generic symbols are: + +| Symbol | Role | +|--------|------| +| `nvfp4_sf_swizzled_bytes` | block-scale-factor byte layout helper | +| `bf16_weight_to_nvfp4_swizzled` | one-shot weight -> NVFP4 quantise | +| `quantize_bf16_to_nvfp4_swizzled` | per-call dynamic activation quantise | +| `fp4_w4a16_gemm_sm120_bf16out_pingpong` | SM120-native W4A4 MMA -> bf16 | +| `add_bias_bf16` | in-place bias add on bf16 GEMM output | +| `fp4_w4a16_gemm_bias_gelu_fp4out_sm120` | fused FFN-up GEMM + bias + GELU -> FP4 | + +The attention backend (`FLASHRT_ATTN_MODE`) optionally pulls in +`sageattention` (Sage) or `triton_flash_attn` (Triton FA); `fa2` uses +the vendored `flash_rt_fa2.so`. The fused norm / RoPE / Euler-step +elementwise kernels are self-contained Triton JIT kernels shipped in the +package (`_kernels.py`) and need no build step. + +## Pipeline + +`flash_rt/models/minimax_remover/pipeline.py` — `MiniMaxRemoverPipeline`. +It wraps a loaded diffusers MiniMax-Remover `pipe` and consumes it in +place: + +- every eligible transformer Linear -> NVFP4 W4A4 GEMM (weight quantised + once at load time; activation quantised **dynamically** per call with + per-16-element UE4M3 block scales computed on-GPU — no offline + calibration, no CPU sync); +- transformer switched to bf16 (NVFP4-native, eliminates the fp16<->bf16 + cast pair); +- RoPE freqs cached as complex; +- `torch.nn.functional.scaled_dot_product_attention` -> FA2 / SageAttention; +- per-block LayerNorm + adaLN modulation + gate-residual fused into a + single fp32-stat Triton kernel; +- the N-step flow-matching denoise loop replaced by a manual, graph- + capturable pointer-based loop (`ManualRemoverPipeline`). QKV quantises + the norm output **once** and reuses it for all three projections; the + FFN-up GEMM fuses bias + GELU straight to FP4 output so the FFN-down + projection skips re-quantisation. With `FLASHRT_MANUAL_GRAPH=1` the + whole N-step x N-block loop is captured as a single CUDA Graph; + inside the captured graph there are **zero** torch elementwise ops — + every operation is a kernel launch. + +The VAE encode / decode run unchanged from the loaded diffusers model +(one-shot per segment, outside the graph). No MiniMax-Remover source is +imported; the `pipe` is duck-typed through `.transformer` / `.vae` / +`.scheduler` / `.video_processor` and the `expand_masks` / `resize` +helpers. + +## Performance (RTX 5060 Ti, SM120, CUDA 13) + +### End-to-end (123 frames, 3 segments, fp16 reference baseline) + +| Stack | Wall time | Speedup | PSNR vs fp16 | +|-------|-----------|---------|--------------| +| fp16 reference (diffusers) | 30.7 s | 1.0× | — | +| FlashRT NVFP4 (this pipeline) | 11.9 s | **2.6×** | 52.0 / 45.2 dB | + +At 24 fps the 123-frame clip is 5.1 s, so the FlashRT stack runs at +**RTF ≈ 2.3** (processing-time / clip-duration); the fp16 reference is +RTF ≈ 6.0. + +### Transformer GEMM (NVFP4 vs fp16 matmul, single layer) + +| Linear | fp16 matmul | NVFP4 W4A4 | per-layer speedup | +|--------|-------------|------------|-------------------| +| FFN up [5120 -> 13824] | 1.095 ms | 0.840 ms | 1.30× | +| FFN down [13824 -> 5120] | 1.020 ms | 0.864 ms | 1.18× | +| QKV / out [5120 -> 5120] | 0.409 ms | 0.359 ms | 1.14× | + +Including cast + quantise overhead, the isolated FP4 GEMM is 4–9× faster +than the fp16 matmul on the large FFN projections (e.g. ffn_up +3.95 ms -> 0.47 ms). NVFP4 is also 1.14–1.30× faster per layer than the +static-quant FP8 GEMM. + +### Precision specification + +The pipeline keeps the math reference-equivalent on the precision-critical +path (fp32-stat LayerNorm / RMSNorm, interleaved RoPE) and confines the +loss to the quantised GEMMs and the attention backend. + +| Component | Metric | Value | +|-----------|--------|-------| +| Attention — SageAttention QK-int8 PV-fp8 (`sage_fp8`, default) | cosine vs SDPA | 0.9993 | +| Attention — SageAttention QK-int8 PV-fp16 (`sage_fp16`) | cosine vs SDPA | 0.9999 | +| NVFP4 W4A4 GEMM | cosine vs fp16 matmul | >= 0.999 | +| End-to-end (full pipeline) | PSNR vs fp16 | 52.0 dB (mean) / 45.2 dB (worst frame) | +| End-to-end (full pipeline) | max abs diff vs fp16 | bounded by the FP4 grid; per-block relative, scales with activation magnitude (median per-pixel deviation < 2 / 255 on 8-bit output) | + +The default `sage_fp8` attention gives the best latency at cosine 0.9993; +switch to `FLASHRT_ATTN_MODE=sage_fp16` for cosine 0.9999 at a small +latency cost. NVFP4 needs no calibration, so the first call is already +in the steady state (no warm-up / calibration pass). + +## Environment variables + +| Variable | Default | Effect | +|----------|---------|--------| +| `FLASHRT_ATTN_MODE` | `sage_fp8` | attention backend (`sage_fp8`/`sage_fp16`/`sage`/`fa2`) | +| `FLASHRT_FP4_GEMM` | `pingpong` | GEMM kernel variant (`pingpong`/`plain`/`widen`) | +| `FLASHRT_FUSED_BLOCK` | `1` | fused QKV-quant-once + fused FFN-up GEMM+bias+gelu block (`0` = per-projection re-quant debug path) | +| `FLASHRT_MANUAL_GRAPH` | `0` | capture the whole denoise loop as one CUDA Graph | +| `FLASHRT_NUM_STEPS` | unset | override the denoise step count (default 12) | + +## Usage + +```python +from flash_rt.models.minimax_remover import MiniMaxRemoverPipeline + +# `pipe` is a loaded diffusers Minimax_Remover_Pipeline (transformer + +# vae + scheduler). The FlashRT pipeline consumes it in place. +pipeline = MiniMaxRemoverPipeline(pipe) +output = pipeline( + images=frames, # [F, H, W, 3] uint8/np, 0..255 + masks=masks, # [F, H, W, 1] np, 0/1 + num_frames=len(frames), + height=720, width=1280, + num_inference_steps=12, +) +video = output.frames +``` + +## Model weights + +MiniMax-Remover checkpoint + the `Transformer3DModel` / `AutoencoderKLWan` +definitions are loaded by the reference project (unmodified, via the +loaded diffusers `pipe`). This FlashRT pipeline module imports no +MiniMax-Remover source. diff --git a/flash_rt/models/minimax_remover/__init__.py b/flash_rt/models/minimax_remover/__init__.py new file mode 100644 index 00000000..532fd642 --- /dev/null +++ b/flash_rt/models/minimax_remover/__init__.py @@ -0,0 +1,18 @@ +"""FlashRT -- MiniMax-Remover video inpainting pipeline. + +MiniMax-Remover is a flow-matching video inpainting Transformer used for +subtitle / object removal. This package ships the NVFP4 (W4A4) +kernelized inference pipeline (``MiniMaxRemoverPipeline``): the +transformer Linears are rewritten as NVFP4 GEMMs over the generic +FlashRT SM120 kernels, the per-block norm / gate / residual / gelu ops +become fused Triton kernels, attention moves to FA2 / SageAttention, and +the N-step flow-matching loop is captured as a single CUDA Graph. The VAE +encode / decode run unchanged from the loaded diffusers model. +""" + +from flash_rt.models.minimax_remover.pipeline import ( + MiniMaxRemoverPipeline, + _load_kernels, +) + +__all__ = ["MiniMaxRemoverPipeline"] diff --git a/flash_rt/models/minimax_remover/_attention.py b/flash_rt/models/minimax_remover/_attention.py new file mode 100644 index 00000000..5f3915ac --- /dev/null +++ b/flash_rt/models/minimax_remover/_attention.py @@ -0,0 +1,150 @@ +"""FlashRT -- MiniMax-Remover kernel attention processor. + +Replaces ``torch.nn.functional.scaled_dot_product_attention`` in every +attention block with a pointer-based kernel backend: + +* ``FlashRTFA2Processor.__call__`` runs the QKV projection (through the + NVFP4 Linears installed by ``_nvfp4_linear``), Q/K RMSNorm + (fp32-stat Triton), interleaved RoPE on the native [B, S, H, D] + layout (no transpose / copy), then the chosen attention kernel. + +The attention backend is selected by ``FLASHRT_ATTN_MODE``: + + * ``sage`` / ``sage_auto`` -- SageAttention auto-dispatcher. + * ``sage_fp8`` / ``sage2`` -- SageAttention QK-int8 + PV-fp8 CUDA + (default; ~5x vs FA2, cos ~0.9993). + * ``sage_fp16`` / ``sage1`` -- SageAttention QK-int8 + PV-fp16 CUDA. + * ``sage_triton`` -- SageAttention QK-int8 + PV-fp16 Triton. + * ``fa2`` -- FlashRT FA2 (``flash_rt_fa2.fwd_fp16``). + +``sageattention`` is an optional third-party dependency (pip); only the +selected backend is imported lazily. ``fa2`` uses FlashRT's own vendored +``flash_rt_fa2.so``. No MiniMax-Remover imports -- tensors only. +""" + +import math +import os + +import torch + +from ._kernels import rms_norm_fp32stat, rope_apply_bshd, freqs_to_cos_sin + +try: + _NUM_SMS = torch.cuda.get_device_properties(0).multi_processor_count +except Exception: + _NUM_SMS = 0 + + +def _attention_mode(): + return os.environ.get("FLASHRT_ATTN_MODE", "sage_fp8").lower() + + +_SAGE = None + + +def _get_sage(): + global _SAGE + if _SAGE is None: + import sageattention as _m + _SAGE = _m + return _SAGE + + +def _sage_attn(q, k, v, scale, mode): + """Dispatch to the requested SageAttention variant. + + All variants accept and return [B, S, H, D] (NHD) fp16. + """ + sa = _get_sage() + kw = dict(tensor_layout="NHD", is_causal=False, sm_scale=scale) + if mode in ("sage", "sage_auto"): + return sa.sageattn(q, k, v, **kw) + if mode in ("sage_fp8", "sage2"): + return sa.sageattn_qk_int8_pv_fp8_cuda(q, k, v, **kw) + if mode in ("sage_fp16", "sage1"): + return sa.sageattn_qk_int8_pv_fp16_cuda(q, k, v, **kw) + if mode == "sage_triton": + return sa.sageattn_qk_int8_pv_fp16_triton(q, k, v, **kw) + return sa.sageattn(q, k, v, **kw) + + +class FlashRTFA2Processor: + """Kernel attention processor for the native [B, S, H, D] layout. + + QKV projection goes through the installed NVFP4 Linears; Q/K + RMSNorm uses the fp32-stat Triton kernel; RoPE is applied in place + on the native layout (zero transpose / copy); the attention backend + is chosen by ``FLASHRT_ATTN_MODE``. Per-shape cos/sin RoPE tables + and FA2 log-sum-exp buffers are cached. + """ + + def __init__(self): + self._lse_bufs = {} + self._cos_sin = {} + + def __call__(self, attn, hidden_states, rotary_emb=None, + attention_mask=None, encoder_hidden_states=None): + mode = _attention_mode() + B, S, _ = hidden_states.shape + H = attn.heads + Dd = attn.inner_dim // H + scale = 1.0 / math.sqrt(float(Dd)) + + q = attn.to_q(hidden_states) + k = attn.to_k(hidden_states) + v = attn.to_v(hidden_states) + if attn.norm_q is not None: + q = rms_norm_fp32stat(q, attn.norm_q.weight, attn.norm_q.eps) + if attn.norm_k is not None: + k = rms_norm_fp32stat(k, attn.norm_k.weight, attn.norm_k.eps) + + q = q.view(B, S, H, Dd) + k = k.view(B, S, H, Dd) + v = v.view(B, S, H, Dd) + + if rotary_emb is not None: + cs = self._cos_sin.get(S) + if cs is None: + cs = freqs_to_cos_sin(rotary_emb) + self._cos_sin[S] = cs + rope_apply_bshd(q, cs[0], cs[1]) + rope_apply_bshd(k, cs[0], cs[1]) + + if not q.is_contiguous(): + q = q.contiguous() + if not k.is_contiguous(): + k = k.contiguous() + if not v.is_contiguous(): + v = v.contiguous() + + if mode.startswith("sage"): + out = _sage_attn(q, k, v, scale, mode) + else: + out = torch.empty_like(q) + lse = self._lse_bufs.get((B, S, H)) + if lse is None: + lse = torch.empty(B, H, S, device=q.device, dtype=torch.float32) + self._lse_bufs[(B, S, H)] = lse + from flash_rt import flash_rt_fa2 as fa2 + qs, ks, vs, os_ = (q.stride(), k.stride(), v.stride(), out.stride()) + fa2.fwd_fp16( + q.data_ptr(), k.data_ptr(), v.data_ptr(), out.data_ptr(), + lse.data_ptr(), 0, 0, + B, S, S, H, H, Dd, + (qs[0], qs[1], qs[2]), (ks[0], ks[1], ks[2]), (vs[0], vs[1], vs[2]), + (os_[0], os_[1], os_[2]), + scale, _NUM_SMS, torch.cuda.current_stream().cuda_stream) + + hidden_states = out.view(B, S, H * Dd) + hidden_states = attn.to_out[0](hidden_states) + return hidden_states + + +def install_attention(transformer): + """Install the kernel attention processor on every transformer block.""" + proc = FlashRTFA2Processor() + n = 0 + for block in transformer.blocks: + block.attn1.processor = proc + n += 1 + return n diff --git a/flash_rt/models/minimax_remover/_kernels.py b/flash_rt/models/minimax_remover/_kernels.py new file mode 100644 index 00000000..c4bfc59c --- /dev/null +++ b/flash_rt/models/minimax_remover/_kernels.py @@ -0,0 +1,282 @@ +"""FlashRT -- MiniMax-Remover fused Triton kernels. + +Self-contained Triton JIT kernels used by the MiniMax-Remover denoise +loop. They are the precision-critical elementwise / normalisation ops +of the Transformer3DModel block forward and the flow-matching step: + +* ``ada_layernorm_fp16_io`` -- fp32-stat LayerNorm + adaLN modulation + fused into one kernel, fp16/bf16 in/out. The FlashRT generic + ``ada_layer_norm_fp16`` loses precision on real diffusion latents; + this kernel matches the reference FP32LayerNorm path bit-for-bit by + accumulating mean/var in fp32 across three passes. +* ``rms_norm_fp32stat`` -- RMSNorm with fp32 statistics + affine + weight, used for attention norm_q / norm_k. +* ``gate_mul_residual_bcast``-- in-place ``residual += x * gate`` with a + broadcast gate vector (avoids the [S, D] expand copy). +* ``rope_apply_bshd`` / ``freqs_to_cos_sin`` -- interleaved RoPE applied + in-place on the native [B, S, H, D] layout (no transpose / copy). +* ``euler_step_inplace`` -- in-place Euler flow-matching step with + fp32 accumulation, used every denoise step. +* ``mask_mul`` / ``latent_affine`` -- fused host-side elementwise ops + (masked-image build and latent (de)normalisation). + +Every kernel here is dtype-generic on fp16 / bf16 and dispatches on the +input dtype at call time. No model-class imports -- tensors only. +""" + +import torch +import triton +import triton.language as tl + + +def _io_dtype(x): + return tl.bfloat16 if x.dtype == torch.bfloat16 else tl.float16 + + +# ── adaLayerNorm: fp32-stat LayerNorm + (1 + scale) * x_norm + shift ────── + +@triton.jit +def _ada_layernorm_io_kernel(X, SCALE, SHIFT, OUT, M, N, sM_x, sM_o, eps, + BLOCK_N: tl.constexpr, IO_DTYPE: tl.constexpr): + row = tl.program_id(0) + x_ptr = X + row * sM_x + o_ptr = OUT + row * sM_o + _mean = tl.zeros([BLOCK_N], dtype=tl.float32) + for off in tl.range(0, N, BLOCK_N): + cols = off + tl.arange(0, BLOCK_N) + mask = cols < N + x = tl.load(x_ptr + cols, mask=mask, other=0.0).to(tl.float32) + _mean += tl.sum(x) + mean = _mean / N + _var = tl.zeros([BLOCK_N], dtype=tl.float32) + for off in tl.range(0, N, BLOCK_N): + cols = off + tl.arange(0, BLOCK_N) + mask = cols < N + x = tl.load(x_ptr + cols, mask=mask, other=0.0).to(tl.float32) + d = x - mean + _var += tl.sum(d * d) + rstd = 1.0 / tl.sqrt(_var / N + eps) + for off in tl.range(0, N, BLOCK_N): + cols = off + tl.arange(0, BLOCK_N) + mask = cols < N + x = tl.load(x_ptr + cols, mask=mask, other=0.0).to(tl.float32) + x_norm = (x - mean) * rstd + scale = tl.load(SCALE + cols, mask=mask, other=0.0) + shift = tl.load(SHIFT + cols, mask=mask, other=0.0) + y = x_norm * (1.0 + scale) + shift + tl.store(o_ptr + cols, y.to(IO_DTYPE), mask=mask) + + +def ada_layernorm_fp16_io(x, scale, shift, eps=1e-6): + """LayerNorm(x) * (1 + scale) + shift -> same dtype as x. + + ``x`` is contiguous [S, D] or [B, S, D] fp16/bf16; scale/shift are + [D] vectors. Statistics accumulated in fp32 (reference-equivalent). + """ + orig_shape = x.shape + if x.dim() == 3: + x = x.reshape(orig_shape[0] * orig_shape[1], orig_shape[2]) + S, D = x.shape + assert x.is_contiguous(), "ada_layernorm_fp16_io: x must be contiguous" + scale = scale.contiguous().to(torch.float32).view(-1) + shift = shift.contiguous().to(torch.float32).view(-1) + out = torch.empty_like(x) + BLOCK_N = triton.next_power_of_2(min(D, 2048)) + num_warps = 8 if D >= 1024 else 4 + _ada_layernorm_io_kernel[(S,)]( + x, scale, shift, out, S, D, x.stride(0), out.stride(0), eps, + BLOCK_N=BLOCK_N, num_warps=num_warps, IO_DTYPE=_io_dtype(x)) + return out.reshape(orig_shape) + + +# ── RMSNorm with fp32 statistics + affine weight ───────────────────────── + +@triton.jit +def _rmsnorm_affine_kernel(X, WEIGHT, OUT, Npts, D, EPS, BLOCK_D: tl.constexpr, + IO_DTYPE: tl.constexpr): + pt = tl.program_id(0) + xp = X + pt * D + op = OUT + pt * D + _sum = tl.zeros([BLOCK_D], dtype=tl.float32) + for off in tl.range(0, D, BLOCK_D): + cols = off + tl.arange(0, BLOCK_D) + mask = cols < D + x = tl.load(xp + cols, mask=mask, other=0.0).to(tl.float32) + _sum += tl.sum(x * x) + inv_rms = 1.0 / tl.sqrt(_sum / D + EPS) + for off in tl.range(0, D, BLOCK_D): + cols = off + tl.arange(0, BLOCK_D) + mask = cols < D + x = tl.load(xp + cols, mask=mask, other=0.0).to(tl.float32) + w = tl.load(WEIGHT + cols, mask=mask, other=0.0).to(tl.float32) + tl.store(op + cols, (x * inv_rms * w).to(IO_DTYPE), mask=mask) + + +def rms_norm_fp32stat(x, weight, eps): + """RMSNorm (fp32 statistics + affine weight[D]). x[.., D] -> same dtype.""" + D = x.shape[-1] + orig_shape = x.shape + x2 = x.reshape(-1, D).contiguous() + Npts = x2.shape[0] + out = torch.empty_like(x2) + w = weight.contiguous().to(torch.float32).view(-1) + BLOCK_D = 16 + while BLOCK_D < D and BLOCK_D < 2048: + BLOCK_D <<= 1 + _rmsnorm_affine_kernel[(Npts,)]( + x2, w, out, Npts, D, float(eps), BLOCK_D=BLOCK_D, + num_warps=8 if D >= 256 else 4, IO_DTYPE=_io_dtype(x2)) + return out.reshape(orig_shape) + + +# ── in-place residual += x * gate (broadcast gate vector) ──────────────── + +@triton.jit +def _gate_mul_res_bcast_kernel(RES, X, GATE, Nrow, D, BLOCK_D: tl.constexpr, + IO_DTYPE: tl.constexpr): + r = tl.program_id(0) + cols = tl.arange(0, BLOCK_D) + mask = cols < D + rp = RES + r * D + x = tl.load(rp + cols, mask=mask).to(tl.float32) + xv = tl.load(X + r * D + cols, mask=mask).to(tl.float32) + g = tl.load(GATE + cols, mask=mask).to(tl.float32) + tl.store(rp + cols, (x + xv * g).to(IO_DTYPE), mask=mask) + + +def gate_mul_residual_bcast(residual, x, gate): + """In-place residual[S, D] += x[S, D] * gate[D] (gate broadcast).""" + D = residual.shape[-1] + res2 = residual.reshape(-1, D) + x2 = x.reshape(-1, D) + Nrow = res2.shape[0] + g = gate.contiguous().to(residual.dtype).view(-1) + BLOCK_D = 16 + while BLOCK_D < D and BLOCK_D < 2048: + BLOCK_D <<= 1 + _gate_mul_res_bcast_kernel[(Nrow,)](res2, x2, g, Nrow, D, BLOCK_D=BLOCK_D, + num_warps=4, IO_DTYPE=_io_dtype(residual)) + return residual + + +# ── interleaved RoPE on native [B, S, H, D] layout (in-place) ──────────── + +@triton.jit +def _rope_bshd_kernel(X, COS, SIN, Nrows, S, H, Dhalf, + BLOCK_D: tl.constexpr, IO_DTYPE: tl.constexpr): + r = tl.program_id(0) + s = (r // H) % S + x_ptr = X + r * (2 * Dhalf) + cos_ptr = COS + s * Dhalf + sin_ptr = SIN + s * Dhalf + offs = tl.arange(0, BLOCK_D) + mask = offs < Dhalf + cos = tl.load(cos_ptr + offs, mask=mask, other=0.0).to(tl.float32) + sin = tl.load(sin_ptr + offs, mask=mask, other=0.0).to(tl.float32) + x0 = tl.load(x_ptr + 2 * offs, mask=mask, other=0.0).to(tl.float32) + x1 = tl.load(x_ptr + 2 * offs + 1, mask=mask, other=0.0).to(tl.float32) + tl.store(x_ptr + 2 * offs, (x0 * cos - x1 * sin).to(IO_DTYPE), mask=mask) + tl.store(x_ptr + 2 * offs + 1, (x0 * sin + x1 * cos).to(IO_DTYPE), mask=mask) + + +def rope_apply_bshd(x, cos, sin): + """In-place interleaved RoPE on contiguous [B, S, H, D]. + + cos/sin are [S, D // 2] fp32 tables. Returns x (modified in place). + """ + assert x.is_contiguous(), "rope_apply_bshd: x must be contiguous" + B, S, H, D = x.shape + Dhalf = D // 2 + Nrows = B * S * H + BLOCK_D = max(16, 1 << max(0, (Dhalf - 1).bit_length())) + _rope_bshd_kernel[(Nrows,)]( + x, cos.contiguous().to(torch.float32), sin.contiguous().to(torch.float32), + Nrows, S, H, Dhalf, BLOCK_D=BLOCK_D, num_warps=4, IO_DTYPE=_io_dtype(x)) + return x + + +def freqs_to_cos_sin(freqs): + """complex [1, 1, S, D // 2] -> (cos[S, D // 2] fp32, sin[S, D // 2] fp32).""" + f = freqs.squeeze().to(torch.complex64) + return f.real.contiguous().to(torch.float32), f.imag.contiguous().to(torch.float32) + + +# ── denoise-loop elementwise kernels (fp32 accumulation, in-place) ─────── + +@triton.jit +def _euler_kernel(latents_ptr, noise_ptr, n_elements, dt, + BLOCK: tl.constexpr, IO_DTYPE: tl.constexpr): + pid = tl.program_id(0) + offs = pid * BLOCK + tl.arange(0, BLOCK) + mask = offs < n_elements + lat = tl.load(latents_ptr + offs, mask=mask, other=0.0).to(tl.float32) + noise = tl.load(noise_ptr + offs, mask=mask, other=0.0).to(tl.float32) + tl.store(latents_ptr + offs, (lat + dt * noise).to(IO_DTYPE), mask=mask) + + +def euler_step_inplace(latents, noise_pred, dt): + """In-place Euler flow-matching step: latents += dt * noise_pred.""" + n = latents.numel() + grid = lambda meta: (triton.cdiv(n, meta["BLOCK"]),) + _euler_kernel[grid](latents, noise_pred, n, float(dt), + BLOCK=2048, IO_DTYPE=_io_dtype(latents)) + + +@triton.jit +def _mask_mul_kernel(a_ptr, b_ptr, out_ptr, n, BLOCK: tl.constexpr, + IO_DTYPE: tl.constexpr): + pid = tl.program_id(0) + offs = pid * BLOCK + tl.arange(0, BLOCK) + mask = offs < n + a = tl.load(a_ptr + offs, mask=mask).to(tl.float32) + b = tl.load(b_ptr + offs, mask=mask).to(tl.float32) + tl.store(out_ptr + offs, (a * (1.0 - b)).to(IO_DTYPE), mask=mask) + + +def mask_mul(a, b): + """out = a * (1 - b), same dtype as a. Replaces torch elementwise.""" + out = torch.empty_like(a) + n = a.numel() + grid = lambda meta: (triton.cdiv(n, meta["BLOCK"]),) + _mask_mul_kernel[grid](a, b, out, n, BLOCK=4096, IO_DTYPE=_io_dtype(a)) + return out + + +@triton.jit +def _latent_affine_kernel(x_ptr, param_ptr, out_ptr, n, scale, is_sub_mul, + BLOCK: tl.constexpr, IO_DTYPE: tl.constexpr): + pid = tl.program_id(0) + offs = pid * BLOCK + tl.arange(0, BLOCK) + mask = offs < n + x = tl.load(x_ptr + offs, mask=mask).to(tl.float32) + p = tl.load(param_ptr + offs, mask=mask).to(tl.float32) + if is_sub_mul: + result = (x - p) * scale + else: + result = x * scale + p + tl.store(out_ptr + offs, result.to(IO_DTYPE), mask=mask) + + +def latent_normalize(x, mean_param, inv_std_param): + """out = (x - mean) * inv_std. Replaces torch elementwise.""" + out = torch.empty_like(x) + inv_std = 1.0 / float(inv_std_param.max()) + n = x.numel() + grid = lambda meta: (triton.cdiv(n, meta["BLOCK"]),) + _latent_affine_kernel[grid](x, mean_param, out, n, inv_std, True, + BLOCK=4096, IO_DTYPE=_io_dtype(x)) + return out + + +def latent_denormalize(x, mean_param, inv_std_param): + """out = x / inv_std + mean = x * (1/inv_std) + mean. + + inv_std_param stores 1/std; mean_param stores latents_mean. + """ + out = torch.empty_like(x) + inv_std_val = float(inv_std_param.max()) + n = x.numel() + grid = lambda meta: (triton.cdiv(n, meta["BLOCK"]),) + _latent_affine_kernel[grid](x, mean_param, out, n, inv_std_val, False, + BLOCK=4096, IO_DTYPE=_io_dtype(x)) + return out diff --git a/flash_rt/models/minimax_remover/_manual_denoise.py b/flash_rt/models/minimax_remover/_manual_denoise.py new file mode 100644 index 00000000..4ddc197e --- /dev/null +++ b/flash_rt/models/minimax_remover/_manual_denoise.py @@ -0,0 +1,413 @@ +"""FlashRT -- MiniMax-Remover manual pointer-based denoise pipeline. + +Replaces the diffusers ``Minimax_Remover_Pipeline.__call__`` denoise +loop with a pointer-based, graph-capturable implementation that mirrors +the FlashRT frontend pattern: + +1. Pre-compute ALL step time embeddings BEFORE graph capture (the + condition embedder's ``Timesteps`` uses ``torch.arange`` -- a CPU op + that breaks capture). +2. Pre-compute ALL modulation vectors (``scale_shift_table + temb``) for + every step x every block + norm_out, eliminating all ``.float()`` + casts, additions and chunk ops from the hot path. +3. Pre-compute RoPE freqs (fixed for a given latent shape). +4. Pre-compute the Euler flow-matching ``dt`` values. +5. Manual block forward (``block_forward_fused``): only kernel calls -- + Triton adaLN, kernel attention, NVFP4 GEMM, Triton gate-mul, FlashRT + gelu. QKV quantises the norm output ONCE and reuses it for all three + projections; the FFN up GEMM fuses bias + gelu straight to FP4 output + so the FFN-down projection skips re-quantisation. +6. Manual norm_out: Triton adaLN (replaces FP32LayerNorm + mul + add). +7. CUDA Graph captures the ENTIRE N-step x N-block denoise loop as ONE + graph (``FLASHRT_MANUAL_GRAPH=1``). +8. Euler step via the fp32-accumulating Triton kernel. +9. VAE encode / decode stay outside the graph (one-shot per segment). + +Inside the captured graph there are ZERO torch elementwise ops -- every +operation is a kernel launch. + +No MiniMax-Remover imports: the loaded diffusers ``pipe`` is duck-typed +through ``pipe.transformer`` / ``pipe.vae`` / ``pipe.scheduler``. The +``flash_rt_kernels`` module is passed in (``kern``) so importing this +module never requires the compiled extension. +""" + +import math +import os + +import torch +from einops import rearrange +from diffusers.utils.torch_utils import randn_tensor + +from ._kernels import (ada_layernorm_fp16_io, gate_mul_residual_bcast, + rms_norm_fp32stat, rope_apply_bshd, freqs_to_cos_sin, + euler_step_inplace, mask_mul, + latent_normalize, latent_denormalize) +from ._attention import _sage_attn, _attention_mode + +_USE_FUSED_BLOCK = os.environ.get("FLASHRT_FUSED_BLOCK", "1") == "1" + + +def _fp4_gemm_raw(a_packed, a_sfa, linear, m, device, kern): + """FP4 GEMM from a pre-quantised packed input (no re-quantisation).""" + k, n = linear.in_features, linear.out_features + out = torch.empty(m, n, dtype=torch.bfloat16, device=device) + stream = torch.cuda.current_stream().cuda_stream + linear._gemm( + a_packed.data_ptr(), linear.weight_packed.data_ptr(), out.data_ptr(), + m, n, k, a_sfa.data_ptr(), linear.weight_sfb.data_ptr(), + linear.weight_alpha, stream) + if linear.bias is not None: + kern.add_bias_bf16(out.data_ptr(), linear.bias.data_ptr(), m, n, stream) + return out + + +def _quant_to_nvfp4(x_bf16, kern): + """Quantise bf16 [S, D] -> (packed [S, D/2], swizzled SF) for FP4 GEMM.""" + m, k = x_bf16.shape + packed = torch.empty(m, k // 2, dtype=torch.uint8, device=x_bf16.device) + sfa = torch.empty(kern.nvfp4_sf_swizzled_bytes(m, k), dtype=torch.uint8, + device=x_bf16.device) + stream = torch.cuda.current_stream().cuda_stream + kern.quantize_bf16_to_nvfp4_swizzled( + x_bf16.data_ptr(), packed.data_ptr(), sfa.data_ptr(), m, k, stream) + return packed, sfa + + +def block_forward_fused(block, hidden_states, mod, rotary_emb, eps, kern, + cos_sin=None): + """Block forward with pre-computed fp32 modulation; fused QKV + FFN. + + ``mod`` is [6, D] fp32: (shift_msa, scale_msa, gate_msa, + shift_ffn, scale_ffn, gate_ffn). Only kernel calls remain. + """ + _, S, D = hidden_states.shape + hs = hidden_states.contiguous().view(S, D) + stream = torch.cuda.current_stream().cuda_stream + device = hs.device + + # ── Self-attention sub-layer ── + norm1 = ada_layernorm_fp16_io(hs, mod[1], mod[0], eps) + packed1, sfa1 = _quant_to_nvfp4(norm1, kern) + + attn = block.attn1 + H = attn.heads + Dd = D // H + q = _fp4_gemm_raw(packed1, sfa1, attn.to_q, S, device, kern) + k = _fp4_gemm_raw(packed1, sfa1, attn.to_k, S, device, kern) + v = _fp4_gemm_raw(packed1, sfa1, attn.to_v, S, device, kern) + + if attn.norm_q is not None: + q = rms_norm_fp32stat(q, attn.norm_q.weight, attn.norm_q.eps) + if attn.norm_k is not None: + k = rms_norm_fp32stat(k, attn.norm_k.weight, attn.norm_k.eps) + + q = q.view(1, S, H, Dd) + k = k.view(1, S, H, Dd) + v = v.view(1, S, H, Dd) + + if rotary_emb is not None: + if cos_sin is None: + cos_sin = freqs_to_cos_sin(rotary_emb) + rope_apply_bshd(q, cos_sin[0], cos_sin[1]) + rope_apply_bshd(k, cos_sin[0], cos_sin[1]) + + q = q.contiguous() + k = k.contiguous() + v = v.contiguous() + scale = 1.0 / math.sqrt(float(Dd)) + out = _sage_attn(q, k, v, scale, _attention_mode()) + attn_out = attn.to_out[0](out.view(1, S, D)).view(S, D) + gate_mul_residual_bcast(hs, attn_out, mod[2]) + + # ── FFN sub-layer ── + norm2 = ada_layernorm_fp16_io(hs, mod[4], mod[3], eps) + packed2, sfa2 = _quant_to_nvfp4(norm2, kern) + + ffn_up = block.ffn.net[0].proj + n_inner = ffn_up.out_features + up_packed = torch.empty(S, n_inner // 2, dtype=torch.uint8, device=device) + up_sfd = torch.empty(kern.nvfp4_sf_swizzled_bytes(S, n_inner), + dtype=torch.uint8, device=device) + bias_ptr = ffn_up.bias.data_ptr() if ffn_up.bias is not None else 0 + kern.fp4_w4a16_gemm_bias_gelu_fp4out_sm120( + packed2.data_ptr(), ffn_up.weight_packed.data_ptr(), + sfa2.data_ptr(), ffn_up.weight_sfb.data_ptr(), + bias_ptr, up_packed.data_ptr(), up_sfd.data_ptr(), + S, n_inner, D, ffn_up.weight_alpha, stream) + + ff_out = _fp4_gemm_raw(up_packed, up_sfd, block.ffn.net[2], S, device, kern) + gate_mul_residual_bcast(hs, ff_out, mod[5]) + + return hs.view(1, S, D) + + +def block_forward_mod(block, hidden_states, mod, rotary_emb, eps, kern, + cos_sin=None): + """Non-fused block forward (debug fallback): per-projection re-quant.""" + _, S, D = hidden_states.shape + hs = hidden_states.contiguous().view(S, D) + + norm1 = ada_layernorm_fp16_io(hs, mod[1], mod[0], eps) + attn_out = block.attn1( + hidden_states=norm1.view(1, S, D), rotary_emb=rotary_emb).view(S, D) + gate_mul_residual_bcast(hs, attn_out, mod[2]) + + norm2 = ada_layernorm_fp16_io(hs, mod[4], mod[3], eps) + up = block.ffn.net[0].proj(norm2.view(1, S, D)) + inner = up.shape[-1] + stream = torch.cuda.current_stream().cuda_stream + _gelu_fn = kern.gelu_inplace if up.dtype == torch.bfloat16 else kern.gelu_inplace_fp16 + _gelu_fn(up.data_ptr(), S * inner, stream) + ff_out = block.ffn.net[2](up).view(S, D) + gate_mul_residual_bcast(hs, ff_out, mod[5]) + + return hs.view(1, S, D) + + +def transformer_forward_mod(transformer, hidden_states, mod_blocks_step, + mod_out_step, rotary_emb, eps, kern): + """Transformer forward with pre-computed modulation; no torch elementwise.""" + B, C_in, T, H, W = hidden_states.shape + p_t, p_h, p_w = transformer.config.patch_size + post_t, post_h, post_w = T // p_t, H // p_h, W // p_w + + hs = transformer.patch_embedding(hidden_states) + hs = hs.flatten(2).transpose(1, 2) + + cos_sin = freqs_to_cos_sin(rotary_emb) if rotary_emb is not None else None + block_fn = block_forward_fused if _USE_FUSED_BLOCK else block_forward_mod + + for blk_idx, block in enumerate(transformer.blocks): + hs = block_fn(block, hs, mod_blocks_step[blk_idx], rotary_emb, eps, kern, cos_sin) + + S, D = hs.shape[1], hs.shape[2] + hs_2d = hs.contiguous().view(S, D) + hs_2d = ada_layernorm_fp16_io(hs_2d, mod_out_step[1], mod_out_step[0], eps) + hs = transformer.proj_out(hs_2d.view(1, S, D)) + + hs = hs.reshape(B, post_t, post_h, post_w, p_t, p_h, p_w, -1) + hs = hs.permute(0, 7, 1, 4, 2, 5, 3, 6) + return hs.flatten(6, 7).flatten(4, 5).flatten(2, 3) + + +class ManualRemoverPipeline: + """Pointer-based, graph-capturable replacement for ``pipe.__call__``. + + Captures the full N-step denoise loop as one CUDA Graph (when + ``FLASHRT_MANUAL_GRAPH=1``) and replays it on subsequent calls with + the same latent shape. Inside the captured graph: zero torch + elementwise ops -- every operation is a kernel launch. + + The class reads everything it needs off the loaded ``pipe`` (its + transformer, vae, scheduler, video processor, expand_masks / resize + helpers and VAE scale factors); it imports no MiniMax-Remover code. + ``kern`` is the validated ``flash_rt_kernels`` module. + """ + + def __init__(self, pipe, kern): + self.kern = kern + self.pipe = pipe + self.transformer = pipe.transformer + self.vae = pipe.vae + self.scheduler = pipe.scheduler + self.device = pipe._execution_device + self.video_processor = pipe.video_processor + self.eps = float(pipe.transformer.config.eps) + self.num_blocks = len(pipe.transformer.blocks) + self.inner_dim = (pipe.transformer.config.num_attention_heads * + pipe.transformer.config.attention_head_dim) + self._dtype = next(pipe.transformer.parameters()).dtype + self._vae_dtype = next(pipe.vae.parameters()).dtype + + self._graphs = {} + self._mod_cache = None + self._rope_cache = {} + + @property + def vae_scale_factor_temporal(self): + return self.pipe.vae_scale_factor_temporal + + @property + def vae_scale_factor_spatial(self): + return self.pipe.vae_scale_factor_spatial + + def expand_masks(self, masks, iterations): + return self.pipe.expand_masks(masks, iterations) + + def resize(self, images, w, h): + return self.pipe.resize(images, w, h) + + def _precompute_modulation(self, temb_all, tproj_all, num_steps): + """Pre-compute all modulation vectors for steps x blocks + norm_out.""" + D = self.inner_dim + nb = self.num_blocks + tr = self.transformer + device = self.device + + mod_blocks = torch.empty(num_steps, nb, 6, D, dtype=torch.float32, device=device) + mod_out = torch.empty(num_steps, 2, D, dtype=torch.float32, device=device) + + with torch.no_grad(): + for step in range(num_steps): + tproj = tproj_all[step].float() + for blk_idx, block in enumerate(tr.blocks): + mod_blocks[step, blk_idx] = (block.scale_shift_table + tproj).squeeze(0) + temb = temb_all[step].float().unsqueeze(1) + mod_out[step] = (tr.scale_shift_table + temb).squeeze(0) + + self._mod_cache = (num_steps, mod_blocks, mod_out) + return mod_blocks, mod_out + + def __call__(self, images, masks, num_frames, height, width, + num_inference_steps=12, generator=None, iterations=16, + output_type="np"): + device = self.device + kern = self.kern + + self.scheduler.set_timesteps(num_inference_steps, device=device) + timesteps = self.scheduler.timesteps + sigmas = self.scheduler.sigmas + dt_all = [float(sigmas[i + 1] - sigmas[i]) for i in range(num_inference_steps)] + + vae_t = self.vae_scale_factor_temporal + vae_s = self.vae_scale_factor_spatial + num_latent_frames = (num_frames - 1) // vae_t + 1 + lat_shape = (1, 16, num_latent_frames, height // vae_s, width // vae_s) + latents = randn_tensor(lat_shape, generator=generator, + device=device, dtype=self._dtype) + + masks_t = self.expand_masks(masks, iterations) + masks_t = self.resize(masks_t, height, width).to(device).to(self._vae_dtype) + masks_t[masks_t > 0] = 1 + images_t = rearrange(images, "f h w c -> c f h w") + images_t = self.resize(images_t[None, ...], height, width).to(device).to(self._vae_dtype) + masked_images = mask_mul(images_t, masks_t) + + latents_mean = (torch.tensor(self.vae.config.latents_mean) + .view(1, self.vae.config.z_dim, 1, 1, 1) + .to(device, self._vae_dtype)) + latents_std = 1.0 / torch.tensor(self.vae.config.latents_std).view( + 1, self.vae.config.z_dim, 1, 1, 1).to(device, self._vae_dtype) + + with torch.no_grad(): + masked_latents = self.vae.encode(masked_images.to(self._vae_dtype)).latent_dist.mode() + masks_latents = self.vae.encode((2 * masks_t - 1.0).to(self._vae_dtype)).latent_dist.mode() + masked_latents = latent_normalize(masked_latents, latents_mean, latents_std).to(self._dtype) + masks_latents = latent_normalize(masks_latents, latents_mean, latents_std).to(self._dtype) + + if self._mod_cache is None or self._mod_cache[0] != num_inference_steps: + with torch.no_grad(): + temb_all, tproj_all = [], [] + for i in range(num_inference_steps): + t_step = timesteps[i:i + 1] + temb_i, tproj_i = self.transformer.condition_embedder(t_step) + tproj_i = tproj_i.unflatten(1, (6, -1)) + temb_all.append(temb_i) + tproj_all.append(tproj_i) + mod_blocks, mod_out = self._precompute_modulation( + temb_all, tproj_all, num_inference_steps) + else: + mod_blocks, mod_out = self._mod_cache[1], self._mod_cache[2] + + rope_key = tuple(latents.shape) + rotary_emb = self._rope_cache.get(rope_key) + if rotary_emb is None: + with torch.no_grad(): + rotary_emb = self.transformer.rope(latents) + self._rope_cache[rope_key] = rotary_emb + + use_graph = os.environ.get("FLASHRT_MANUAL_GRAPH", "0") == "1" + shape_key = tuple(latents.shape) + entry = self._graphs.get(shape_key) + + if use_graph: + if entry is None: + entry = self._capture_graph( + latents, masked_latents, masks_latents, + mod_blocks, mod_out, dt_all, rotary_emb, num_inference_steps) + self._graphs[shape_key] = entry + graph, lat_buf, masked_buf, masks_buf = entry + lat_buf.copy_(latents) + masked_buf.copy_(masked_latents) + masks_buf.copy_(masks_latents) + graph.replay() + torch.cuda.synchronize() + result_latents = lat_buf.clone() + else: + result_latents = self._denoise_eager( + latents, masked_latents, masks_latents, + mod_blocks, mod_out, dt_all, rotary_emb, num_inference_steps) + + result_latents = latent_denormalize(result_latents.to(self._vae_dtype), + latents_mean, latents_std) + with torch.no_grad(): + video = self.vae.decode(result_latents, return_dict=False)[0] + video = self.video_processor.postprocess_video(video, output_type=output_type) + + from diffusers.pipelines.wan.pipeline_output import WanPipelineOutput + return WanPipelineOutput(frames=video) + + def _denoise_eager(self, latents, masked_latents, masks_latents, + mod_blocks, mod_out, dt_all, rotary_emb, num_steps): + device = latents.device + kern = self.kern + C = latents.shape[1] + concat_buf = torch.empty(latents.shape[0], 3 * C, *latents.shape[2:], + dtype=self._dtype, device=device) + lat_buf = latents.clone() + tr = self.transformer + eps = self.eps + + for step in range(num_steps): + concat_buf[:, :C].copy_(lat_buf) + concat_buf[:, C:2 * C].copy_(masked_latents) + concat_buf[:, 2 * C:3 * C].copy_(masks_latents) + noise_pred = transformer_forward_mod( + tr, concat_buf, mod_blocks[step], mod_out[step], rotary_emb, eps, kern) + euler_step_inplace(lat_buf, noise_pred, dt_all[step]) + + return lat_buf + + def _capture_graph(self, latents, masked_latents, masks_latents, + mod_blocks, mod_out, dt_all, rotary_emb, num_steps): + """Capture the entire N-step denoise loop as one CUDA Graph.""" + device = latents.device + kern = self.kern + C = latents.shape[1] + B, _, T, H, W = latents.shape + + lat_buf = latents.clone() + masked_buf = masked_latents.clone() + masks_buf = masks_latents.clone() + concat_buf = torch.empty(B, 3 * C, T, H, W, dtype=self._dtype, device=device) + + tr = self.transformer + eps = self.eps + + def denoise(): + for step in range(num_steps): + concat_buf[:, :C].copy_(lat_buf) + concat_buf[:, C:2 * C].copy_(masked_buf) + concat_buf[:, 2 * C:3 * C].copy_(masks_buf) + noise_pred = transformer_forward_mod( + tr, concat_buf, mod_blocks[step], mod_out[step], rotary_emb, eps, kern) + euler_step_inplace(lat_buf, noise_pred, dt_all[step]) + + s = torch.cuda.Stream() + with torch.cuda.stream(s): + concat_buf[:, :C].copy_(lat_buf) + concat_buf[:, C:2 * C].copy_(masked_buf) + concat_buf[:, 2 * C:3 * C].copy_(masks_buf) + noise_pred = transformer_forward_mod( + tr, concat_buf, mod_blocks[0], mod_out[0], rotary_emb, eps, kern) + euler_step_inplace(lat_buf, noise_pred, dt_all[0]) + torch.cuda.current_stream().wait_stream(s) + torch.cuda.synchronize() + lat_buf.copy_(latents) + + graph = torch.cuda.CUDAGraph() + with torch.cuda.graph(graph, stream=s): + denoise() + + return (graph, lat_buf, masked_buf, masks_buf) diff --git a/flash_rt/models/minimax_remover/_nvfp4_linear.py b/flash_rt/models/minimax_remover/_nvfp4_linear.py new file mode 100644 index 00000000..dfb282f8 --- /dev/null +++ b/flash_rt/models/minimax_remover/_nvfp4_linear.py @@ -0,0 +1,236 @@ +"""FlashRT -- MiniMax-Remover NVFP4 (W4A4) Linear layer. + +Replaces every eligible ``nn.Linear`` in the Transformer3DModel with a +NVFP4 W4A4 GEMM backed by the generic FlashRT NVFP4 kernels: + +* Weights are quantised once at load time to NVFP4 via + ``bf16_weight_to_nvfp4_swizzled`` (packed [N, K/2] e2m1 + tile-swizzled + block-scale factors + a host-scalar global ``alpha``). +* Activations are quantised dynamically per call via + ``quantize_bf16_to_nvfp4_swizzled`` (per-16-element UE4M3 block scales + computed on-GPU, no CPU sync, no offline calibration -- this is the + key difference from the static FP8 path). +* The GEMM ``fp4_w4a16_gemm_sm120_bf16out`` is the SM120-native W4A4 + MMA producing a bf16 output (the kernel name keeps the legacy + ``w4a16`` token but both operands are FP4). + +Shape constraints (SM120 FP4 MMA): K >= 64, K % 16 == 0, N % 16 == 0. +All MiniMax-Remover Linears (K in {5120, 13824}, N in {64, 5120, 13824}) +satisfy this; ineligible layers (none in practice) keep the original +Linear. NVFP4 needs no calibration, so the calibration shims below are +no-ops kept only for API compatibility. +""" + +import os +from typing import Optional + +import torch +import torch.nn as nn + +_BF16 = torch.bfloat16 +_FP16 = torch.float16 + +_FP4_MIN_K = 64 +_FP4_ALIGN = 16 + + +def _sf_swizzled_bytes(rows, dim, kern): + return kern.nvfp4_sf_swizzled_bytes(rows, dim) + + +def _quantize_weight_nvfp4(w, kern): + """Quantise an [N, K] bf16 weight to NVFP4. + + Returns ``(packed[N, K/2] uint8, sfb uint8, alpha python float)``. + """ + assert w.dtype == _BF16, f"FP4 weight quant requires bf16, got {w.dtype}" + N, K = w.shape + assert K % _FP4_ALIGN == 0 and N % _FP4_ALIGN == 0 and K >= _FP4_MIN_K, ( + f"FP4 weight shape unsupported N={N} K={K} " + f"(need K>=64, N%16==0, K%16==0)") + w = w.contiguous() + packed = torch.empty(N, K // 2, dtype=torch.uint8, device=w.device) + sfb = torch.empty(_sf_swizzled_bytes(N, K, kern), dtype=torch.uint8, device=w.device) + scratch_amax = torch.empty(1, dtype=torch.float32, device=w.device) + out_global = torch.empty(1, dtype=torch.float32, device=w.device) + kern.bf16_weight_to_nvfp4_swizzled( + w.data_ptr(), packed.data_ptr(), sfb.data_ptr(), + scratch_amax.data_ptr(), out_global.data_ptr(), N, K, 0) + torch.cuda.synchronize(w.device) + alpha = float(out_global.item()) + return packed, sfb, alpha + + +def _pick_gemm(kern): + mode = os.environ.get("FLASHRT_FP4_GEMM", "pingpong").lower() + if mode == "plain": + return kern.fp4_w4a16_gemm_sm120_bf16out + if mode == "widen": + return kern.fp4_w4a16_gemm_sm120_bf16out_widen + return kern.fp4_w4a16_gemm_sm120_bf16out_pingpong + + +class FlashRTNvfp4Linear(nn.Module): + """Linear layer backed by the FlashRT NVFP4 (W4A4) GEMM. + + Computes ``y = x @ weight^T + bias`` where the weight is stored as + NVFP4 and the activation is quantised to NVFP4 dynamically per call. + The SM120 FP4 kernels are bf16-native; the residual stream is fp16, + so this layer casts fp16 -> bf16 on entry and bf16 -> fp16 on exit + (two large-tensor casts, memory-bound, ~0.04 ms/layer). + """ + + def __init__(self, in_features, out_features, bias=True, + device=None, dtype=torch.float16, kern=None): + super().__init__() + self.in_features = in_features + self.out_features = out_features + self._kern = kern + + self.weight_packed = nn.Parameter( + torch.empty(out_features, in_features // 2, dtype=torch.uint8, device=device), + requires_grad=False) + self.weight_sfb = nn.Parameter( + torch.empty(_sf_swizzled_bytes(out_features, in_features, kern), + dtype=torch.uint8, device=device), + requires_grad=False) + self.weight_alpha = 1.0 + self.register_buffer( + "weight_alpha_buf", torch.ones(1, dtype=torch.float32, device=device), + persistent=False) + + if bias: + self.bias = nn.Parameter( + torch.zeros(out_features, dtype=_BF16, device=device), + requires_grad=False) + else: + self.register_parameter("bias", None) + + self.calibrating = False + self._gemm = _pick_gemm(kern) + + @property + def weight(self): + return self.weight_packed + + @classmethod + def from_linear(cls, linear, kern): + in_f, out_f = linear.weight.shape[1], linear.weight.shape[0] + layer = cls(in_f, out_f, bias=linear.bias is not None, + device=linear.weight.device, dtype=_FP16, kern=kern) + w_bf16 = linear.weight.data.to(_BF16) + packed, sfb, alpha = _quantize_weight_nvfp4(w_bf16, kern) + layer.weight_packed.data = packed + layer.weight_sfb.data = sfb + layer.weight_alpha = alpha + layer.weight_alpha_buf.data = torch.tensor([alpha], dtype=torch.float32, + device=w_bf16.device) + if linear.bias is not None: + layer.bias.data = linear.bias.data.to(_BF16) + return layer + + def forward(self, x): + kern = self._kern + in_dtype = x.dtype + if x.dtype != _BF16: + x = x.to(_BF16) + + orig_shape = x.shape + x2d = x.reshape(-1, self.in_features) + if x2d.stride(0) != self.in_features or x2d.stride(1) != 1: + x2d = x2d.contiguous() + m = x2d.shape[0] + k, n = self.in_features, self.out_features + stream = torch.cuda.current_stream().cuda_stream + + a_packed = torch.empty(m, k // 2, dtype=torch.uint8, device=x2d.device) + a_sfa = torch.empty(_sf_swizzled_bytes(m, k, kern), dtype=torch.uint8, device=x2d.device) + kern.quantize_bf16_to_nvfp4_swizzled( + x2d.data_ptr(), a_packed.data_ptr(), a_sfa.data_ptr(), m, k, stream) + + out = torch.empty(m, n, dtype=_BF16, device=x2d.device) + self._gemm( + a_packed.data_ptr(), self.weight_packed.data_ptr(), out.data_ptr(), + m, n, k, a_sfa.data_ptr(), self.weight_sfb.data_ptr(), + self.weight_alpha, stream) + if self.bias is not None: + kern.add_bias_bf16(out.data_ptr(), self.bias.data_ptr(), m, n, stream) + out = out.view(*orig_shape[:-1], n) + + if in_dtype != _BF16: + out = out.to(in_dtype) + return out + + def freeze_act_scale(self, margin=1.0): + pass + + +def _is_nvfp4_eligible(linear): + if not isinstance(linear, nn.Linear): + return False + in_f, out_f = linear.weight.shape[1], linear.weight.shape[0] + return (in_f % _FP4_ALIGN == 0 and out_f % _FP4_ALIGN == 0 + and in_f >= _FP4_MIN_K) + + +def install_flashrt_nvfp4(model, kern, verbose=False, target="all"): + """Recursively replace eligible Linears in ``model`` with NVFP4 layers. + + ``target``: + * ``"all"`` (default) -- every attention / ffn / proj Linear. + * ``"ffn_only"`` -- only the FFN up/down projections (higher + precision, the speedup comes from the large GEMMs anyway). + Timestep / condition embedders are always skipped (precision + sensitive and small). Returns the number of replaced layers. + """ + replaced = 0 + skipped_shape = 0 + skip_substr = ("time_embedder", "condition_embedder") + + if target == "ffn_only": + include_patterns = ("ffn.net.0.proj", "ffn.net.2") + else: + include_patterns = () + + def _should_replace(name): + if any(s in name for s in skip_substr): + return False + if include_patterns: + return any(p in name for p in include_patterns) + return True + + targets = [] + for name, module in model.named_modules(): + if not isinstance(module, nn.Linear): + continue + if not _should_replace(name): + continue + targets.append((name, module)) + + for name, linear in targets: + if not _is_nvfp4_eligible(linear): + skipped_shape += 1 + continue + parent = model + for p in name.split(".")[:-1]: + parent = getattr(parent, p) + attr = name.split(".")[-1] + setattr(parent, attr, FlashRTNvfp4Linear.from_linear(linear, kern)) + replaced += 1 + + return replaced + + +def set_calibration(model, on): + for module in model.modules(): + if isinstance(module, FlashRTNvfp4Linear): + module.calibrating = on + + +def freeze_calibration(model, margin=1.0): + n = 0 + for module in model.modules(): + if isinstance(module, FlashRTNvfp4Linear): + module.freeze_act_scale(margin) + n += 1 + return n diff --git a/flash_rt/models/minimax_remover/pipeline.py b/flash_rt/models/minimax_remover/pipeline.py new file mode 100644 index 00000000..5abc4ac8 --- /dev/null +++ b/flash_rt/models/minimax_remover/pipeline.py @@ -0,0 +1,207 @@ +"""FlashRT -- MiniMax-Remover NVFP4 kernelized inference pipeline. + +MiniMax-Remover is a flow-matching video inpainting model (a +Transformer3DModel + an AutoencoderKLWan VAE). This pipeline rewrites the +transformer denoise path onto NVFP4 (W4A4) GEMMs driven by the generic +FlashRT kernels, fused fp32-stat Triton norm/RoPE, kernel attention and +a graph-capturable manual N-step flow-matching loop. + +What is replaced (vs the diffusers reference ``Minimax_Remover_Pipeline``): + +* every eligible transformer Linear -> NVFP4 W4A4 GEMM (weight quantised + once at load time, activation quantised dynamically per call -- no + offline calibration). +* per-block norm/gate/residual/gelu elementwise -> single fused kernels. +* ``torch.nn.functional.scaled_dot_product_attention`` -> FA2 / SageAttention. +* the N-step Python denoise loop -> a single captured CUDA Graph + (``FLASHRT_MANUAL_GRAPH=1``); inside the graph there are zero torch + elementwise ops, only kernel launches. + +The loaded diffusers ``pipe`` is consumed in place: it is duck-typed +(``.transformer`` / ``.vae`` / ``.scheduler`` / ``.video_processor`` and +the ``expand_masks`` / ``resize`` helpers). No MiniMax-Remover source is +imported -- the VAE encode / decode run unchanged from the loaded model. + +The pipeline requires the generic SM120 NVFP4 kernels in +``flash_rt_kernels``. They are gated by the Blackwell NVFP4 build option +(``ENABLE_CUTLASS_SM120_NVFP4_W4A16``); ``_load_kernels`` validates them +and raises a clear ``RuntimeError`` if they are absent, so a non-NVFP4 +build fails fast instead of crashing mid-quantisation. +""" + +import logging +import os +import types + +import torch + +from ._nvfp4_linear import install_flashrt_nvfp4 +from ._attention import install_attention +from ._manual_denoise import ManualRemoverPipeline + +logger = logging.getLogger(__name__) + +# Core generic SM120 NVFP4 kernel surface required by this pipeline. All of +# these are gated by the Blackwell NVFP4 build option; if any is missing the +# pipeline cannot run and _load_kernels raises a clear RuntimeError. +_REQUIRED_NVFP4_SYMBOLS = ( + "nvfp4_sf_swizzled_bytes", + "bf16_weight_to_nvfp4_swizzled", + "quantize_bf16_to_nvfp4_swizzled", + "fp4_w4a16_gemm_sm120_bf16out_pingpong", + "add_bias_bf16", + "fp4_w4a16_gemm_bias_gelu_fp4out_sm120", +) + + +def _load_kernels(): + """Import ``flash_rt_kernels`` and validate the required NVFP4 surface. + + Returns the raw ``flash_rt_kernels`` module. Raises ``RuntimeError`` + if any required symbol is missing (i.e. the build was not configured + with the Blackwell NVFP4 kernels enabled). + """ + try: + from flash_rt import flash_rt_kernels as fvk + except ImportError: + try: + import flash_rt_kernels as fvk # type: ignore + except ImportError: + raise RuntimeError( + "flash_rt_kernels is not available. Build FlashRT with " + "'pip install -e .' and ensure the compiled .so is on the path.") + + missing = [s for s in _REQUIRED_NVFP4_SYMBOLS if not hasattr(fvk, s)] + if missing: + raise RuntimeError( + "MiniMax-Remover requires the SM120 NVFP4 kernels which are not " + "compiled into flash_rt_kernels. Rebuild with the Blackwell NVFP4 " + "build option enabled (ENABLE_CUTLASS_SM120_NVFP4_W4A16) so the " + "NVFP4 quantise / GEMM / fused-bias-gelu kernels are present.\n" + f"Missing symbols: {', '.join(missing)}") + return fvk + + +class _FrozenMarker: + """NVFP4 needs no calibration (per-call dynamic quantisation). + + This marker reports ``is_frozen() == True`` so a smart ``__call__`` + routes straight to the manual graph pipeline on the first call. + """ + + def __init__(self): + self._frozen = True + + def is_frozen(self): + return self._frozen + + +class MiniMaxRemoverPipeline: + """NVFP4 kernelized inference pipeline for MiniMax-Remover. + + Wraps a loaded diffusers MiniMax-Remover ``pipe`` and rewrites the + transformer denoise path onto NVFP4 GEMMs, fused Triton norm/RoPE, + kernel attention and a graph-capturable manual flow-matching loop. + The pipe is consumed in place; transformer Linears referenced by the + patched path are quantised to NVFP4 and the original weights deleted. + + Args: + pipe: a loaded diffusers pipeline exposing ``.transformer``, + ``.vae``, ``.scheduler``, ``.video_processor``, + ``_execution_device``, ``expand_masks``, ``resize`` and the + ``vae_scale_factor_temporal`` / ``vae_scale_factor_spatial`` + properties. + num_inference_steps: default denoise step count (12). + fp4_target: ``"all"`` (default, every attention/ffn/proj Linear) + or ``"ffn_only"`` (FFN up/down only). + use_bf16: run the transformer in bf16 (NVFP4-native, no cast). + use_manual_pipeline: install the graph-capturable manual denoise + loop (default ``True``). + """ + + def __init__(self, pipe, num_inference_steps=12, fp4_target="all", + use_bf16=True, use_manual_pipeline=True): + self.fvk = _load_kernels() + self.pipe = pipe + self.transformer = pipe.transformer + self.num_inference_steps = num_inference_steps + + n_lin = install_flashrt_nvfp4(self.transformer, self.fvk, + verbose=False, target=fp4_target) + logger.info("MiniMax-Remover: target=%r, %d Linears -> NVFP4 W4A4 GEMM", + fp4_target, n_lin) + + if use_bf16: + self.transformer.to(torch.bfloat16) + logger.info("MiniMax-Remover: transformer -> bf16 (NVFP4-native)") + + self._optimize_rope() + + n_attn = install_attention(self.transformer) + logger.info("MiniMax-Remover: %d attention blocks -> kernel backend", n_attn) + + pipe._flashrt_wrapper = _FrozenMarker() + + self._manual = None + if use_manual_pipeline: + self._manual = ManualRemoverPipeline(pipe, self.fvk) + self._install_smart_call() + logger.info("MiniMax-Remover: manual denoise pipeline installed " + "(CUDA-graph capturable, NVFP4 dynamic quant -- no calibration)") + + def _optimize_rope(self): + """Cache the RoPE freqs as complex. + + The reference caches complex128/float64 which is slow on consumer + GPUs. Patched on the loaded rope module's class (no model import). + """ + rope_module = getattr(self.transformer, "rope", None) + if rope_module is None: + return + rope_cls = type(rope_module) + orig = rope_cls.forward + + def rope_fwd_c64(self, hidden_states): + out = orig(self, hidden_states) + return out.to(torch.complex64) if out.dtype == torch.complex128 else out + + rope_cls.forward = rope_fwd_c64 + + def _install_smart_call(self): + """Route ``pipe.__call__`` to the manual pipeline (frozen, no calibration).""" + _orig_call = self.pipe.__class__.__call__ + + @torch.no_grad() + def _smart_call(self, *args, **kwargs): + w = getattr(self, "_flashrt_wrapper", None) + m = getattr(self, "_manual_pipeline", None) + ns = os.environ.get("FLASHRT_NUM_STEPS") + if ns: + kwargs["num_inference_steps"] = int(ns) + if w is not None and w.is_frozen() and m is not None: + return m(*args, **kwargs) + return _orig_call(self, *args, **kwargs) + + self.pipe.__class__.__call__ = _smart_call + self.pipe._manual_pipeline = self._manual + + def __call__(self, images, masks, num_frames, height, width, + num_inference_steps=None, generator=None, iterations=16, + output_type="np"): + """Run the NVFP4 denoise loop. + + Delegates to the manual pipeline when installed (the optimised + path), otherwise to the patched diffusers ``pipe.__call__``. + """ + if num_inference_steps is None: + num_inference_steps = self.num_inference_steps + if self._manual is not None: + return self._manual( + images, masks, num_frames, height, width, + num_inference_steps=num_inference_steps, generator=generator, + iterations=iterations, output_type=output_type) + return self.pipe( + images=images, masks=masks, num_frames=num_frames, + height=height, width=width, + num_inference_steps=num_inference_steps, generator=generator, + iterations=iterations, output_type=output_type) diff --git a/tests/test_minimax_remover_smoke.py b/tests/test_minimax_remover_smoke.py new file mode 100644 index 00000000..f27468a7 --- /dev/null +++ b/tests/test_minimax_remover_smoke.py @@ -0,0 +1,170 @@ +"""Smoke tests for MiniMax-Remover FlashRT integration. + +These tests run in **any** build configuration: + - default build (SM120 NVFP4 kernels absent): import succeeds, + ``_load_kernels`` raises ``RuntimeError``, pipeline construction + fails fast. + - gated build (SM120 NVFP4 kernels present): the required NVFP4 + symbols are present and callable. + +No GPU, no model checkpoint, no MiniMax-Remover source tree is required. +""" +import pytest + + +# ── 1. Package import always succeeds ── + +def test_package_import(): + """Importing the model package must not require flash_rt_kernels.""" + from flash_rt.models.minimax_remover import MiniMaxRemoverPipeline + assert MiniMaxRemoverPipeline is not None + + +def test_pipeline_module_import(): + """The pipeline module imports cleanly without flash_rt_kernels.""" + from flash_rt.models.minimax_remover import pipeline + assert hasattr(pipeline, "MiniMaxRemoverPipeline") + assert hasattr(pipeline, "_load_kernels") + assert hasattr(pipeline, "_REQUIRED_NVFP4_SYMBOLS") + assert "nvfp4_sf_swizzled_bytes" in pipeline._REQUIRED_NVFP4_SYMBOLS + + +# ── 2. _load_kernels validates the NVFP4 surface ── + +def test_load_kernels_raises_when_symbols_absent(): + """Without the NVFP4 kernels, _load_kernels raises a clear RuntimeError.""" + from flash_rt.models.minimax_remover import pipeline + + class _NoNvfp4: + # Module stub exposing none of the required symbols. + pass + + import sys + import types + + fake_mod = types.ModuleType("flash_rt.flash_rt_kernels") + sys.modules["flash_rt.flash_rt_kernels"] = fake_mod + try: + with pytest.raises(RuntimeError) as excinfo: + pipeline._load_kernels() + msg = str(excinfo.value) + assert "NVFP4" in msg + assert "Missing symbols" in msg + assert "nvfp4_sf_swizzled_bytes" in msg + finally: + del sys.modules["flash_rt.flash_rt_kernels"] + + +def test_load_kernels_succeeds_when_symbols_present(): + """With all required symbols, _load_kernels returns the kernels module.""" + from flash_rt.models.minimax_remover import pipeline + + import sys + import types + + fake_mod = types.ModuleType("flash_rt.flash_rt_kernels") + for s in pipeline._REQUIRED_NVFP4_SYMBOLS: + setattr(fake_mod, s, lambda *a, **k: None) + sys.modules["flash_rt.flash_rt_kernels"] = fake_mod + try: + fvk = pipeline._load_kernels() + assert fvk is fake_mod + finally: + del sys.modules["flash_rt.flash_rt_kernels"] + + +# ── 3. Pipeline construction validates kernel availability ── + +class _FakePipe: + """Minimal stub matching the diffusers pipeline contract. + + Construction must fail at _load_kernels before any pipe attribute is + touched, so the stub is never actually read. + """ + + +def test_pipeline_constructor_validates_kernels(monkeypatch): + """Pipeline construction must fail before touching model internals.""" + from flash_rt.models.minimax_remover import pipeline + + def _raise_missing(): + raise RuntimeError( + "MiniMax-Remover requires the SM120 NVFP4 kernels which are not " + "compiled into flash_rt_kernels. Rebuild with the Blackwell NVFP4 " + "build option enabled.") + + monkeypatch.setattr(pipeline, "_load_kernels", _raise_missing) + with pytest.raises(RuntimeError, match="NVFP4"): + pipeline.MiniMaxRemoverPipeline(_FakePipe()) + + +def test_pipeline_constructor_calls_load_kernels(monkeypatch): + """_load_kernels is invoked exactly once during construction.""" + from flash_rt.models.minimax_remover import pipeline + + calls = [] + + def _fake_load(): + calls.append(1) + raise RuntimeError("stop construction here") + + monkeypatch.setattr(pipeline, "_load_kernels", _fake_load) + with pytest.raises(RuntimeError, match="stop construction"): + pipeline.MiniMaxRemoverPipeline(_FakePipe()) + assert len(calls) == 1 + + +# ── 4. Gated build: required NVFP4 symbols present and callable ── + +def test_nvfp4_symbols_present_when_gated(): + """In a gated build, every required NVFP4 symbol is present & callable.""" + try: + from flash_rt import flash_rt_kernels as fvk + except ImportError: + try: + import flash_rt_kernels as fvk # type: ignore + except ImportError: + pytest.skip("flash_rt_kernels not built") + + from flash_rt.models.minimax_remover.pipeline import _REQUIRED_NVFP4_SYMBOLS + missing = [s for s in _REQUIRED_NVFP4_SYMBOLS if not hasattr(fvk, s)] + if missing: + pytest.skip(f"SM120 NVFP4 kernels not compiled (missing: {', '.join(missing)})") + + for sym in _REQUIRED_NVFP4_SYMBOLS: + assert callable(getattr(fvk, sym)), f"{sym} is not callable" + + +def test_nvfp4_symbols_absent_in_default_build(): + """In a default (non-NVFP4) build, _load_kernels documents the gap. + + This documents the 'compile option OFF' case end-to-end: if any + required symbol is missing the pipeline refuses to construct. + """ + try: + from flash_rt import flash_rt_kernels as fvk + except ImportError: + pytest.skip("flash_rt_kernels not built (treated as missing)") + from flash_rt.models.minimax_remover.pipeline import _REQUIRED_NVFP4_SYMBOLS + + missing = [s for s in _REQUIRED_NVFP4_SYMBOLS if not hasattr(fvk, s)] + if not missing: + pytest.skip("this build has the NVFP4 kernels (gated build) — covered elsewhere") + + # Verify _load_kernels raises and names the missing symbols. + import sys + import types + + fake_mod = types.ModuleType("flash_rt.flash_rt_kernels") + for s in _REQUIRED_NVFP4_SYMBOLS: + if hasattr(fvk, s): + setattr(fake_mod, s, getattr(fvk, s)) + sys.modules["flash_rt.flash_rt_kernels"] = fake_mod + try: + from flash_rt.models.minimax_remover import pipeline + with pytest.raises(RuntimeError) as excinfo: + pipeline._load_kernels() + for s in missing: + assert s in str(excinfo.value) + finally: + del sys.modules["flash_rt.flash_rt_kernels"] From 4b366e8a4acfdbf4566dd7664661ec1c0d695889 Mon Sep 17 00:00:00 2001 From: chenping Date: Thu, 2 Jul 2026 14:16:56 +0800 Subject: [PATCH 02/23] feat(minimax-remover): lazy-load runtime deps and improve build docs --- docs/minimax_remover_usage.md | 42 +- examples/minimax_remover_quickstart.py | 690 ++++++++++++++++++ flash_rt/models/minimax_remover/_attention.py | 10 +- flash_rt/models/minimax_remover/pipeline.py | 57 +- pyproject.toml | 8 +- 5 files changed, 780 insertions(+), 27 deletions(-) create mode 100644 examples/minimax_remover_quickstart.py diff --git a/docs/minimax_remover_usage.md b/docs/minimax_remover_usage.md index e3b51ef9..3c813790 100644 --- a/docs/minimax_remover_usage.md +++ b/docs/minimax_remover_usage.md @@ -6,25 +6,35 @@ MiniMax-Remover video inpainting (subtitle / object removal) with NVFP4 ## Build The pipeline reuses the **generic** FlashRT SM120 NVFP4 kernels — it -ships **no model-specific CUDA operators**. It requires those generic -kernels to be compiled in. Build FlashRT on a Blackwell host with the -NVFP4 build option enabled so the NVFP4 quantise / W4A4 GEMM / -fused-bias-gelu kernels are present in `flash_rt_kernels.so`: +ships **no model-specific CUDA operators**. Building for Blackwell +auto-enables the NVFP4 kernels (the NVFP4 quantise / W4A4 GEMM / +fused-bias-gelu entry points land in `flash_rt_kernels.so`): ```bash cd FlashRT -mkdir build && cd build -cmake .. -DENABLE_CUTLASS_SM120_NVFP4_W4A16=ON -make -j$(nproc) -pip install -e .. +cmake -S . -B build -DGPU_ARCH=120 -DCMAKE_BUILD_TYPE=Release +cmake --build build -j --target flash_rt_kernels +pip install -e ".[torch,minimax-remover]" ``` -Importing `flash_rt.models.minimax_remover` always succeeds; the kernel -surface is validated lazily in `MiniMaxRemoverPipeline.__init__` via -`_load_kernels()`. If any required symbol is missing the constructor -raises a clear `RuntimeError` listing the missing names, so a non-NVFP4 -build fails fast instead of crashing mid-quantisation. The required -generic symbols are: +`GPU_ARCH=120` (RTX 5090) or `121` selects the Blackwell target; the +NVFP4 surface is compiled in automatically (internally gated by +`ENABLE_CUTLASS_SM120_NVFP4_W4A16`, which is set from `GPU_ARCH`, not a +flag users pass). Then install the runtime extras: + +```bash +pip install -e ".[minimax-remover]" # diffusers + einops + sageattention +``` + +Importing `flash_rt.models.minimax_remover` always succeeds — it needs +**none** of `diffusers` / `einops` / `triton` / `sageattention`. The +kernel surface is validated lazily in +`MiniMaxRemoverPipeline.__init__` via `_load_kernels()`, and the runtime +deps are resolved at construction via `_import_runtime()`. If a required +symbol or dep is missing the constructor raises a clear `RuntimeError` +with the rebuild/install hint, so a non-NVFP4 build or a bare +environment fails fast instead of crashing mid-run. The required generic +symbols are: | Symbol | Role | |--------|------| @@ -36,8 +46,8 @@ generic symbols are: | `fp4_w4a16_gemm_bias_gelu_fp4out_sm120` | fused FFN-up GEMM + bias + GELU -> FP4 | The attention backend (`FLASHRT_ATTN_MODE`) optionally pulls in -`sageattention` (Sage) or `triton_flash_attn` (Triton FA); `fa2` uses -the vendored `flash_rt_fa2.so`. The fused norm / RoPE / Euler-step +`sageattention` (Sage); `fa2` uses the vendored `flash_rt_fa2.so` and is +the dependency-light fallback. The fused norm / RoPE / Euler-step elementwise kernels are self-contained Triton JIT kernels shipped in the package (`_kernels.py`) and need no build step. diff --git a/examples/minimax_remover_quickstart.py b/examples/minimax_remover_quickstart.py new file mode 100644 index 00000000..06f3735d --- /dev/null +++ b/examples/minimax_remover_quickstart.py @@ -0,0 +1,690 @@ +#!/usr/bin/env python3 +"""MiniMax-Remover full-frame mask removal -- FlashRT NVFP4 quickstart. + +End-to-end demo of the FlashRT-accelerated MiniMax-Remover video mask +removal pipeline. Every frame (plus its binary mask) is fed to the model +at once -- no segmentation, no bbox cropping. Mask pixels above the +threshold mark the region to inpaint; the rest of the frame is preserved. + +This is a FlashRT optimization of the upstream project: + https://github.com/zibojia/MiniMax-Remover + +It wraps a loaded diffusers MiniMax-Remover pipeline with +``flash_rt.models.minimax_remover.MiniMaxRemoverPipeline``, which rewrites +the transformer denoise path onto NVFP4 (W4A4) GEMMs, fused fp32-stat +Triton norm/RoPE, kernel attention and a graph-capturable manual +flow-matching loop. + +------------------------------------------------------------------ +Test data +------------------------------------------------------------------ +Sample frames + masks (numeric filenames, aligned by frame number): + + git clone https://github.com/chenping9999/object_removal_data.git + +------------------------------------------------------------------ +Model weights +------------------------------------------------------------------ +Download the VAE / transformer / scheduler once: + + huggingface-cli download zibojia/minimax-remover \ + --include vae transformer scheduler --local-dir ./minimax-remover + +------------------------------------------------------------------ +Build +------------------------------------------------------------------ +FlashRT must be built for Blackwell so the generic SM120 NVFP4 kernels +are compiled in (GPU_ARCH=120 / 121 auto-enables them): + + cmake -S . -B build -DGPU_ARCH=120 -DCMAKE_BUILD_TYPE=Release + cmake --build build -j --target flash_rt_kernels + pip install -e ".[torch,minimax-remover]" + +------------------------------------------------------------------ +Run +------------------------------------------------------------------ + python3 examples/minimax_remover_quickstart.py \ + --model-dir ./minimax-remover \ + --frames-dir ./object_removal_data/ \ + --masks-dir ./object_removal_data/ \ + --output-dir ./out + +------------------------------------------------------------------ +Precision note +------------------------------------------------------------------ +NVFP4 (W4A4) is calibrated for the model's large GEMMs. The fused +norm/RoPE kernels keep the precision-critical path fp32-stat. For +large full-frame latents, 4-bit weight/activation quantisation can +accumulate small per-block error; if you observe colour drift on a +high-resolution full-frame job, prefer the bbox-cropped regime (crop +to the mask region before inference) where the quantisation grid is +tighter. The provided test data is sized for this path. + +The VAE temporal compression factor is 4, so the input frame count +should be 4k+1 for a lossless round trip; trailing frames that the VAE +does not decode are copied from the source so the output frame count +always matches the input. Width/height not divisible by 16 are padded +bottom/right and cropped back on output. +""" +from __future__ import annotations + +import argparse +import math +import os +import shutil +import sys +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from typing import Dict, List, Optional, Tuple, Union + +os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") + +import numpy as np +import scipy +import torch +import torch.nn as nn +import torch.nn.functional as F +from PIL import Image + +# Make the flash_rt package importable when run directly from the repo root. +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +# diffusers model plumbing (vendored here so this demo depends only on pip +# packages -- no MiniMax-Remover source is imported). +from diffusers.configuration_utils import ConfigMixin, register_to_config +from diffusers.models import AutoencoderKLWan +from diffusers.models.attention import FeedForward +from diffusers.models.attention_processor import Attention +from diffusers.models.embeddings import (PixArtAlphaTextProjection, + TimestepEmbedding, Timesteps, + get_1d_rotary_pos_embed) +from diffusers.models.modeling_outputs import Transformer2DModelOutput +from diffusers.models.modeling_utils import ModelMixin +from diffusers.models.normalization import FP32LayerNorm +from diffusers.pipelines.pipeline_utils import DiffusionPipeline +from diffusers.pipelines.wan.pipeline_output import WanPipelineOutput +from diffusers.schedulers import FlowMatchEulerDiscreteScheduler +from diffusers.utils.torch_utils import randn_tensor +from diffusers.video_processor import VideoProcessor +from einops import rearrange + +DEVICE = torch.device("cuda:0") +MASK_THRESHOLD = 127 +NUM_INFERENCE_STEPS = 12 +PIPE_ITERATIONS = 6 +RANDOM_SEED = 42 + + +# ===================================================================== +# Reference model definition (self-contained). +# +# The Transformer3DModel below mirrors the upstream MiniMax-Remover +# architecture verbatim so the released checkpoint loads without pulling +# in the upstream source tree. FlashRT then patches its forward path. +# ===================================================================== + +class AttnProcessor2_0: + """Reference SDPA attention processor (replaced by FlashRT at runtime).""" + + def __init__(self): + if not hasattr(F, "scaled_dot_product_attention"): + raise ImportError( + "AttnProcessor2_0 requires PyTorch 2.0+.") + + def __call__(self, attn: Attention, hidden_states: torch.Tensor, + rotary_emb: Optional[torch.Tensor] = None, + attention_mask: Optional[torch.Tensor] = None, + encoder_hidden_states: Optional[torch.Tensor] = None + ) -> torch.Tensor: + encoder_hidden_states = hidden_states + query = attn.to_q(hidden_states) + key = attn.to_k(encoder_hidden_states) + value = attn.to_v(encoder_hidden_states) + + if attn.norm_q is not None: + query = attn.norm_q(query) + if attn.norm_k is not None: + key = attn.norm_k(key) + + query = query.unflatten(2, (attn.heads, -1)).transpose(1, 2) + key = key.unflatten(2, (attn.heads, -1)).transpose(1, 2) + value = value.unflatten(2, (attn.heads, -1)).transpose(1, 2) + + if rotary_emb is not None: + def apply_rotary_emb(hidden_states: torch.Tensor, freqs: torch.Tensor): + x_rotated = torch.view_as_complex( + hidden_states.to(torch.float64).unflatten(3, (-1, 2))) + x_out = torch.view_as_real(x_rotated * freqs).flatten(3, 4) + return x_out.type_as(hidden_states) + + query = apply_rotary_emb(query, rotary_emb) + key = apply_rotary_emb(key, rotary_emb) + + hidden_states = F.scaled_dot_product_attention( + query, key, value, attn_mask=None, dropout_p=0.0, is_causal=False) + hidden_states = hidden_states.transpose(1, 2).flatten(2, 3) + hidden_states = hidden_states.type_as(query) + hidden_states = attn.to_out[0](hidden_states) + hidden_states = attn.to_out[1](hidden_states) + return hidden_states + + +class TimeEmbedding(nn.Module): + def __init__(self, dim: int, time_freq_dim: int, time_proj_dim: int): + super().__init__() + self.timesteps_proj = Timesteps(num_channels=time_freq_dim, + flip_sin_to_cos=True, downscale_freq_shift=0) + self.time_embedder = TimestepEmbedding(in_channels=time_freq_dim, + time_embed_dim=dim) + self.act_fn = nn.SiLU() + self.time_proj = nn.Linear(dim, time_proj_dim) + + def forward(self, timestep: torch.Tensor): + timestep = self.timesteps_proj(timestep) + time_embedder_dtype = next(iter(self.time_embedder.parameters())).dtype + if timestep.dtype != time_embedder_dtype and time_embedder_dtype != torch.int8: + timestep = timestep.to(time_embedder_dtype) + temb = self.time_embedder(timestep).type_as(self.time_proj.weight.data) + timestep_proj = self.time_proj(self.act_fn(temb)) + return temb, timestep_proj + + +class RotaryPosEmbed(nn.Module): + def __init__(self, attention_head_dim: int, patch_size: Tuple[int, int, int], + max_seq_len: int, theta: float = 10000.0): + super().__init__() + self.attention_head_dim = attention_head_dim + self.patch_size = patch_size + self.max_seq_len = max_seq_len + + h_dim = w_dim = 2 * (attention_head_dim // 6) + t_dim = attention_head_dim - h_dim - w_dim + + freqs = [] + for dim in [t_dim, h_dim, w_dim]: + freq = get_1d_rotary_pos_embed( + dim, max_seq_len, theta, use_real=False, + repeat_interleave_real=False, freqs_dtype=torch.float64) + freqs.append(freq) + self.freqs = torch.cat(freqs, dim=1) + + def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: + batch_size, num_channels, num_frames, height, width = hidden_states.shape + p_t, p_h, p_w = self.patch_size + ppf, pph, ppw = num_frames // p_t, height // p_h, width // p_w + + self.freqs = self.freqs.to(hidden_states.device) + freqs = self.freqs.split_with_sizes( + [self.attention_head_dim // 2 - 2 * (self.attention_head_dim // 6), + self.attention_head_dim // 6, + self.attention_head_dim // 6], dim=1) + + freqs_f = freqs[0][:ppf].view(ppf, 1, 1, -1).expand(ppf, pph, ppw, -1) + freqs_h = freqs[1][:pph].view(1, pph, 1, -1).expand(ppf, pph, ppw, -1) + freqs_w = freqs[2][:ppw].view(1, 1, ppw, -1).expand(ppf, pph, ppw, -1) + freqs = torch.cat([freqs_f, freqs_h, freqs_w], dim=-1).reshape( + 1, 1, ppf * pph * ppw, -1) + return freqs + + +class TransformerBlock(nn.Module): + def __init__(self, dim: int, ffn_dim: int, num_heads: int, + qk_norm: str = "rms_norm_across_heads", cross_attn_norm: bool = False, + eps: float = 1e-6, added_kv_proj_dim: Optional[int] = None): + super().__init__() + self.norm1 = FP32LayerNorm(dim, eps, elementwise_affine=False) + self.attn1 = Attention( + query_dim=dim, heads=num_heads, kv_heads=num_heads, + dim_head=dim // num_heads, qk_norm=qk_norm, eps=eps, bias=True, + cross_attention_dim=None, out_bias=True, + processor=AttnProcessor2_0()) + self.ffn = FeedForward(dim, inner_dim=ffn_dim, activation_fn="gelu-approximate") + self.norm2 = FP32LayerNorm(dim, eps, elementwise_affine=False) + self.scale_shift_table = nn.Parameter(torch.randn(1, 6, dim) / dim ** 0.5) + + def forward(self, hidden_states: torch.Tensor, temb: torch.Tensor, + rotary_emb: torch.Tensor) -> torch.Tensor: + shift_msa, scale_msa, gate_msa, c_shift_msa, c_scale_msa, c_gate_msa = ( + self.scale_shift_table + temb.float()).chunk(6, dim=1) + norm_hidden_states = (self.norm1(hidden_states.float()) + * (1 + scale_msa) + shift_msa).type_as(hidden_states) + attn_output = self.attn1(hidden_states=norm_hidden_states, rotary_emb=rotary_emb) + hidden_states = (hidden_states.float() + + attn_output * gate_msa).type_as(hidden_states) + norm_hidden_states = (self.norm2(hidden_states.float()) + * (1 + c_scale_msa) + c_shift_msa).type_as(hidden_states) + ff_output = self.ffn(norm_hidden_states) + hidden_states = (hidden_states.float() + + ff_output.float() * c_gate_msa).type_as(hidden_states) + return hidden_states + + +class Transformer3DModel(ModelMixin, ConfigMixin): + """MiniMax-Remover Transformer (patch-embed + N adaLN blocks + proj_out). + + Loaded from the released checkpoint; its forward is then patched by + the FlashRT pipeline onto NVFP4 GEMMs + fused kernels. + """ + + _skip_layerwise_casting_patterns = ["patch_embedding", "condition_embedder", "norm"] + _no_split_modules = ["TransformerBlock"] + _keep_in_fp32_modules = ["time_embedder", "scale_shift_table", "norm1", "norm2"] + + @register_to_config + def __init__(self, patch_size: Tuple[int] = (1, 2, 2), + num_attention_heads: int = 40, attention_head_dim: int = 128, + in_channels: int = 16, out_channels: int = 16, + freq_dim: int = 256, ffn_dim: int = 13824, num_layers: int = 40, + cross_attn_norm: bool = True, + qk_norm: Optional[str] = "rms_norm_across_heads", + eps: float = 1e-6, added_kv_proj_dim: Optional[int] = None, + rope_max_seq_len: int = 1024) -> None: + super().__init__() + inner_dim = num_attention_heads * attention_head_dim + out_channels = out_channels or in_channels + + self.rope = RotaryPosEmbed(attention_head_dim, patch_size, rope_max_seq_len) + self.patch_embedding = nn.Conv3d( + in_channels, inner_dim, kernel_size=patch_size, stride=patch_size) + + self.condition_embedder = TimeEmbedding( + dim=inner_dim, time_freq_dim=freq_dim, time_proj_dim=inner_dim * 6) + + self.blocks = nn.ModuleList([ + TransformerBlock(inner_dim, ffn_dim, num_attention_heads, qk_norm, + cross_attn_norm, eps, added_kv_proj_dim) + for _ in range(num_layers)]) + + self.norm_out = FP32LayerNorm(inner_dim, eps, elementwise_affine=False) + self.proj_out = nn.Linear(inner_dim, out_channels * math.prod(patch_size)) + self.scale_shift_table = nn.Parameter(torch.randn(1, 2, inner_dim) / inner_dim ** 0.5) + + def forward(self, hidden_states: torch.Tensor, + timestep: torch.LongTensor) -> Union[torch.Tensor, dict]: + batch_size, num_channels, num_frames, height, width = hidden_states.shape + p_t, p_h, p_w = self.config.patch_size + post_patch_num_frames = num_frames // p_t + post_patch_height = height // p_h + post_patch_width = width // p_w + + rotary_emb = self.rope(hidden_states) + hidden_states = self.patch_embedding(hidden_states) + hidden_states = hidden_states.flatten(2).transpose(1, 2) + + temb, timestep_proj = self.condition_embedder(timestep) + timestep_proj = timestep_proj.unflatten(1, (6, -1)) + + for block in self.blocks: + hidden_states = block(hidden_states, timestep_proj, rotary_emb) + + shift, scale = (self.scale_shift_table + temb.unsqueeze(1)).chunk(2, dim=1) + hidden_states = (self.norm_out(hidden_states.float()) + * (1 + scale) + shift).type_as(hidden_states) + hidden_states = self.proj_out(hidden_states) + + hidden_states = hidden_states.reshape( + batch_size, post_patch_num_frames, post_patch_height, post_patch_width, + p_t, p_h, p_w, -1) + hidden_states = hidden_states.permute(0, 7, 1, 4, 2, 5, 3, 6) + output = hidden_states.flatten(6, 7).flatten(4, 5).flatten(2, 3) + return Transformer2DModelOutput(sample=output) + + +# ===================================================================== +# Reference diffusers pipeline (self-contained). +# +# Constructs the pipe (transformer + vae + scheduler). Its __call__ is +# the reference flow-matching loop; FlashRT replaces it at runtime with +# the NVFP4 manual denoise pipeline. +# ===================================================================== + +class MinimaxRemoverPipeline(DiffusionPipeline): + """diffusers pipeline wrapping the MiniMax-Remover transformer + VAE. + + Uses FlowMatchEulerDiscreteScheduler: the FlashRT manual denoise loop + advances the latent with sigma-based Euler steps, which matches + flow-matching semantics exactly. + """ + + model_cpu_offload_seq = "transformer->vae" + _callback_tensor_inputs = ["latents"] + + def __init__(self, transformer: Transformer3DModel, vae: AutoencoderKLWan, + scheduler: FlowMatchEulerDiscreteScheduler): + super().__init__() + self.register_modules(vae=vae, transformer=transformer, scheduler=scheduler) + self.vae_scale_factor_temporal = ( + 2 ** sum(self.vae.temperal_downsample) if getattr(self, "vae", None) else 4) + self.vae_scale_factor_spatial = ( + 2 ** len(self.vae.temperal_downsample) if getattr(self, "vae", None) else 8) + self.video_processor = VideoProcessor(vae_scale_factor=self.vae_scale_factor_spatial) + + def expand_masks(self, masks, iterations): + masks = masks.cpu().detach().numpy() + masks2 = [] + for i in range(len(masks)): + mask = masks[i] + mask = mask > 0 + mask = scipy.ndimage.binary_dilation(mask, iterations=iterations) + masks2.append(mask) + masks = np.array(masks2).astype(np.float32) + masks = torch.from_numpy(masks) + masks = masks.repeat(1, 1, 1, 3) + masks = rearrange(masks, "f h w c -> c f h w") + masks = masks[None, ...] + return masks + + def resize(self, images, w, h): + bsz, _, _, _, _ = images.shape + images = rearrange(images, "b c f w h -> (b f) c w h") + images = F.interpolate(images, (w, h), mode="bilinear") + images = rearrange(images, "(b f) c w h -> b c f w h", b=bsz) + return images + + @torch.no_grad() + def __call__(self, height: int = 720, width: int = 1280, num_frames: int = 81, + num_inference_steps: int = 50, generator=None, + images: Optional[torch.Tensor] = None, + masks: Optional[torch.Tensor] = None, + latents: Optional[torch.Tensor] = None, + output_type: Optional[str] = "np", iterations: int = 16): + device = self._execution_device + batch_size = 1 + transformer_dtype = torch.float16 + + self.scheduler.set_timesteps(num_inference_steps, device=device) + timesteps = self.scheduler.timesteps + num_channels_latents = 16 + num_latent_frames = (num_frames - 1) // self.vae_scale_factor_temporal + 1 + + shape = (batch_size, num_channels_latents, num_latent_frames, + int(height) // self.vae_scale_factor_spatial, + int(width) // self.vae_scale_factor_spatial) + latents = randn_tensor(shape, generator=generator, device=device, dtype=torch.float16) + + masks = self.expand_masks(masks, iterations) + masks = self.resize(masks, height, width).to("cuda:0").half() + masks[masks > 0] = 1 + images = rearrange(images, "f h w c -> c f h w") + images = self.resize(images[None, ...], height, width).to("cuda:0").half() + masked_images = images * (1 - masks) + + latents_mean = (torch.tensor(self.vae.config.latents_mean) + .view(1, self.vae.config.z_dim, 1, 1, 1) + .to(self.vae.device, torch.float16)) + latents_std = 1.0 / torch.tensor(self.vae.config.latents_std).view( + 1, self.vae.config.z_dim, 1, 1, 1).to(self.vae.device, torch.float16) + + with torch.no_grad(): + masked_latents = self.vae.encode(masked_images.half()).latent_dist.mode() + masks_latents = self.vae.encode(2 * masks.half() - 1.0).latent_dist.mode() + masked_latents = (masked_latents - latents_mean) * latents_std + masks_latents = (masks_latents - latents_mean) * latents_std + + self._num_timesteps = len(timesteps) + for i, t in enumerate(timesteps): + latent_model_input = latents.to(transformer_dtype) + latent_model_input = torch.cat( + [latent_model_input, masked_latents, masks_latents], dim=1) + timestep = t.expand(latents.shape[0]) + noise_pred = self.transformer( + hidden_states=latent_model_input.half(), timestep=timestep)[0] + latents = self.scheduler.step(noise_pred, t, latents, return_dict=False)[0] + + latents = latents.half() / latents_std + latents_mean + video = self.vae.decode(latents, return_dict=False)[0] + video = self.video_processor.postprocess_video(video, output_type=output_type) + return WanPipelineOutput(frames=video) + + +def build_pipeline(model_dir: Path) -> MinimaxRemoverPipeline: + """Load VAE / transformer / scheduler and assemble the diffusers pipe.""" + vae = AutoencoderKLWan.from_pretrained(model_dir / "vae", torch_dtype=torch.float16) + transformer = Transformer3DModel.from_pretrained( + model_dir / "transformer", torch_dtype=torch.float16) + scheduler = FlowMatchEulerDiscreteScheduler.from_pretrained(model_dir / "scheduler") + pipe = MinimaxRemoverPipeline(transformer=transformer, vae=vae, scheduler=scheduler) + pipe.to(DEVICE) + return pipe + + +# ===================================================================== +# Frame / mask IO helpers (self-contained). +# ===================================================================== + +def collect_frame_files(frames_dir: Path) -> List[Path]: + """Collect numeric-named image files, sorted by frame number.""" + supported = ["*.png", "*.jpg", "*.jpeg", "*.PNG", "*.JPG", "*.JPEG"] + frame_files: Dict[int, Path] = {} + for pattern in supported: + for file in frames_dir.glob(pattern): + try: + frame_files.setdefault(int(file.stem), file) + except ValueError: + continue + if not frame_files: + raise ValueError( + f"No frames found in {frames_dir} " + "(numeric-named png/jpg/jpeg expected).") + return [p for _, p in sorted(frame_files.items(), key=lambda x: x[0])] + + +def build_frame_path_map(paths: List[Path]) -> Dict[int, Path]: + return {int(p.stem): p for p in paths} + + +def load_frames(image_paths: List[Path], num_workers: int = 8) -> np.ndarray: + img0 = Image.open(image_paths[0]).convert("RGB") + base_width, base_height = img0.size + img0_np = np.array(img0, dtype=np.uint8) + if len(image_paths) == 1: + return np.stack([img0_np], axis=0) + results: List[Optional[np.ndarray]] = [img0_np] + [None] * (len(image_paths) - 1) + max_workers = max(1, min(num_workers, (os.cpu_count() or 4), len(image_paths))) + + def _load_one(idx, path): + img = Image.open(path).convert("RGB") + if img.size != (base_width, base_height): + img = img.resize((base_width, base_height), Image.Resampling.BILINEAR) + return idx, np.array(img, dtype=np.uint8) + + with ThreadPoolExecutor(max_workers=max_workers) as ex: + futures = [ex.submit(_load_one, idx, path) + for idx, path in enumerate(image_paths[1:], start=1)] + for fut in as_completed(futures): + idx, arr = fut.result() + results[idx] = arr + return np.stack([r for r in results if r is not None], axis=0) + + +def _load_single_mask(path: Optional[Path], orig_width: int, + orig_height: int) -> Tuple[np.ndarray, bool]: + if path is None: + return np.zeros((orig_height, orig_width, 1), dtype=np.float32), False + img = Image.open(path).convert("L") + if img.size != (orig_width, orig_height): + img = img.resize((orig_width, orig_height), Image.Resampling.NEAREST) + arr = np.array(img, dtype=np.uint8) + mask = (arr > MASK_THRESHOLD).astype(np.float32)[..., None] + return mask, bool(mask.any()) + + +def load_masks(seg_paths: List[Path], mask_path_map: Dict[int, Path], + orig_width: int, orig_height: int, + num_workers: int = 8) -> Tuple[np.ndarray, List[bool]]: + n = len(seg_paths) + masks = np.zeros((n, orig_height, orig_width, 1), dtype=np.float32) + has_region: List[bool] = [False] * n + if n == 0: + return masks, has_region + masks[0], has_region[0] = _load_single_mask( + mask_path_map.get(int(seg_paths[0].stem)), orig_width, orig_height) + if n == 1: + return masks, has_region + max_workers = max(1, min(num_workers, (os.cpu_count() or 4), n)) + + def _load_one(local_i, frame_no): + mask, has = _load_single_mask(mask_path_map.get(frame_no), orig_width, orig_height) + return local_i, mask, has + + with ThreadPoolExecutor(max_workers=max_workers) as ex: + futures = [ex.submit(_load_one, i, int(seg_paths[i].stem)) for i in range(1, n)] + for fut in as_completed(futures): + local_i, mask, has = fut.result() + masks[local_i] = mask + has_region[local_i] = has + return masks, has_region + + +def pad_hw_to_multiple_of_16(height: int, width: int) -> Tuple[int, int, int, int]: + ph = (16 - height % 16) % 16 + pw = (16 - width % 16) % 16 + return ph, pw, height + ph, width + pw + + +def pad_frames_bottom_right(frames: np.ndarray, ph: int, pw: int) -> np.ndarray: + if ph == 0 and pw == 0: + return frames + return np.pad(frames, ((0, 0), (0, ph), (0, pw), (0, 0)), mode="edge") + + +def pad_masks_bottom_right(masks: np.ndarray, ph: int, pw: int) -> np.ndarray: + if ph == 0 and pw == 0: + return masks + return np.pad(masks, ((0, 0), (0, ph), (0, pw), (0, 0)), + mode="constant", constant_values=0.0) + + +# ===================================================================== +# Demo entry point. +# ===================================================================== + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser( + description="MiniMax-Remover full-frame mask removal (FlashRT NVFP4).") + p.add_argument("--model-dir", type=str, required=True, + help="MiniMax-Remover checkpoint dir (vae/ transformer/ scheduler/).") + p.add_argument("--frames-dir", type=str, required=True, + help="Input video frames dir (numeric filenames).") + p.add_argument("--masks-dir", type=str, required=True, + help="Mask frames dir (numeric filenames, aligned with frames).") + p.add_argument("--output-dir", type=str, required=True, help="Output frames dir.") + p.add_argument("--iterations", type=int, default=PIPE_ITERATIONS, + help="Mask dilation iterations inside the pipeline (default 6).") + p.add_argument("--num-inference-steps", type=int, default=NUM_INFERENCE_STEPS, + help="Denoise steps (default 12).") + p.add_argument("--no-flashrt", action="store_true", + help="Run the reference diffusers path instead of FlashRT (for diffing).") + return p.parse_args() + + +def main() -> None: + args = parse_args() + frames_dir = Path(args.frames_dir) + masks_dir = Path(args.masks_dir) + output_dir = Path(args.output_dir) + model_dir = Path(args.model_dir) + + if not frames_dir.is_dir(): + raise ValueError(f"frames dir does not exist: {frames_dir}") + if not masks_dir.is_dir(): + raise ValueError(f"masks dir does not exist: {masks_dir}") + if not (model_dir / "transformer").is_dir(): + raise ValueError( + f"model dir missing transformer/: {model_dir}\n" + "Download with: huggingface-cli download zibojia/minimax-remover " + "--include vae transformer scheduler --local-dir ") + + print("=" * 60) + print("MiniMax-Remover full-frame mask removal (FlashRT NVFP4 W4A4)") + print("=" * 60) + + image_paths = collect_frame_files(frames_dir) + mask_paths = collect_frame_files(masks_dir) + mask_path_map = build_frame_path_map(mask_paths) + + first = Image.open(image_paths[0]).convert("RGB") + orig_w, orig_h = first.size + first.close() + + ph, pw, pad_h, pad_w = pad_hw_to_multiple_of_16(orig_h, orig_w) + print(f" resolution: {orig_w}x{orig_h} -> padded {pad_w}x{pad_h} " + f"(bottom +{ph}, right +{pw})") + + n = len(image_paths) + print(f" frames: {n}; steps={args.num_inference_steps}, iterations={args.iterations}") + + frames = load_frames(image_paths) + masks, has_region = load_masks(image_paths, mask_path_map, orig_w, orig_h) + + if not any(has_region): + print(" no removal region detected (masks empty); copying all source frames.") + output_dir.mkdir(parents=True, exist_ok=True) + for src in image_paths: + shutil.copy2(src, output_dir / src.name) + return + + frames_padded = pad_frames_bottom_right(frames, ph, pw) + masks_padded = pad_masks_bottom_right(masks, ph, pw) + + pipe = build_pipeline(model_dir) + + if args.no_flashrt: + runner = pipe + tag = "reference (diffusers fp16)" + else: + from flash_rt.models.minimax_remover import MiniMaxRemoverPipeline + runner = MiniMaxRemoverPipeline(pipe) + tag = "FlashRT NVFP4 W4A4" + + t, h, w, _ = frames_padded.shape + images_tensor = torch.from_numpy(frames_padded).to(device=DEVICE, dtype=torch.float16) + images_tensor = images_tensor / 127.5 - 1.0 + masks_infer = torch.from_numpy(masks_padded.astype(np.float32)) + + print(f" running inference [{tag}] (all frames at once)...") + torch.cuda.reset_peak_memory_stats() + torch.cuda.empty_cache() + t0 = time.time() + out = runner( + images=images_tensor, masks=masks_infer, num_frames=t, + height=h, width=w, num_inference_steps=args.num_inference_steps, + generator=torch.Generator(device=DEVICE).manual_seed(RANDOM_SEED), + iterations=args.iterations).frames[0] + torch.cuda.synchronize() + elapsed = time.time() - t0 + print(f" inference wall time: {elapsed:.2f}s") + + out_u8 = (np.array(out, dtype=np.float32) * 255.0).clip(0, 255).astype(np.uint8) + proc_len = out_u8.shape[0] + + output_dir.mkdir(parents=True, exist_ok=True) + saved = inpainted = uncovered = 0 + for local_i, path in enumerate(image_paths): + dst = output_dir / path.name + if local_i < proc_len and has_region[local_i]: + crop = out_u8[local_i, :orig_h, :orig_w, :] + Image.fromarray(crop, mode="RGB").save(dst) + inpainted += 1 + elif local_i >= proc_len and has_region[local_i]: + Image.fromarray(frames[local_i], mode="RGB").save(dst) + uncovered += 1 + else: + Image.fromarray(frames[local_i], mode="RGB").save(dst) + saved += 1 + + peak_alloc = torch.cuda.max_memory_allocated() / 1024 ** 3 + peak_reserved = torch.cuda.max_memory_reserved() / 1024 ** 3 + print(f" saved {saved}/{n} frames to {output_dir} ({inpainted} inpainted)") + if uncovered > 0: + print(f" note: {uncovered} trailing frame(s) kept from source " + f"(VAE temporal factor 4; pad input to 4k+1 frames to avoid).") + print(f" peak VRAM: allocated {peak_alloc:.2f} GB / reserved {peak_reserved:.2f} GB") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/flash_rt/models/minimax_remover/_attention.py b/flash_rt/models/minimax_remover/_attention.py index 5f3915ac..dd850385 100644 --- a/flash_rt/models/minimax_remover/_attention.py +++ b/flash_rt/models/minimax_remover/_attention.py @@ -45,7 +45,15 @@ def _attention_mode(): def _get_sage(): global _SAGE if _SAGE is None: - import sageattention as _m + try: + import sageattention as _m + except ImportError as e: + raise RuntimeError( + "FLASHRT_ATTN_MODE=sage_* requires the 'sageattention' package " + "(the default attention backend). Install the model extra:\n" + " pip install -e \".[minimax-remover]\"\n" + "or switch to the dependency-light FlashRT FA2 backend:\n" + " FLASHRT_ATTN_MODE=fa2") from e _SAGE = _m return _SAGE diff --git a/flash_rt/models/minimax_remover/pipeline.py b/flash_rt/models/minimax_remover/pipeline.py index 5abc4ac8..f139731d 100644 --- a/flash_rt/models/minimax_remover/pipeline.py +++ b/flash_rt/models/minimax_remover/pipeline.py @@ -35,15 +35,12 @@ import torch -from ._nvfp4_linear import install_flashrt_nvfp4 -from ._attention import install_attention -from ._manual_denoise import ManualRemoverPipeline - logger = logging.getLogger(__name__) # Core generic SM120 NVFP4 kernel surface required by this pipeline. All of -# these are gated by the Blackwell NVFP4 build option; if any is missing the -# pipeline cannot run and _load_kernels raises a clear RuntimeError. +# these are compiled in automatically on a Blackwell (sm_120a / sm_121a) +# build; if any is missing the pipeline cannot run and _load_kernels raises +# a clear RuntimeError. _REQUIRED_NVFP4_SYMBOLS = ( "nvfp4_sf_swizzled_bytes", "bf16_weight_to_nvfp4_swizzled", @@ -53,6 +50,41 @@ "fp4_w4a16_gemm_bias_gelu_fp4out_sm120", ) +# Optional third-party packages pulled in only when the pipeline is actually +# constructed. Importing this model package never touches them, so +# `import flash_rt.models.minimax_remover` succeeds in a bare environment. +_RUNTIME_DEPS = ("diffusers", "einops", "triton") + + +def _import_runtime(): + """Lazily import the optional runtime deps + the pipeline submodules. + + Importing ``flash_rt.models.minimax_remover`` does not require any of + the optional dependencies; they are only resolved here, at pipeline + construction time. Raises ``RuntimeError`` with an install hint if any + optional dep is missing instead of a low-level ``ModuleNotFoundError``. + + Returns ``(install_flashrt_nvfp4, install_attention, ManualRemoverPipeline)``. + """ + missing = [] + for dep in _RUNTIME_DEPS: + try: + __import__(dep) + except ImportError: + missing.append(dep) + if missing: + raise RuntimeError( + "MiniMax-Remover runtime requires " + f"{', '.join(missing)} which {'is' if len(missing) == 1 else 'are'} " + "not installed. Install the model extra:\n" + " pip install -e \".[minimax-remover]\"\n" + "('triton' normally ships with torch on CUDA; 'diffusers' and " + "'einops' are in the extra.)") + from ._nvfp4_linear import install_flashrt_nvfp4 + from ._attention import install_attention + from ._manual_denoise import ManualRemoverPipeline + return install_flashrt_nvfp4, install_attention, ManualRemoverPipeline + def _load_kernels(): """Import ``flash_rt_kernels`` and validate the required NVFP4 surface. @@ -75,9 +107,12 @@ def _load_kernels(): if missing: raise RuntimeError( "MiniMax-Remover requires the SM120 NVFP4 kernels which are not " - "compiled into flash_rt_kernels. Rebuild with the Blackwell NVFP4 " - "build option enabled (ENABLE_CUTLASS_SM120_NVFP4_W4A16) so the " - "NVFP4 quantise / GEMM / fused-bias-gelu kernels are present.\n" + "compiled into flash_rt_kernels. Build FlashRT for Blackwell, which " + "auto-enables the NVFP4 kernels:\n" + " cmake -S . -B build -DGPU_ARCH=120 -DCMAKE_BUILD_TYPE=Release\n" + " cmake --build build -j --target flash_rt_kernels\n" + "(sm_120a / sm_121a, i.e. GPU_ARCH=120/121). The internal compile " + "flag is ENABLE_CUTLASS_SM120_NVFP4_W4A16.\n" f"Missing symbols: {', '.join(missing)}") return fvk @@ -122,6 +157,10 @@ class MiniMaxRemoverPipeline: def __init__(self, pipe, num_inference_steps=12, fp4_target="all", use_bf16=True, use_manual_pipeline=True): self.fvk = _load_kernels() + # Optional runtime deps (diffusers / einops / triton) and the pipeline + # submodules are resolved here, at construction time -- importing the + # model package never touches them. + install_flashrt_nvfp4, install_attention, ManualRemoverPipeline = _import_runtime() self.pipe = pipe self.transformer = pipe.transformer self.num_inference_steps = num_inference_steps diff --git a/pyproject.toml b/pyproject.toml index 9a1a4d4e..4fcd2fa3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,12 @@ torch = ["torch", "safetensors", "sentencepiece"] jax = ["jax", "jaxlib", "ml_dtypes", "orbax-checkpoint", "flax"] server = ["fastapi", "uvicorn"] eval = ["opencv-python", "tqdm"] +# MiniMax-Remover NVFP4 video-inpainting pipeline. Optional: only needed to +# construct/run flash_rt.models.minimax_remover (importing the package needs +# none of these). diffusers + einops are the runtime model deps; sageattention +# is the default attention backend (FLASHRT_ATTN_MODE=sage_fp8) -- switch to +# FLASHRT_ATTN_MODE=fa2 to drop it. triton ships with torch on CUDA. +minimax-remover = ["diffusers", "einops", "sageattention"] # Optional. Required only for the legacy upstream attention path # (FVK_RTX_FA2=0 / FVK_RTX_FA2_SITES=...) and the GROOT N1.6/N1.7 # backend. Default RTX Pi0 / Pi0.5 inference uses the vendored @@ -35,7 +41,7 @@ flash-attn = ["flash-attn"] # (flash_rt/hardware/thor/fa4_backend.py) sets CUTE_DSL_ARCH=sm_101a. # Requires an editable / source-tree install (pip install -e). pip install ".[thor-fa4]". thor-fa4 = ["nvidia-cutlass-dsl==4.5.1", "quack-kernels==0.4.1"] -all = ["flash-rt[torch,jax,server,eval]"] +all = ["flash-rt[torch,jax,server,eval,minimax-remover]"] [tool.setuptools.packages.find] include = ["flash_rt*"] From 274d5ec36f9759150b0bbc2f1bbe1d708a5a4529 Mon Sep 17 00:00:00 2001 From: LiangSu8899 <7thuniversels@gmail.com> Date: Thu, 2 Jul 2026 04:32:32 -0400 Subject: [PATCH 03/23] fix(minimax-remover): share attention mode dispatch --- flash_rt/models/minimax_remover/_attention.py | 50 ++++++++++++------- .../models/minimax_remover/_manual_denoise.py | 4 +- pyproject.toml | 9 ++-- tests/test_minimax_remover_smoke.py | 46 +++++++++++++++++ 4 files changed, 86 insertions(+), 23 deletions(-) diff --git a/flash_rt/models/minimax_remover/_attention.py b/flash_rt/models/minimax_remover/_attention.py index dd850385..63d085f2 100644 --- a/flash_rt/models/minimax_remover/_attention.py +++ b/flash_rt/models/minimax_remover/_attention.py @@ -76,6 +76,37 @@ def _sage_attn(q, k, v, scale, mode): return sa.sageattn(q, k, v, **kw) +def _fa2_attn(q, k, v, scale, lse_cache=None): + """Run FlashRT's vendored FA2 backend on [B, S, H, D] fp16 tensors.""" + B, S, H, Dd = q.shape + out = torch.empty_like(q) + if lse_cache is None: + lse = torch.empty(B, H, S, device=q.device, dtype=torch.float32) + else: + lse = lse_cache.get((B, S, H)) + if lse is None: + lse = torch.empty(B, H, S, device=q.device, dtype=torch.float32) + lse_cache[(B, S, H)] = lse + from flash_rt import flash_rt_fa2 as fa2 + qs, ks, vs, os_ = (q.stride(), k.stride(), v.stride(), out.stride()) + fa2.fwd_fp16( + q.data_ptr(), k.data_ptr(), v.data_ptr(), out.data_ptr(), + lse.data_ptr(), 0, 0, + B, S, S, H, H, Dd, + (qs[0], qs[1], qs[2]), (ks[0], ks[1], ks[2]), (vs[0], vs[1], vs[2]), + (os_[0], os_[1], os_[2]), + scale, _NUM_SMS, torch.cuda.current_stream().cuda_stream) + return out + + +def attention_forward(q, k, v, scale, mode=None, *, lse_cache=None): + """Dispatch MiniMax-Remover attention without importing unused backends.""" + mode = _attention_mode() if mode is None else str(mode).lower() + if mode.startswith("sage"): + return _sage_attn(q, k, v, scale, mode) + return _fa2_attn(q, k, v, scale, lse_cache) + + class FlashRTFA2Processor: """Kernel attention processor for the native [B, S, H, D] layout. @@ -125,23 +156,8 @@ def __call__(self, attn, hidden_states, rotary_emb=None, if not v.is_contiguous(): v = v.contiguous() - if mode.startswith("sage"): - out = _sage_attn(q, k, v, scale, mode) - else: - out = torch.empty_like(q) - lse = self._lse_bufs.get((B, S, H)) - if lse is None: - lse = torch.empty(B, H, S, device=q.device, dtype=torch.float32) - self._lse_bufs[(B, S, H)] = lse - from flash_rt import flash_rt_fa2 as fa2 - qs, ks, vs, os_ = (q.stride(), k.stride(), v.stride(), out.stride()) - fa2.fwd_fp16( - q.data_ptr(), k.data_ptr(), v.data_ptr(), out.data_ptr(), - lse.data_ptr(), 0, 0, - B, S, S, H, H, Dd, - (qs[0], qs[1], qs[2]), (ks[0], ks[1], ks[2]), (vs[0], vs[1], vs[2]), - (os_[0], os_[1], os_[2]), - scale, _NUM_SMS, torch.cuda.current_stream().cuda_stream) + out = attention_forward(q, k, v, scale, mode, + lse_cache=self._lse_bufs) hidden_states = out.view(B, S, H * Dd) hidden_states = attn.to_out[0](hidden_states) diff --git a/flash_rt/models/minimax_remover/_manual_denoise.py b/flash_rt/models/minimax_remover/_manual_denoise.py index 4ddc197e..584ef3f7 100644 --- a/flash_rt/models/minimax_remover/_manual_denoise.py +++ b/flash_rt/models/minimax_remover/_manual_denoise.py @@ -43,7 +43,7 @@ rms_norm_fp32stat, rope_apply_bshd, freqs_to_cos_sin, euler_step_inplace, mask_mul, latent_normalize, latent_denormalize) -from ._attention import _sage_attn, _attention_mode +from ._attention import attention_forward, _attention_mode _USE_FUSED_BLOCK = os.environ.get("FLASHRT_FUSED_BLOCK", "1") == "1" @@ -116,7 +116,7 @@ def block_forward_fused(block, hidden_states, mod, rotary_emb, eps, kern, k = k.contiguous() v = v.contiguous() scale = 1.0 / math.sqrt(float(Dd)) - out = _sage_attn(q, k, v, scale, _attention_mode()) + out = attention_forward(q, k, v, scale, _attention_mode()) attn_out = attn.to_out[0](out.view(1, S, D)).view(S, D) gate_mul_residual_bcast(hs, attn_out, mod[2]) diff --git a/pyproject.toml b/pyproject.toml index 4fcd2fa3..1fd54150 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,10 +21,11 @@ server = ["fastapi", "uvicorn"] eval = ["opencv-python", "tqdm"] # MiniMax-Remover NVFP4 video-inpainting pipeline. Optional: only needed to # construct/run flash_rt.models.minimax_remover (importing the package needs -# none of these). diffusers + einops are the runtime model deps; sageattention -# is the default attention backend (FLASHRT_ATTN_MODE=sage_fp8) -- switch to -# FLASHRT_ATTN_MODE=fa2 to drop it. triton ships with torch on CUDA. -minimax-remover = ["diffusers", "einops", "sageattention"] +# none of these). diffusers + einops are the runtime model deps; scipy is used +# by the quickstart mask helpers; sageattention is the default attention backend +# (FLASHRT_ATTN_MODE=sage_fp8) -- switch to FLASHRT_ATTN_MODE=fa2 to drop it. +# triton ships with torch on CUDA. +minimax-remover = ["diffusers", "einops", "scipy", "sageattention"] # Optional. Required only for the legacy upstream attention path # (FVK_RTX_FA2=0 / FVK_RTX_FA2_SITES=...) and the GROOT N1.6/N1.7 # backend. Default RTX Pi0 / Pi0.5 inference uses the vendored diff --git a/tests/test_minimax_remover_smoke.py b/tests/test_minimax_remover_smoke.py index f27468a7..b15f859e 100644 --- a/tests/test_minimax_remover_smoke.py +++ b/tests/test_minimax_remover_smoke.py @@ -29,6 +29,52 @@ def test_pipeline_module_import(): assert "nvfp4_sf_swizzled_bytes" in pipeline._REQUIRED_NVFP4_SYMBOLS +def test_attention_forward_fa2_does_not_import_sageattention(monkeypatch): + """The documented fa2 fallback must not require sageattention.""" + import sys + import types + + import torch + + import flash_rt + from flash_rt.models.minimax_remover import _attention + + calls = [] + fake_fa2 = types.SimpleNamespace( + fwd_fp16=lambda *args: calls.append(args)) + monkeypatch.setattr(flash_rt, "flash_rt_fa2", fake_fa2, raising=False) + monkeypatch.setitem(sys.modules, "flash_rt.flash_rt_fa2", fake_fa2) + monkeypatch.setattr( + _attention, "_get_sage", + lambda: pytest.fail("fa2 mode must not import sageattention")) + + class _FakeStream: + cuda_stream = 0 + + monkeypatch.setattr(torch.cuda, "current_stream", + lambda: _FakeStream(), raising=False) + + q = torch.empty(1, 2, 1, 4, dtype=torch.float16) + k = torch.empty_like(q) + v = torch.empty_like(q) + out = _attention.attention_forward(q, k, v, 0.5, "fa2") + + assert out.shape == q.shape + assert calls + + +def test_manual_fused_block_uses_shared_attention_forward(): + """The manual fused block must respect FLASHRT_ATTN_MODE.""" + from pathlib import Path + + root = Path(__file__).resolve().parents[1] + src = (root / "flash_rt/models/minimax_remover/_manual_denoise.py").read_text() + + assert "from ._attention import attention_forward" in src + assert "_sage_attn" not in src + assert "attention_forward(q, k, v, scale, _attention_mode())" in src + + # ── 2. _load_kernels validates the NVFP4 surface ── def test_load_kernels_raises_when_symbols_absent(): From c67100d2156fcd4d6a281fdca3bcab50b6fd4bba Mon Sep 17 00:00:00 2001 From: LiangSu8899 <7thuniversels@gmail.com> Date: Thu, 2 Jul 2026 04:41:43 -0400 Subject: [PATCH 04/23] docs(minimax-remover): mention scipy extra --- docs/minimax_remover_usage.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/minimax_remover_usage.md b/docs/minimax_remover_usage.md index 3c813790..9d004a73 100644 --- a/docs/minimax_remover_usage.md +++ b/docs/minimax_remover_usage.md @@ -23,11 +23,11 @@ NVFP4 surface is compiled in automatically (internally gated by flag users pass). Then install the runtime extras: ```bash -pip install -e ".[minimax-remover]" # diffusers + einops + sageattention +pip install -e ".[minimax-remover]" # diffusers + einops + scipy + sageattention ``` Importing `flash_rt.models.minimax_remover` always succeeds — it needs -**none** of `diffusers` / `einops` / `triton` / `sageattention`. The +**none** of `diffusers` / `einops` / `scipy` / `triton` / `sageattention`. The kernel surface is validated lazily in `MiniMaxRemoverPipeline.__init__` via `_load_kernels()`, and the runtime deps are resolved at construction via `_import_runtime()`. If a required From 0479e4061d580788252ea57495a353ffb766e85c Mon Sep 17 00:00:00 2001 From: chenping Date: Thu, 2 Jul 2026 17:02:56 +0800 Subject: [PATCH 05/23] feat(minimax-remover): add FP8 W8A8 kernelized inference pipeline - Add FlashRTFp8Linear with static calibration for FP8 GEMM acceleration - Add MiniMaxRemoverPipelineFP8 for full-frame inpainting with FP8 - Add fused kernel blocks (adaLN, gate-residual, GELU) - Add Triton Flash Attention (fp16/fp8 variants) - Add Triton fused LayerNorm with fp32 statistics - Add Triton RoPE for native [B,S,H,D] layout - Add shared utilities for kernel loading and validation --- .../models/minimax_remover/_fp8_linear.py | 258 +++++++++++++++++ .../models/minimax_remover/_fp8_pipeline.py | 131 +++++++++ .../models/minimax_remover/_kern_block.py | 272 ++++++++++++++++++ .../minimax_remover/_triton_flash_attn.py | 139 +++++++++ .../minimax_remover/_triton_fused_norm.py | 199 +++++++++++++ .../models/minimax_remover/_triton_rope.py | 69 +++++ flash_rt/models/minimax_remover/_utils.py | 94 ++++++ 7 files changed, 1162 insertions(+) create mode 100644 flash_rt/models/minimax_remover/_fp8_linear.py create mode 100644 flash_rt/models/minimax_remover/_fp8_pipeline.py create mode 100644 flash_rt/models/minimax_remover/_kern_block.py create mode 100644 flash_rt/models/minimax_remover/_triton_flash_attn.py create mode 100644 flash_rt/models/minimax_remover/_triton_fused_norm.py create mode 100644 flash_rt/models/minimax_remover/_triton_rope.py create mode 100644 flash_rt/models/minimax_remover/_utils.py diff --git a/flash_rt/models/minimax_remover/_fp8_linear.py b/flash_rt/models/minimax_remover/_fp8_linear.py new file mode 100644 index 00000000..ceb433a4 --- /dev/null +++ b/flash_rt/models/minimax_remover/_fp8_linear.py @@ -0,0 +1,258 @@ +""" +FlashRT FP8 Linear layer. + +Implements true FP8 GEMM acceleration using FlashRT-compiled CUDA kernels: + - Weights are statically quantized to FP8 E4M3 (done once at load time). + - Activations are quantized using a *calibrated static scale* (eliminating + per-call GPU reduce synchronization overhead). + - Calls the fp8_gemm_descale_fp16 kernel to compute D = (A_fp8 @ B_fp8) * act_scale * w_scale. + +Benchmarked on RTX 5060 Ti (sm120): 3x+ speedup over PyTorch fp16 matmul, +with cosine similarity >= 0.999 against the fp16 reference output. + +Calibration workflow: + 1. install_flashrt_fp8(transformer) -> replace all Linears with FlashRTFp8Linear + 2. transformer.begin_calibration() -> enter calibration mode (forward records activation amax) + 3. Run several representative forwards (e.g. all 12 steps of the first inference segment) + 4. transformer.end_calibration(margin=1.0) -> freeze the static act_scale +""" + +import logging +from typing import Optional + +import torch +import torch.nn as nn + +from flash_rt import flash_rt_kernels as kern + +logger = logging.getLogger(__name__) + +_FP8 = torch.float8_e4m3fn +_FP8_MAX = 448.0 + + +def _quantize_weight_fp8(w: torch.Tensor): + """Quantize an [N, K] fp16/bf16 weight tensor to FP8. + + Returns (w_fp8_t [K, N] contiguous, weight_scale fp32 scalar tensor). + """ + w = w.contiguous() + amax = w.abs().max() + scale = (amax / _FP8_MAX).clamp(min=1e-12).to(torch.float32).view(1) + # Kernel requires [K, N] row-major layout (A[M,K] @ B[K,N]) + w_t = w.t().contiguous() + n = w_t.numel() + w_fp8 = torch.empty(w_t.shape, dtype=_FP8, device=w.device) + kern.quantize_fp8_static_fp16( + w_t.data_ptr(), w_fp8.data_ptr(), scale.data_ptr(), n, 0 + ) + return w_fp8, scale + + +class FlashRTFp8Linear(nn.Module): + """Linear layer backed by the FlashRT FP8 GEMM. + + Computation is equivalent to nn.Linear: y = x @ weight^T + bias, + where weight is stored in FP8 form (transposed to [K, N]) and the + activation is quantized to FP8 at forward time. + """ + + def __init__(self, in_features: int, out_features: int, bias: bool = True, + device=None, dtype=torch.float16): + super().__init__() + self.in_features = in_features + self.out_features = out_features + + # FP8 weight (transposed [K, N] layout, row-major) + scale + self.weight_fp8 = nn.Parameter( + torch.empty(in_features, out_features, dtype=_FP8, device=device), + requires_grad=False, + ) + self.weight_scale = nn.Parameter( + torch.ones(1, dtype=torch.float32, device=device), + requires_grad=False, + ) + + if bias: + self.bias = nn.Parameter( + torch.zeros(out_features, dtype=dtype, device=device), + requires_grad=False, + ) + else: + self.register_parameter("bias", None) + + # Static activation scale (frozen after calibration) + self.act_scale = nn.Parameter( + torch.ones(1, dtype=torch.float32, device=device), + requires_grad=False, + ) + # Activation amax recorded during calibration (accumulated on GPU, no CPU sync) + self.register_buffer( + "act_amax", torch.zeros(1, dtype=torch.float32, device=device) + ) + self.register_buffer("act_amax_max", torch.zeros(1, dtype=torch.float32, device=device)) + + self.calibrating = False + + # Backward-compatible with nn.Linear's weight attribute (some code reads .weight / .weight.dtype) + @property + def weight(self): + return self.weight_fp8 + + @classmethod + def from_linear(cls, linear: nn.Linear) -> "FlashRTFp8Linear": + w = linear.weight.data + dtype = torch.float16 if w.dtype == torch.float16 else torch.bfloat16 + layer = cls( + w.shape[1], w.shape[0], + bias=linear.bias is not None, + device=w.device, dtype=dtype, + ) + w_fp8, scale = _quantize_weight_fp8(w.to(dtype)) + layer.weight_fp8.data = w_fp8 + layer.weight_scale.data = scale + if linear.bias is not None: + layer.bias.data = linear.bias.data.to(dtype) + # Use weight amax to give the activation scale a reasonable initial value + # (avoid overflow before calibration) + layer.act_scale.data = (scale * 4.0).clamp(min=1e-6) + return layer + + def forward(self, x: torch.Tensor) -> torch.Tensor: + in_dtype = x.dtype + # Kernel input requires fp16 + if x.dtype != torch.float16: + x = x.to(torch.float16) + + orig_shape = x.shape + x2d = x.reshape(-1, self.in_features) + if x2d.stride(0) != self.in_features or x2d.stride(1) != 1: + x2d = x2d.contiguous() + m = x2d.shape[0] + k, n = self.in_features, self.out_features + + if self.calibrating: + # Dynamic scale (on GPU, no CPU sync); also accumulate historical amax + amax = x2d.abs().max() + self.act_amax.data.copy_(amax) + self.act_amax_max.data = torch.maximum(self.act_amax_max.data, amax) + scale = (amax / _FP8_MAX).clamp(min=1e-12).to(torch.float32).view(1) + else: + scale = self.act_scale.data + + # Temporary allocation (freed immediately after use, relying on PyTorch's caching + # allocator). This avoids each of the 181 layers holding its own persistent buffer, + # which would balloon VRAM (measured peak jumps from ~3GB to ~12GB with persistent buffers). + x_fp8 = torch.empty(m, k, dtype=_FP8, device=x2d.device) + out = torch.empty(m, n, dtype=torch.float16, device=x2d.device) + # Use the current CUDA stream (not a hardcoded 0) so the kernels are + # stream-safe and graph-compatible (a caller capturing a CUDA Graph + # would replay them on the captured stream). The FP8 pipeline does + # not capture a graph itself. + stream = torch.cuda.current_stream().cuda_stream + kern.quantize_fp8_static_fp16( + x2d.data_ptr(), x_fp8.data_ptr(), scale.data_ptr(), m * k, stream + ) + kern.fp8_gemm_descale_fp16( + x_fp8.data_ptr(), self.weight_fp8.data_ptr(), out.data_ptr(), + m, n, k, scale.data_ptr(), self.weight_scale.data_ptr(), stream, + ) + if self.bias is not None: + # FlashRT fused bias (in-place [m,n]+=bias[n]), no torch op + kern.add_bias_fp16(out.data_ptr(), self.bias.data_ptr(), m, n, stream) + out = out.view(*orig_shape[:-1], n) + + if in_dtype != torch.float16: + out = out.to(in_dtype) + return out + + def freeze_act_scale(self, margin: float = 1.0): + """After calibration: set the static act_scale from the accumulated amax.""" + amax = float(self.act_amax_max.item()) + if amax <= 0: + amax = float(self.weight_scale.item()) * _FP8_MAX + scale = max(amax * margin / _FP8_MAX, 1e-12) + self.act_scale.data = torch.tensor([scale], dtype=torch.float32, device=self.weight_fp8.device) + self.calibrating = False + + +def _is_fp8_target(module: nn.Module) -> bool: + """Determine whether a Linear is suitable for FP8 replacement. + + Skips very small Linears (e.g. norm affine params) and + condition_embedder/time_embedder. + """ + return isinstance(module, nn.Linear) + + +def install_flashrt_fp8(model: nn.Module, verbose: bool = True, target: str = "all") -> int: + """Recursively replace Linears in the model with FlashRTFp8Linear. + + Replacement scope: + - target="all" (default): replace all attn/ffn/proj_out Linears. Measured + to be the fastest (FFN large matrices get ~3x GEMM speedup; attention + small matrices also benefit slightly from FP8 thanks to halved weight + VRAM and lower memory traffic). + - target="ffn_only": only replace FFN up/down projections (slightly higher + accuracy, PSNR~61dB). + Kept in fp32: time_embedder, condition_embedder (timestep encoding is small + and sensitive to precision). + """ + replaced = 0 + skip_substr = ("time_embedder", "condition_embedder") + + # Target selection: which name patterns participate in FP8 + if target == "ffn_only": + include_patterns = ("ffn.net.0.proj", "ffn.net.2") + else: + include_patterns = () # empty = all (except skips) + + def _should_replace(name: str) -> bool: + if any(s in name for s in skip_substr): + return False + if include_patterns: + return any(p in name for p in include_patterns) + return True + + # Collect (name, linear) pairs to replace + targets = [] + for name, module in model.named_modules(): + if not _is_fp8_target(module): + continue + if not _should_replace(name): + continue + targets.append((name, module)) + + for name, linear in targets: + # Find the parent module and attribute name + parent = model + parts = name.split(".") + for p in parts[:-1]: + parent = getattr(parent, p) + attr = parts[-1] + new_layer = FlashRTFp8Linear.from_linear(linear) + setattr(parent, attr, new_layer) + replaced += 1 + if verbose: + logger.info(" [FP8] %s: %s", name, tuple(linear.weight.shape)) + + if verbose: + logger.info(" Replaced %d Linears -> FlashRTFp8Linear", replaced) + return replaced + + +def set_calibration(model: nn.Module, on: bool): + """Toggle calibration mode on all FlashRTFp8Linear modules.""" + for module in model.modules(): + if isinstance(module, FlashRTFp8Linear): + module.calibrating = on + + +def freeze_calibration(model: nn.Module, margin: float = 1.0): + """Freeze the static act_scale on all FlashRTFp8Linear modules.""" + n = 0 + for module in model.modules(): + if isinstance(module, FlashRTFp8Linear): + module.freeze_act_scale(margin) + n += 1 + return n diff --git a/flash_rt/models/minimax_remover/_fp8_pipeline.py b/flash_rt/models/minimax_remover/_fp8_pipeline.py new file mode 100644 index 00000000..48b0a9fe --- /dev/null +++ b/flash_rt/models/minimax_remover/_fp8_pipeline.py @@ -0,0 +1,131 @@ +"""FlashRT -- MiniMax-Remover FP8 kernelized inference pipeline. + +FP8 (W8A8) version for full-frame inpainting. Unlike NVFP4 (W4A4) which +produces black/drift outputs on full-frame large latents, FP8 stays close +to the fp16 reference: end-to-end cosine >= 0.999 and PSNR ~35-41 dB vs +fp16 on full-frame clips. + +Uses static calibration: the first inference call runs in dynamic-FP8 +calibration mode (accumulating activation amax on GPU), then freezes to a +static act_scale for all subsequent calls (zero CPU sync overhead in the +steady state). +""" + +import logging +import os + +import torch + +logger = logging.getLogger(__name__) + +from flash_rt.models.minimax_remover._utils import load_fp8_kernels + + +def _import_runtime_fp8(): + """Lazy import FP8 runtime dependencies.""" + missing = [] + for dep in ("diffusers", "einops", "triton"): + try: + __import__(dep) + except ImportError: + missing.append(dep) + if missing: + raise RuntimeError( + f"MiniMax-Remover FP8 requires {', '.join(missing)}. " + "Install: pip install -e '.[minimax-remover]'" + ) + from ._fp8_linear import install_flashrt_fp8, set_calibration, freeze_calibration + from ._kern_block import install_fused_blocks, install_fa2_attention + return install_flashrt_fp8, set_calibration, freeze_calibration, \ + install_fused_blocks, install_fa2_attention + + +class MiniMaxRemoverPipelineFP8: + """FP8 (W8A8) kernelized inference pipeline for full-frame inpainting. + + Unlike NVFP4 which is calibrated only for small cropped regions, FP8 + works on full-frame large latents: end-to-end cosine >= 0.999 and PSNR + ~35-41 dB vs the fp16 reference on full-frame clips. + + The first ``__call__`` runs in calibration mode (dynamic FP8 + amax + accumulation). At the end of that call the static act_scale is frozen + and all subsequent calls use the frozen scale (zero CPU sync, suitable + for CUDA Graph capture). + + Args: + pipe: loaded diffusers pipeline + num_inference_steps: denoise steps (12) + fp8_target: "all" or "ffn_only" + use_bf16: run transformer in bf16 (default False, keeps fp16) + calib_margin: act_scale margin multiplier (1.1) + """ + + def __init__(self, pipe, num_inference_steps=12, fp8_target="all", + use_bf16=False, calib_margin=1.1): + self.fvk = load_fp8_kernels() + (install_flashrt_fp8, set_calibration, freeze_calibration, + install_fused_blocks, install_fa2_attention) = _import_runtime_fp8() + + self.pipe = pipe + self.transformer = pipe.transformer + self.num_inference_steps = num_inference_steps + self.calib_margin = calib_margin + self._calibrated = False + + self._set_calibration = lambda on: set_calibration(self.transformer, on) + self._freeze_calibration = lambda: freeze_calibration( + self.transformer, margin=self.calib_margin) + + fp8_target_env = os.environ.get("FLASHRT_FP8_TARGET", fp8_target) + n_lin = install_flashrt_fp8(self.transformer, + verbose=True, target=fp8_target_env) + logger.info("MiniMax-Remover FP8: target=%r, %d Linears -> FP8 W8A8 GEMM", + fp8_target_env, n_lin) + + if use_bf16: + self.transformer.to(torch.bfloat16) + logger.info("MiniMax-Remover FP8: transformer -> bf16") + + n_block = install_fused_blocks(self.transformer) + logger.info("MiniMax-Remover FP8: %d blocks -> fused norm/gate/gelu kernels", + n_block) + + n_attn = install_fa2_attention(self.transformer) + logger.info("MiniMax-Remover FP8: %d attention blocks -> kernel backend", + n_attn) + + self._install_calibrated_call() + + def _install_calibrated_call(self): + """Wrap pipe.__call__ so the first invocation calibrates FP8 scales. + + On the first call: enable calibration mode, run the full diffusers + __call__ (which runs num_inference_steps transformer forwards + accumulating amax on GPU), then freeze the static act_scale. + Subsequent calls use the frozen scales directly. + """ + _orig_call = self.pipe.__class__.__call__ + + pipeline_fp8 = self + + @torch.no_grad() + def _calibrated_call(pipe_self, *args, **kwargs): + if not pipeline_fp8._calibrated: + logger.info("MiniMax-Remover FP8: calibration mode " + "(first call, dynamic FP8 + amax accumulation)") + pipeline_fp8._set_calibration(True) + + result = _orig_call(pipe_self, *args, **kwargs) + + if not pipeline_fp8._calibrated: + n = pipeline_fp8._freeze_calibration() + pipeline_fp8._calibrated = True + logger.info("MiniMax-Remover FP8: calibration done, " + "froze %d static act_scales (margin=%.2f)", + n, pipeline_fp8.calib_margin) + return result + + self.pipe.__class__.__call__ = _calibrated_call + + def __call__(self, *args, **kwargs): + return self.pipe(*args, **kwargs) diff --git a/flash_rt/models/minimax_remover/_kern_block.py b/flash_rt/models/minimax_remover/_kern_block.py new file mode 100644 index 00000000..0c660a71 --- /dev/null +++ b/flash_rt/models/minimax_remover/_kern_block.py @@ -0,0 +1,272 @@ +""" +FlashRT pure-kernel Transformer block fusion. + +Replaces every element-wise/norm/gate/residual/gelu op inside each Transformer3DModel +block with FlashRT fused kernels; attention uses FlashRT's built-in FA2 (fwd_fp16) in +place of torch SDPA; all Linear layers still run through FlashRT FP8 GEMM (installed +by install_flashrt_fp8). + +Fusion points (per block, compared to the original video_subtitle_remover.py): + Original: norm1(fp32) -> .float -> *(1+scale) -> +shift -> .type_as (5 large [S,D] kernels) + + hidden.float + attn*gate -> .type_as (4 large [S,D] kernels) + norm2 likewise; FFN gelu is a torch op + This version: + ada_layer_norm_fp16 -> 1 kernel (LN + modulation + direct fp16 output) + gate_mul_residual_fp16 -> 1 in-place kernel (residual + gate) + gelu_inplace_fp16 -> 1 in-place kernel (tanh gelu) + +Key correctness details: + * patch_embedding(...).transpose(1,2) produces a **non-contiguous** output; FlashRT's + pointer-based kernels read contiguous memory, so the block entry must call + .contiguous() (only the first block truly copies; subsequent blocks receive the + contiguous output of the previous block, so contiguous() is a no-op). + * gate_mul_residual_fp16 requires gate to be a full [S,D] tensor (not broadcast), so + the gate vector must be expanded. + * ada_layer_norm_fp16 = LN(x)*(1+scale)+shift (verified to match exactly, including + fp32 statistics accumulation, stable even for near-zero-variance tokens). + * gelu_inplace_fp16 = tanh-approximate GELU (matches the FFN's approximate='tanh', + verified exact). +""" + +import math +import os +import logging +import torch +import torch.nn.functional as F +from flash_rt import flash_rt_kernels as kern +from ._triton_fused_norm import ada_layernorm_fp16_io, rms_norm_fp32stat, gate_mul_residual_bcast +from ._triton_rope import rope_apply_bshd, freqs_to_cos_sin + +logger = logging.getLogger(__name__) + +_FP16 = torch.float16 + +# Attention kernel selection: sage (default, 5x vs FA2) / sage_fp8 / sage_fp16 / triton_fp8 / fa2 / triton_fp16 +_ATTENTION_MODE = os.environ.get("FLASHRT_ATTN_MODE", "sage_fp8").lower() +_TFA = None +_SAGE = None +_FA2 = None +def _get_tfa(): + global _TFA + if _TFA is None: + from . import _triton_flash_attn as _m + _TFA = _m + return _TFA + +def _get_fa2(): + global _FA2 + if _FA2 is None: + from flash_rt import flash_rt_fa2 as fa2 + _FA2 = fa2 + return _FA2 + +def _get_sage(): + global _SAGE + if _SAGE is None: + import sageattention as _m + _SAGE = _m + return _SAGE + +def _sage_attn(q, k, v, scale, mode): + """Dispatch to the appropriate SageAttention variant. + + Variants (all accept [B,S,H,D] NHD fp16, return fp16): + sage / sage_auto → sageattn dispatcher (auto-selects fastest backend) + sage_fp8 / sage2 → QK int8 per-warp + PV fp8 CUDA (fastest, cos ~0.9993) + sage_fp16 / sage1 → QK int8 per-warp + PV fp16 CUDA (most accurate, cos ~0.9999) + sage_triton → QK int8 per-block + PV fp16 Triton (fallback) + """ + sa = _get_sage() + kw = dict(tensor_layout="NHD", is_causal=False, sm_scale=scale) + if mode in ("sage", "sage_auto"): + return sa.sageattn(q, k, v, **kw) + if mode in ("sage_fp8", "sage2"): + return sa.sageattn_qk_int8_pv_fp8_cuda(q, k, v, **kw) + if mode in ("sage_fp16", "sage1"): + return sa.sageattn_qk_int8_pv_fp16_cuda(q, k, v, **kw) + if mode == "sage_triton": + return sa.sageattn_qk_int8_pv_fp16_triton(q, k, v, **kw) + return sa.sageattn(q, k, v, **kw) + +# Prefetch SM count (used by FA2 splitkv heuristic), fetched only once +try: + _NUM_SMS = torch.cuda.get_device_properties(0).multi_processor_count +except Exception: + _NUM_SMS = 0 + + +def _apply_rotary_fp32(h_bhsd, freqs): + """h: [B,H,S,D] fp16; freqs: [1,1,S,D/2] complex64 -> applies RoPE, returns [B,H,S,D] fp16.""" + x_rot = torch.view_as_complex(h_bhsd.to(torch.float32).unflatten(3, (-1, 2))) + x_out = torch.view_as_real(x_rot * freqs).flatten(3, 4) + return x_out.type_as(h_bhsd) + + +class FlashRTFA2Processor: + """Pure FlashRT FA2 attention: native [B,S,H,D] layout (no transpose copy) + Triton RoPE. + + QKV/out projections run through FlashRT FP8 GEMM; RMSNorm of Q/K uses diffusers + (fp32 statistics); RoPE uses a Triton interleaved kernel (bit-exact with + view_as_complex) to rotate in-place directly on [B,S,H,D], eliminating the original + two transpose+contiguous calls and all torch complex ops; the attention main loop + uses fa2.fwd_fp16 ([B,S,H,D] is exactly the layout FA2 needs, zero-copy). + """ + + def __init__(self): + self._lse_bufs = {} # (B,S,H) -> lse + self._cos_sin = {} # S -> (cos[S,D/2], sin[S,D/2]) fp32 + + def __call__(self, attn, hidden_states, rotary_emb=None, + attention_mask=None, encoder_hidden_states=None): + B, S, _ = hidden_states.shape + H = attn.heads + Dd = attn.inner_dim // H + scale = 1.0 / math.sqrt(float(Dd)) + + q = attn.to_q(hidden_states) # [B,S,inner] FP8 GEMM + k = attn.to_k(hidden_states) + v = attn.to_v(hidden_states) + if attn.norm_q is not None: + q = rms_norm_fp32stat(q, attn.norm_q.weight, attn.norm_q.eps) # Triton fp32-stat + if attn.norm_k is not None: + k = rms_norm_fp32stat(k, attn.norm_k.weight, attn.norm_k.eps) + # Native [B,S,H,D] view (no copy) — exactly the (batch,seqlen,heads,head_dim) FA2 needs + q = q.view(B, S, H, Dd) + k = k.view(B, S, H, Dd) + v = v.view(B, S, H, Dd) + + if rotary_emb is not None: + cs = self._cos_sin.get(S) + if cs is None: + cs = freqs_to_cos_sin(rotary_emb) # (cos[S,D/2], sin[S,D/2]) + self._cos_sin[S] = cs + rope_apply_bshd(q, cs[0], cs[1]) # in-place Triton + rope_apply_bshd(k, cs[0], cs[1]) + + # Ensure contiguity (norm_q/RMSNorm output is already contiguous; as a safeguard) + if not q.is_contiguous(): + q = q.contiguous() + if not k.is_contiguous(): + k = k.contiguous() + if not v.is_contiguous(): + v = v.contiguous() + + if _ATTENTION_MODE.startswith("sage"): + out = _sage_attn(q, k, v, scale, _ATTENTION_MODE) + elif _ATTENTION_MODE in ("triton_fp8", "triton_fp16"): + # Triton flash-attention (fp8 or fp16), returns out [B,S,H,Dd] fp16 + tfa = _get_tfa() + out = (tfa.flash_attn_fp8 if _ATTENTION_MODE == "triton_fp8" + else tfa.flash_attn_fp16)(q, k, v, scale) + else: + out = torch.empty_like(q) + lse = self._lse_bufs.get((B, S, H)) + if lse is None: + lse = torch.empty(B, H, S, device=q.device, dtype=torch.float32) + self._lse_bufs[(B, S, H)] = lse + qs, ks, vs, os_ = (q.stride(), k.stride(), v.stride(), out.stride()) + _get_fa2().fwd_fp16( + q.data_ptr(), k.data_ptr(), v.data_ptr(), out.data_ptr(), + lse.data_ptr(), 0, 0, + B, S, S, H, H, Dd, + (qs[0], qs[1], qs[2]), (ks[0], ks[1], ks[2]), (vs[0], vs[1], vs[2]), + (os_[0], os_[1], os_[2]), + scale, _NUM_SMS, torch.cuda.current_stream().cuda_stream, + ) + + hidden_states = out.view(B, S, H * Dd) + hidden_states = attn.to_out[0](hidden_states) # FP8/NVFP4 GEMM + return hidden_states + + +def install_fa2_attention(transformer): + """Replace every block's attention processor with FlashRT FA2.""" + n = 0 + proc = FlashRTFA2Processor() + for block in transformer.blocks: + block.attn1.processor = proc + n += 1 + return n + + +def install_fused_blocks(transformer, norm_mode=None, gelu_mode=None): + """Replace each TransformerBlock.forward with the pure FlashRT kernel-fused version. + + norm_mode: 'fp16' (default, ada_layer_norm_fp16) | 'fp32' (original fp32 LayerNorm + modulation, for debugging) + gelu_mode: 'inplace' (default, gelu_inplace_fp16) | 'torch' (original F.gelu, for debugging) + """ + import os as _os + if norm_mode is None: + norm_mode = _os.environ.get("FLASHRT_NORM_MODE", "triton") + if gelu_mode is None: + gelu_mode = _os.environ.get("FLASHRT_GELU_MODE", "inplace") + eps = float(transformer.blocks[0].norm1.eps) + stream_of = lambda: torch.cuda.current_stream().cuda_stream + + def _ada_norm(self_hs, scale_v, shift_v, S, D): + """Single-kernel fusion: fp32-statistics LayerNorm + adaLN modulation -> fp16. + + Why not use FlashRT's ada_layer_norm_fp16: its statistics are insufficiently + precise on real diffusion latents, yielding only 41 dB end-to-end PSNR (vs 65 dB + for the fp32 version). This Triton kernel accumulates mean/var in fp32 across + three passes, bit-exact with the original FP32LayerNorm, while still being a + single kernel. scale/shift stay fp32 (from temb.float()), and modulation is + also done in fp32. + """ + return ada_layernorm_fp16_io(self_hs, scale_v.view(D), shift_v.view(D), eps) + + def _ada_norm_flashrt_fp16(self_hs, scale_v, shift_v, S, D): + out = torch.empty(S, D, dtype=_FP16, device=self_hs.device) + kern.ada_layer_norm_fp16( + self_hs.data_ptr(), + scale_v.view(D).to(_FP16).contiguous().data_ptr(), + shift_v.view(D).to(_FP16).contiguous().data_ptr(), + out.data_ptr(), S, D, eps, stream_of()) + return out + + def block_forward(self, hidden_states, temb, rotary_emb): + B, S, D = hidden_states.shape + # Ensure contiguity at entry (first block truly copies; subsequent blocks are no-ops) + hs = hidden_states.contiguous().view(S, D) + + (shift_msa, scale_msa, gate_msa, + c_shift_msa, c_scale_msa, c_gate_msa) = (self.scale_shift_table + temb.float()).chunk(6, dim=1) + + if norm_mode == "fp16": + norm1_out = _ada_norm_flashrt_fp16(hs, scale_msa, shift_msa, S, D) + else: + norm1_out = _ada_norm(hs, scale_msa, shift_msa, S, D) + attn_out = self.attn1(hidden_states=norm1_out.view(1, S, D), rotary_emb=rotary_emb).view(S, D) + # Broadcast gate[D] (avoids [S,D] expand copy); fp16 in-place + gate_mul_residual_bcast(hs, attn_out, gate_msa.view(D)) + + if norm_mode == "fp16": + norm2_out = _ada_norm_flashrt_fp16(hs, c_scale_msa, c_shift_msa, S, D) + else: + norm2_out = _ada_norm(hs, c_scale_msa, c_shift_msa, S, D) + n2_3d = norm2_out.view(1, S, D) + if gelu_mode == "torch": + ff_out = self.ffn(n2_3d).view(S, D) + else: + up = self.ffn.net[0].proj(n2_3d) + inner = up.shape[-1] + _gelu_fn = kern.gelu_inplace if up.dtype == torch.bfloat16 else kern.gelu_inplace_fp16 + _gelu_fn(up.data_ptr(), S * inner, stream_of()) + ff_out = self.ffn.net[2](up).view(S, D) + gate_mul_residual_bcast(hs, ff_out, c_gate_msa.view(D)) + return hs.view(1, S, D) + + block_cls = type(transformer.blocks[0]) + block_cls.forward = block_forward + logger.info(" [FlashRT-Kern] block fusion: norm=%s gelu=%s", norm_mode, gelu_mode) + return len(transformer.blocks) + + +def install_fused_norm_out(transformer): + """Fuse the final norm_out + modulation (2-segment shift/scale). + + Original: (norm_out(hidden.float())*(1+scale)+shift).type_as(hidden) + This version: single ada_layer_norm_fp16 kernel. + Requires rewriting transformer.forward to insert this fusion point. + """ + pass diff --git a/flash_rt/models/minimax_remover/_triton_flash_attn.py b/flash_rt/models/minimax_remover/_triton_flash_attn.py new file mode 100644 index 00000000..3546fd23 --- /dev/null +++ b/flash_rt/models/minimax_remover/_triton_flash_attn.py @@ -0,0 +1,139 @@ +""" +Triton Flash-Attention (sm120), providing both fp16 and fp8 implementations. + +Motivation: MiniMax-Remover's attention accounts for 47% of a single step +(FA2 ≈ SDPA ≈ 6.3ms/layer, already at the fp16 compute roofline of 44 TFLOP/s). +This file provides two equivalent kernels: + + flash_attn_fp16(q,k,v,scale) -- fp16 baseline (verified cos=1.0 vs FA2, same speed) + flash_attn_fp8 (q,k,v,scale) -- FP8 QK^T + fp32 online softmax + FP8 P·V -> fp16 + +Why FP8 can still be effective inside flash-attention (even though a standalone fp8 +dot is only 1.2~1.4x faster): + For each Q-block, FA must iterate over all K/V blocks; the larger S is, the more the + HBM->SRAM loading of K/V dominates (memory-bound tendency). Storing K/V as fp8 directly + halves the required memory bandwidth for this part, and the tensor-core computation + also runs in fp8 (higher throughput). Combined, end-to-end this can be 1.3~1.7x faster + than fp16 FA. + +Input layout: [B, S, H, Dd] (matches the q/k/v view of FlashRTFA2Processor, zero-copy). +Non-causal (full attention). head_dim is fixed at 128 (for this model). +""" + +import torch +import triton +import triton.language as tl + + +@triton.jit +def _fa_fwd_kernel( + Q, K, V, O, + sm_scale, + stride_qb, stride_qs, stride_qh, + stride_kb, stride_ks, stride_kh, + stride_vb, stride_vs, stride_vh, + stride_ob, stride_os, stride_oh, + S, + H: tl.constexpr, Dd: tl.constexpr, + BM: tl.constexpr, BN: tl.constexpr, + USE_FP8: tl.constexpr, +): + """A single program handles (batch, head, one Q-block). Online (streaming) softmax. + + Q,K,V layout [B,S,H,Dd] row-major (stride_d=1).""" + pid_m = tl.program_id(0) # Q-block index + pid_h = tl.program_id(1) # head + pid_b = tl.program_id(2) # batch + + rm = pid_m * BM + tl.arange(0, BM) # [BM] Q rows + rk = tl.arange(0, Dd) # [Dd] head dim + rn = tl.arange(0, BN) # [BN] K/V rows (within block) + + q_base = Q + pid_b * stride_qb + pid_h * stride_qh + k_base = K + pid_b * stride_kb + pid_h * stride_kh + v_base = V + pid_b * stride_vb + pid_h * stride_vh + + # Q tile [BM, Dd] (Q loaded only once) + q_mask = rm[:, None] < S + q = tl.load(q_base + rm[:, None] * stride_qs + rk[None, :], + mask=q_mask, other=0.0) + if USE_FP8: + q = q.to(tl.float8e4nv) + + # Online softmax accumulators + m_i = tl.full([BM], -float('inf'), dtype=tl.float32) + l_i = tl.zeros([BM], dtype=tl.float32) + acc = tl.zeros((BM, Dd), dtype=tl.float32) + + n_blocks = tl.cdiv(S, BN) + for j in range(0, n_blocks): + kj = j * BN + rn # [BN] K/V rows in this block + kv_mask = kj < S + # K tile [BN, Dd] + k = tl.load(k_base + kj[:, None] * stride_ks + rk[None, :], + mask=kv_mask[:, None], other=0.0) + # V tile [BN, Dd] + v = tl.load(v_base + kj[:, None] * stride_vs + rk[None, :], + mask=kv_mask[:, None], other=0.0) + + if USE_FP8: + k = k.to(tl.float8e4nv) + v = v.to(tl.float8e4nv) + + # QK^T: [BM, Dd] @ [Dd, BN] -> [BM, BN] + qk = tl.dot(q, tl.trans(k)).to(tl.float32) * sm_scale + # Set positions outside the K/V block (padding) to -inf, excluding from softmax + qk = tl.where(kv_mask[None, :], qk, -float('inf')) + + m_ij = tl.maximum(m_i, tl.max(qk, axis=1)) # [BM] new running max + alpha = tl.exp(m_i - m_ij) # scale old accumulator + p = tl.exp(qk - m_ij[:, None]) # [BM, BN] unnormalized probs + l_ij = tl.sum(p, axis=1) # [BM] prob sum of this block + + # P · V : [BM, BN] @ [BN, Dd] -> [BM, Dd] + if USE_FP8: + acc = acc * alpha[:, None] + tl.dot(p.to(tl.float8e4nv), v).to(tl.float32) + else: + acc = acc * alpha[:, None] + tl.dot(p.to(v.dtype), v) + m_i = m_ij + l_i = l_i * alpha + l_ij + + # Normalize and write out fp16 + out = acc / l_i[:, None] + o_base = O + pid_b * stride_ob + pid_h * stride_oh + tl.store(o_base + rm[:, None] * stride_os + rk[None, :], + out.to(tl.float16), mask=q_mask) + + +def _launch(q, k, v, scale, use_fp8, bm=128, bn=128, num_stages=2, num_warps=8): + """q,k,v: [B,S,H,Dd] fp16 contiguous. Returns out [B,S,H,Dd] fp16. + + Default BM=128/BN=128: optimal for S=6688 in practice (fewer K/V blocks, better + loading amortization). + num_stages=2: with BM=BN=128, stages>=3 exceeds the 99KB shared memory limit on sm120.""" + assert q.dtype == torch.float16 + B, S, H, Dd = q.shape + assert Dd == 128, "this kernel targets head_dim=128" + out = torch.empty_like(q) + grid = (triton.cdiv(S, bm), H, B) + _fa_fwd_kernel[grid]( + q, k, v, out, scale, + q.stride(0), q.stride(1), q.stride(2), + k.stride(0), k.stride(1), k.stride(2), + v.stride(0), v.stride(1), v.stride(2), + out.stride(0), out.stride(1), out.stride(2), + S, H=H, Dd=Dd, BM=bm, BN=bn, + USE_FP8=use_fp8, + num_warps=num_warps, num_stages=num_stages, + ) + return out + + +def flash_attn_fp16(q, k, v, scale): + """fp16 flash-attention (baseline). q,k,v: [B,S,H,Dd] fp16.""" + return _launch(q, k, v, scale, use_fp8=False) + + +def flash_attn_fp8(q, k, v, scale): + """FP8 flash-attention: QK^T and P·V both use fp8 tensor cores; softmax in fp32.""" + return _launch(q, k, v, scale, use_fp8=True) diff --git a/flash_rt/models/minimax_remover/_triton_fused_norm.py b/flash_rt/models/minimax_remover/_triton_fused_norm.py new file mode 100644 index 00000000..d8967234 --- /dev/null +++ b/flash_rt/models/minimax_remover/_triton_fused_norm.py @@ -0,0 +1,199 @@ +""" +Triton fused adaLayerNorm (fp32 statistics + fp16 I/O) + +Why a custom kernel is needed: + FlashRT's ada_layer_norm_fp16 loses significant precision on "real diffusion + latents" (end-to-end PSNR 41 dB vs 65 dB for the fp32 version), even though it + matches F.layer_norm with cos=1.0 on random/constant data. The reason is that + its LayerNorm statistics lack sufficient precision. The original version uses + FP32LayerNorm(hidden.float()), computing statistics in fp32 -- this is + precision-critical. + + This kernel fuses "fp32-statistics LayerNorm + adaLN modulation + (1+scale)*x_norm+shift + fp16 output" into a **single kernel**, which is + bit-exact with the original fp32 path, but eliminates the original's 5 large + [S,D] kernels (hidden.float / fp32 LN / *(1+scale) / +shift / .type_as). + + out[S,D] fp16 = ( LayerNorm_fp32(x_fp16) * (1 + scale_fp32[D]) + shift_fp32[D] ) + + Also provides a fused gate-residual fp16 version (equivalent to FlashRT's + gate_mul_residual_fp16, verified bit-exact, used as a fallback / self-contained + implementation). +""" + +import torch +import triton +import triton.language as tl + + +@triton.jit +def _ada_layernorm_io_kernel( + X, SCALE, SHIFT, OUT, + M, N, + sM_x, sM_o, + eps, + BLOCK_N: tl.constexpr, + IO_DTYPE: tl.constexpr, +): + # Each row (token) is handled by one program; when N(=D) is large, use multi-block reduction + row = tl.program_id(0) + x_ptr = X + row * sM_x + o_ptr = OUT + row * sM_o + # First pass: compute the mean + _mean = tl.zeros([BLOCK_N], dtype=tl.float32) + for off in tl.range(0, N, BLOCK_N): + cols = off + tl.arange(0, BLOCK_N) + mask = cols < N + x = tl.load(x_ptr + cols, mask=mask, other=0.0).to(tl.float32) + _mean += tl.sum(x) + mean = _mean / N + # Second pass: compute the variance + _var = tl.zeros([BLOCK_N], dtype=tl.float32) + for off in tl.range(0, N, BLOCK_N): + cols = off + tl.arange(0, BLOCK_N) + mask = cols < N + x = tl.load(x_ptr + cols, mask=mask, other=0.0).to(tl.float32) + d = x - mean + _var += tl.sum(d * d) + var = _var / N + rstd = 1.0 / tl.sqrt(var + eps) + # Third pass: normalize + modulate + store as IO_DTYPE + for off in tl.range(0, N, BLOCK_N): + cols = off + tl.arange(0, BLOCK_N) + mask = cols < N + x = tl.load(x_ptr + cols, mask=mask, other=0.0).to(tl.float32) + x_norm = (x - mean) * rstd + scale = tl.load(SCALE + cols, mask=mask, other=0.0) + shift = tl.load(SHIFT + cols, mask=mask, other=0.0) + y = x_norm * (1.0 + scale) + shift + tl.store(o_ptr + cols, y.to(IO_DTYPE), mask=mask) + + +# Backward-compat alias +_ada_layernorm_fp16_io_kernel = None + + +def ada_layernorm_io(x: torch.Tensor, scale: torch.Tensor, + shift: torch.Tensor, eps: float = 1e-6) -> torch.Tensor: + """x: [S, D] or [B, S, D] fp16/bf16 contiguous; scale/shift: [D] fp32. + Returns [.., D] with the same dtype. Statistics are computed in fp32, + equivalent to the original FP32LayerNorm.""" + orig_shape = x.shape + if x.dim() == 3: + x = x.reshape(orig_shape[0] * orig_shape[1], orig_shape[2]) + S, D = x.shape + assert x.is_contiguous(), "ada_layernorm_io: x must be contiguous" + scale = scale.contiguous().to(torch.float32).view(-1) + shift = shift.contiguous().to(torch.float32).view(-1) + out = torch.empty_like(x) + BLOCK_N = triton.next_power_of_2(min(D, 2048)) + num_warps = 8 if D >= 1024 else 4 + IO_DTYPE = tl.bfloat16 if x.dtype == torch.bfloat16 else tl.float16 + _ada_layernorm_io_kernel[(S,)]( + x, scale, shift, out, S, D, + x.stride(0), out.stride(0), eps, + BLOCK_N=BLOCK_N, num_warps=num_warps, IO_DTYPE=IO_DTYPE, + ) + return out.reshape(orig_shape) + + +# Backward-compat wrapper +def ada_layernorm_fp16_io(x, scale, shift, eps=1e-6): + return ada_layernorm_io(x, scale, shift, eps) + + +@triton.jit +def _gate_mul_residual_kernel(RES, X, GATE, N, BLOCK: tl.constexpr, + IO_DTYPE: tl.constexpr): + pid = tl.program_id(0) + cols = pid * BLOCK + tl.arange(0, BLOCK) + mask = cols < N + r = tl.load(RES + cols, mask=mask).to(tl.float32) + x = tl.load(X + cols, mask=mask).to(tl.float32) + g = tl.load(GATE + cols, mask=mask).to(tl.float32) + y = r + x * g + tl.store(RES + cols, y.to(IO_DTYPE), mask=mask) + + +def gate_mul_residual(residual: torch.Tensor, x: torch.Tensor, + gate: torch.Tensor) -> torch.Tensor: + """residual[S,D] += x[S,D] * gate[S,D] (writes in place into residual).""" + n = residual.numel() + BLOCK = 1024 + IO_DTYPE = tl.bfloat16 if residual.dtype == torch.bfloat16 else tl.float16 + _gate_mul_residual_kernel[(triton.cdiv(n, BLOCK),)]( + residual, x, gate.contiguous(), n, BLOCK=BLOCK, IO_DTYPE=IO_DTYPE) + return residual + + +@triton.jit +def _rmsnorm_affine_kernel(X, WEIGHT, OUT, Npts, D, EPS, BLOCK_D: tl.constexpr, + IO_DTYPE: tl.constexpr): + pt = tl.program_id(0) + xp = X + pt * D + op = OUT + pt * D + _sum = tl.zeros([BLOCK_D], dtype=tl.float32) + for off in tl.range(0, D, BLOCK_D): + cols = off + tl.arange(0, BLOCK_D) + mask = cols < D + x = tl.load(xp + cols, mask=mask, other=0.0).to(tl.float32) + _sum += tl.sum(x * x) + inv_rms = 1.0 / tl.sqrt(_sum / D + EPS) + for off in tl.range(0, D, BLOCK_D): + cols = off + tl.arange(0, BLOCK_D) + mask = cols < D + x = tl.load(xp + cols, mask=mask, other=0.0).to(tl.float32) + w = tl.load(WEIGHT + cols, mask=mask, other=0.0).to(tl.float32) + y = x * inv_rms * w + tl.store(op + cols, y.to(IO_DTYPE), mask=mask) + + +def rms_norm_fp32stat(x: torch.Tensor, weight: torch.Tensor, eps: float) -> torch.Tensor: + """RMSNorm (fp32 statistics + affine weight[D]); x[..,D] fp16/bf16 -> same dtype. + Equivalent to diffusers RMSNorm, avoiding the round-trip fp32 upcast. + Used for Q/K norm_q/norm_k.""" + D = x.shape[-1] + orig_shape = x.shape + x2 = x.reshape(-1, D).contiguous() + Npts = x2.shape[0] + out = torch.empty_like(x2) + w = weight.contiguous().to(torch.float32).view(-1) + BLOCK_D = 16 + while BLOCK_D < D and BLOCK_D < 2048: + BLOCK_D <<= 1 + IO_DTYPE = tl.bfloat16 if x.dtype == torch.bfloat16 else tl.float16 + _rmsnorm_affine_kernel[(Npts,)]( + x2, w, out, Npts, D, float(eps), BLOCK_D=BLOCK_D, + num_warps=8 if D >= 256 else 4, IO_DTYPE=IO_DTYPE) + return out.reshape(orig_shape) + + +@triton.jit +def _gate_mul_res_bcast_kernel(RES, X, GATE, Nrow, D, BLOCK_D: tl.constexpr, + IO_DTYPE: tl.constexpr): + """res[Nrow,D] += x[Nrow,D] * gate[D] (gate is broadcast, avoiding the [S,D] expand copy).""" + r = tl.program_id(0) + cols = tl.arange(0, BLOCK_D) + mask = cols < D + rp = RES + r * D + x = tl.load(rp + cols, mask=mask).to(tl.float32) + xv = tl.load(X + r * D + cols, mask=mask).to(tl.float32) + g = tl.load(GATE + cols, mask=mask).to(tl.float32) + tl.store(rp + cols, (x + xv * g).to(IO_DTYPE), mask=mask) + + +def gate_mul_residual_bcast(residual: torch.Tensor, x: torch.Tensor, + gate: torch.Tensor) -> torch.Tensor: + """residual[S,D] += x[S,D] * gate[D] (gate is broadcast, in-place).""" + D = residual.shape[-1] + res2 = residual.reshape(-1, D) + x2 = x.reshape(-1, D) + Nrow = res2.shape[0] + g = gate.contiguous().to(residual.dtype).view(-1) + BLOCK_D = 16 + while BLOCK_D < D and BLOCK_D < 2048: + BLOCK_D <<= 1 + IO_DTYPE = tl.bfloat16 if residual.dtype == torch.bfloat16 else tl.float16 + _gate_mul_res_bcast_kernel[(Nrow,)](res2, x2, g, Nrow, D, BLOCK_D=BLOCK_D, + num_warps=4, IO_DTYPE=IO_DTYPE) + return residual diff --git a/flash_rt/models/minimax_remover/_triton_rope.py b/flash_rt/models/minimax_remover/_triton_rope.py new file mode 100644 index 00000000..a2d0a904 --- /dev/null +++ b/flash_rt/models/minimax_remover/_triton_rope.py @@ -0,0 +1,69 @@ +""" +Triton fused interleaved RoPE (native [B,S,H,D] layout, in-place) + +The original version (including the previous fp32 optimization) converts Q/K from +[B,S,H*D] to [B,H,S,D] inside attention, performs complex rotation using +torch view_as_complex/view_as_real, then converts back to [B,S,H*D] — this produces +multiple large copies plus several elementwise kernels (profiling shows they account +for ~28% of the torch elementwise overhead). + +This kernel performs the interleaved complex rotation directly on the [B,S,H*D] layout +required by FA2 (bit-for-bit identical to MiniMax's view_as_complex(unflatten(-1,2))), +as a single in-place kernel, eliminating two transpose+contiguous operations and all +torch complex ops. + +Rotation definition (bit-for-bit identical to the original; cos/sin taken from +freqs = cos + i*sin): + out[..., 2i] = x[...,2i]*cos[s,i] - x[...,2i+1]*sin[s,i] + out[..., 2i+1] = x[...,2i]*sin[s,i] + x[...,2i+1]*cos[s,i] +freqs depends only on S (broadcast over B/H). +""" + +import torch +import triton +import triton.language as tl + + +@triton.jit +def _rope_bshd_kernel(X, COS, SIN, Nrows, S, H, Dhalf, + BLOCK_D: tl.constexpr, IO_DTYPE: tl.constexpr): + r = tl.program_id(0) # row index (b*S*H + h) + s = (r // H) % S + x_ptr = X + r * (2 * Dhalf) + cos_ptr = COS + s * Dhalf + sin_ptr = SIN + s * Dhalf + offs = tl.arange(0, BLOCK_D) + mask = offs < Dhalf + cos = tl.load(cos_ptr + offs, mask=mask, other=0.0).to(tl.float32) + sin = tl.load(sin_ptr + offs, mask=mask, other=0.0).to(tl.float32) + x0 = tl.load(x_ptr + 2 * offs, mask=mask, other=0.0).to(tl.float32) + x1 = tl.load(x_ptr + 2 * offs + 1, mask=mask, other=0.0).to(tl.float32) + o0 = x0 * cos - x1 * sin + o1 = x0 * sin + x1 * cos + tl.store(x_ptr + 2 * offs, o0.to(IO_DTYPE), mask=mask) + tl.store(x_ptr + 2 * offs + 1, o1.to(IO_DTYPE), mask=mask) + + +def rope_apply_bshd(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor: + """x: [B,S,H,D] fp16/bf16 contiguous (FA2 native layout), rotated in-place. + cos/sin: [S, D//2] fp32. Returns x (modified in-place). + """ + assert x.is_contiguous() + B, S, H, D = x.shape + Dhalf = D // 2 + Nrows = B * S * H + BLOCK_D = triton.next_power_of_2(Dhalf) if hasattr(triton, "next_power_of_2") else (1 << (Dhalf - 1).bit_length()) + if BLOCK_D < 16: + BLOCK_D = 16 + IO_DTYPE = tl.bfloat16 if x.dtype == torch.bfloat16 else tl.float16 + _rope_bshd_kernel[(Nrows,)]( + x, cos.contiguous().to(torch.float32), sin.contiguous().to(torch.float32), + Nrows, S, H, Dhalf, BLOCK_D=BLOCK_D, num_warps=4, IO_DTYPE=IO_DTYPE, + ) + return x + + +def freqs_to_cos_sin(freqs: torch.Tensor): + """freqs: complex [1,1,S,D//2] -> (cos[S,D//2] fp32, sin[S,D2] fp32) on freqs.device.""" + f = freqs.squeeze().to(torch.complex64) # [S, D//2] + return f.real.contiguous().to(torch.float32), f.imag.contiguous().to(torch.float32) diff --git a/flash_rt/models/minimax_remover/_utils.py b/flash_rt/models/minimax_remover/_utils.py new file mode 100644 index 00000000..54235119 --- /dev/null +++ b/flash_rt/models/minimax_remover/_utils.py @@ -0,0 +1,94 @@ +"""Shared utilities for MiniMax-Remover pipelines.""" + +import logging + +logger = logging.getLogger(__name__) + +_REQUIRED_NVFP4_SYMBOLS = ( + "nvfp4_sf_swizzled_bytes", + "bf16_weight_to_nvfp4_swizzled", + "quantize_bf16_to_nvfp4_swizzled", + "fp4_w4a16_gemm_sm120_bf16out_pingpong", + "add_bias_bf16", + "fp4_w4a16_gemm_bias_gelu_fp4out_sm120", +) + +_REQUIRED_FP8_SYMBOLS = ( + "quantize_fp8_static_fp16", + "fp8_gemm_descale_fp16", + "add_bias_fp16", +) + +# Generic elementwise block-fusion kernels used by both pipelines on the +# default hot path (gelu_mode="inplace"). They are part of the default +# flash_rt_kernels build; a build missing either variant must fail fast here +# instead of crashing mid-forward with a bare AttributeError. ``gelu_inplace`` +# is the bf16 path (NVFP4 default transformer dtype), ``gelu_inplace_fp16`` +# the fp16 path (FP8 default). +_REQUIRED_BLOCK_SYMBOLS = ( + "gelu_inplace", + "gelu_inplace_fp16", +) + + +def _load_kernels(required_symbols=None): + """Import flash_rt_kernels and validate required symbols. + + Args: + required_symbols: tuple of symbol names to check (default: NVFP4 + precision surface plus the shared block-fusion surface) + + Returns the flash_rt_kernels module. Raises RuntimeError if any required + symbol is missing, naming every missing symbol so a non-matching build + fails fast instead of crashing mid-run. + """ + if required_symbols is None: + required_symbols = _REQUIRED_NVFP4_SYMBOLS + _REQUIRED_BLOCK_SYMBOLS + + try: + from flash_rt import flash_rt_kernels as fvk + except ImportError: + try: + import flash_rt_kernels as fvk # type: ignore + except ImportError: + raise RuntimeError( + "flash_rt_kernels is not available. Build FlashRT with:\n" + " cmake -S . -B build -DGPU_ARCH=120 -DCMAKE_BUILD_TYPE=Release\n" + " cmake --build build -j --target flash_rt_kernels\n" + "After building, the .so file is placed in flash_rt/ directly " + "— no make install step is needed.") from None + + missing = [s for s in required_symbols if not hasattr(fvk, s)] + + if missing: + names = ", ".join(missing) + raise RuntimeError( + f"flash_rt_kernels is missing required symbols: {names}\n" + "Rebuild FlashRT for your target hardware. For the Blackwell NVFP4 " + "kernels use GPU_ARCH=120/121 (auto-enables NVFP4):\n" + " cmake -S . -B build -DGPU_ARCH=120 -DCMAKE_BUILD_TYPE=Release\n" + " cmake --build build -j --target flash_rt_kernels\n" + "FP8 kernels are part of the default build. After building, the .so " + "lands in flash_rt/ (pip install -e . makes it importable).") + + return fvk + + +def load_nvfp4_kernels(): + """Load kernels required for the NVFP4 pipeline. + + Validates the NVFP4 precision surface plus the shared block-fusion + surface (gelu), so a build that can run the NVFP4 GEMMs but not the + fused block path still fails fast. + """ + return _load_kernels(_REQUIRED_NVFP4_SYMBOLS + _REQUIRED_BLOCK_SYMBOLS) + + +def load_fp8_kernels(): + """Load kernels required for the FP8 pipeline. + + Validates the FP8 precision surface plus the shared block-fusion surface + (gelu), so a build that can run the FP8 GEMMs but not the fused block + path still fails fast. + """ + return _load_kernels(_REQUIRED_FP8_SYMBOLS + _REQUIRED_BLOCK_SYMBOLS) \ No newline at end of file From ee0f16cab4dcdb692e551093a3190e3049c6c787 Mon Sep 17 00:00:00 2001 From: chenping Date: Thu, 2 Jul 2026 21:58:23 +0800 Subject: [PATCH 06/23] docs(minimax-remover): add FP8 pipeline and update documentation for precision paths - Add MiniMaxRemoverPipelineFP8 as default full-frame pipeline (FP8 W8A8) - Clarify NVFP4 is only suitable for small cropped regions - Add performance comparison table and correctness metrics - Update quickstart example with --use-fp4 flag - Refactor kernel loading into shared _utils module --- docs/minimax_remover_usage.md | 214 +++++++++++----- examples/minimax_remover_quickstart.py | 35 ++- flash_rt/models/minimax_remover/__init__.py | 33 ++- flash_rt/models/minimax_remover/pipeline.py | 81 ++---- tests/test_minimax_remover_smoke.py | 270 ++++++++++++++------ 5 files changed, 417 insertions(+), 216 deletions(-) diff --git a/docs/minimax_remover_usage.md b/docs/minimax_remover_usage.md index 9d004a73..9a3182eb 100644 --- a/docs/minimax_remover_usage.md +++ b/docs/minimax_remover_usage.md @@ -1,14 +1,23 @@ # MiniMax-Remover — FlashRT Inference Pipeline -MiniMax-Remover video inpainting (subtitle / object removal) with NVFP4 -(W4A4) kernelized inference on Blackwell SM120. +MiniMax-Remover video inpainting (subtitle / object removal) with FlashRT +kernelized inference on Blackwell SM120. Two precision paths ship under +`flash_rt.models.minimax_remover`: -## Build +| Entrypoint | Precision | Recommended use | +|------------|-----------|-----------------| +| `MiniMaxRemoverPipelineFP8` | FP8 (W8A8) | **Default.** Full-frame inpainting; end-to-end cosine >= 0.999, PSNR ~35-41 dB vs fp16 (see Performance). | +| `MiniMaxRemoverPipeline` | NVFP4 (W4A4) | Cropped small regions only. Full-frame large latents drift/blacken due to FP4 error accumulation. | + +Both reuse the **generic** FlashRT kernels — the package ships **no +model-specific CUDA operators** — and rewrite the transformer Linears as +quantized GEMMs, fuse norm/gate/residual/gelu ops, and use kernel attention +(FA2 / SageAttention). The NVFP4 path additionally captures the N-step +flow-matching loop as a single CUDA Graph; the FP8 path is graph-compatible +(stable static scales, no host sync in steady state) but does not itself +capture a graph. -The pipeline reuses the **generic** FlashRT SM120 NVFP4 kernels — it -ships **no model-specific CUDA operators**. Building for Blackwell -auto-enables the NVFP4 kernels (the NVFP4 quantise / W4A4 GEMM / -fused-bias-gelu entry points land in `flash_rt_kernels.so`): +## Build ```bash cd FlashRT @@ -17,24 +26,28 @@ cmake --build build -j --target flash_rt_kernels pip install -e ".[torch,minimax-remover]" ``` -`GPU_ARCH=120` (RTX 5090) or `121` selects the Blackwell target; the -NVFP4 surface is compiled in automatically (internally gated by -`ENABLE_CUTLASS_SM120_NVFP4_W4A16`, which is set from `GPU_ARCH`, not a -flag users pass). Then install the runtime extras: +`GPU_ARCH=120` (RTX 5090) or `121` selects the Blackwell target; the NVFP4 +surface is compiled in automatically (internally gated by +`ENABLE_CUTLASS_SM120_NVFP4_W4A16`, which is set from `GPU_ARCH`, not a flag +users pass). The FP8 symbols are part of the default build. Then install the +runtime extras: ```bash pip install -e ".[minimax-remover]" # diffusers + einops + scipy + sageattention ``` Importing `flash_rt.models.minimax_remover` always succeeds — it needs -**none** of `diffusers` / `einops` / `scipy` / `triton` / `sageattention`. The -kernel surface is validated lazily in -`MiniMaxRemoverPipeline.__init__` via `_load_kernels()`, and the runtime -deps are resolved at construction via `_import_runtime()`. If a required -symbol or dep is missing the constructor raises a clear `RuntimeError` -with the rebuild/install hint, so a non-NVFP4 build or a bare -environment fails fast instead of crashing mid-run. The required generic -symbols are: +**none** of `diffusers` / `einops` / `scipy` / `triton` / `sageattention`. The kernel +surface is validated lazily in each pipeline's `__init__` via +`load_nvfp4_kernels()` / `load_fp8_kernels()` (`flash_rt/models/minimax_remover/_utils.py`), +and the runtime deps are resolved at construction via `_import_runtime()`. If +a required symbol or dep is missing the constructor raises a clear +`RuntimeError` with the rebuild/install hint, so a non-matching build or a +bare environment fails fast instead of crashing mid-run. + +### Required kernel symbols + +NVFP4 path (Blackwell-only, auto-enabled by `GPU_ARCH=120/121`): | Symbol | Role | |--------|------| @@ -45,17 +58,49 @@ symbols are: | `add_bias_bf16` | in-place bias add on bf16 GEMM output | | `fp4_w4a16_gemm_bias_gelu_fp4out_sm120` | fused FFN-up GEMM + bias + GELU -> FP4 | +FP8 path (default build): + +| Symbol | Role | +|--------|------| +| `quantize_fp8_static_fp16` | weight + static activation FP8 quantise | +| `fp8_gemm_descale_fp16` | FP8 W8A8 MMA -> fp16 with scale descale | +| `add_bias_fp16` | in-place bias add on fp16 GEMM output | + +Shared block-fusion symbols (default `gelu_mode="inplace"`, default build, required by both paths): + +| Symbol | Role | +|--------|------| +| `gelu_inplace` | in-place tanh-approximate GELU on bf16 FFN-up output | +| `gelu_inplace_fp16` | in-place tanh-approximate GELU on fp16 FFN-up output | + The attention backend (`FLASHRT_ATTN_MODE`) optionally pulls in -`sageattention` (Sage); `fa2` uses the vendored `flash_rt_fa2.so` and is -the dependency-light fallback. The fused norm / RoPE / Euler-step -elementwise kernels are self-contained Triton JIT kernels shipped in the -package (`_kernels.py`) and need no build step. +`sageattention` (Sage); `fa2` uses the vendored `flash_rt_fa2.so` and is the +dependency-light fallback. The fused norm / RoPE / Euler-step elementwise +kernels are self-contained Triton JIT kernels shipped in the package +(`_kernels.py`) and need no build step. + +## Pipelines + +### FP8 — `MiniMaxRemoverPipelineFP8` (default, full-frame) -## Pipeline +`flash_rt/models/minimax_remover/_fp8_pipeline.py`. Uses static calibration: +the first inference call runs in dynamic-FP8 calibration mode (accumulating +activation amax on GPU), then freezes to a static `act_scale` for all +subsequent calls (zero CPU sync overhead in the steady state, suitable for +CUDA Graph capture). **The frozen scale is calibrated to the first call's +input; if the input resolution/shape changes, construct a new pipeline so +the scale is re-calibrated.** -`flash_rt/models/minimax_remover/pipeline.py` — `MiniMaxRemoverPipeline`. -It wraps a loaded diffusers MiniMax-Remover `pipe` and consumes it in -place: +- every eligible transformer Linear -> FP8 W8A8 GEMM (weight quantised once + at load time; activation quantised with a calibrated static scale); +- per-block LayerNorm + adaLN modulation + gate-residual fused into Triton + kernels (fp32 statistics); +- `torch.nn.functional.scaled_dot_product_attention` -> FA2 / SageAttention. + +### NVFP4 — `MiniMaxRemoverPipeline` (small-region only) + +`flash_rt/models/minimax_remover/pipeline.py`. It wraps a loaded diffusers +MiniMax-Remover `pipe` and consumes it in place: - every eligible transformer Linear -> NVFP4 W4A4 GEMM (weight quantised once at load time; activation quantised **dynamically** per call with @@ -64,7 +109,6 @@ place: - transformer switched to bf16 (NVFP4-native, eliminates the fp16<->bf16 cast pair); - RoPE freqs cached as complex; -- `torch.nn.functional.scaled_dot_product_attention` -> FA2 / SageAttention; - per-block LayerNorm + adaLN modulation + gate-residual fused into a single fp32-stat Triton kernel; - the N-step flow-matching denoise loop replaced by a manual, graph- @@ -76,24 +120,54 @@ place: inside the captured graph there are **zero** torch elementwise ops — every operation is a kernel launch. -The VAE encode / decode run unchanged from the loaded diffusers model -(one-shot per segment, outside the graph). No MiniMax-Remover source is -imported; the `pipe` is duck-typed through `.transformer` / `.vae` / -`.scheduler` / `.video_processor` and the `expand_masks` / `resize` -helpers. +Both paths run the VAE encode / decode unchanged from the loaded diffusers +model (one-shot per segment, outside the graph). No MiniMax-Remover source +is imported; the `pipe` is duck-typed through `.transformer` / `.vae` / +`.scheduler` / `.video_processor` and the `expand_masks` / `resize` helpers. ## Performance (RTX 5060 Ti, SM120, CUDA 13) -### End-to-end (123 frames, 3 segments, fp16 reference baseline) +All numbers below are reproducible with the quickstart: -| Stack | Wall time | Speedup | PSNR vs fp16 | -|-------|-----------|---------|--------------| -| fp16 reference (diffusers) | 30.7 s | 1.0× | — | -| FlashRT NVFP4 (this pipeline) | 11.9 s | **2.6×** | 52.0 / 45.2 dB | +```bash +python3 examples/minimax_remover_quickstart.py \ + --model-dir ./minimax-remover \ + --frames-dir ./object_removal_data/ \ + --masks-dir ./object_removal_data/ \ + --output-dir ./out # FP8 (default) +python3 examples/minimax_remover_quickstart.py ... --use-fp4 # NVFP4 +python3 examples/minimax_remover_quickstart.py ... --no-flashrt # fp16 reference +``` -At 24 fps the 123-frame clip is 5.1 s, so the FlashRT stack runs at -**RTF ≈ 2.3** (processing-time / clip-duration); the fp16 reference is -RTF ≈ 6.0. +Wall time is a single end-to-end segment (load -> encode -> denoise loop -> +decode -> save). FP8 numbers include the one-time calibration pass on the +first call. Correctness (PSNR / cosine) is the FP8/NVFP4 output compared +against the `--no-flashrt` fp16 reference output on the same input. + +### End-to-end, full-frame (single segment, all frames at once) + +All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the +**same** input clip (same seed, same frames, same masks). + +| Clip (frames, resolution) | Stack | Wall time | Speedup vs fp16 ref | PSNR mean / worst vs fp16 ref | cosine mean | +|--------------------------|-------|-----------|---------------------|-------------------------------|-------------| +| tennis (70 frames, 432x240) | fp16 reference (`--no-flashrt`) | 17.31 s | 1.0x | — | — | +| tennis (70 frames, 432x240) | FlashRT FP8 (default) | 11.76 s | **1.47x** | 40.8 / 37.4 dB | 0.99981 | +| tennis (70 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 9.52 s | 1.82x | 7.0 / 6.2 dB | 0.00000 (broken) | +| bmx-trees (80 frames, 432x240) | fp16 reference (`--no-flashrt`) | 19.76 s | 1.0x | — | — | +| bmx-trees (80 frames, 432x240) | FlashRT FP8 (default) | 13.24 s | **1.49x** | 35.1 / 32.0 dB | 0.99912 | +| bmx-trees (80 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 10.72 s | 1.84x | 7.3 / 7.0 dB | 0.00000 (broken) | + +Takeaways: + +- **FP8 is the correct default for full-frame inpainting**: ~1.5x faster than + the fp16 reference with cosine >= 0.999 and PSNR 35-41 dB. +- **NVFP4 is faster but unusable on full-frame latents**: cosine collapses to + ~0.0 and PSNR to ~7 dB (median per-pixel deviation ~85/255). The FP4 + quantisation error accumulates over the large full-frame activations and the + output drifts to black — exactly why FP8 is the default. NVFP4 is only + appropriate for small cropped regions, where its per-block error stays + bounded. ### Transformer GEMM (NVFP4 vs fp16 matmul, single layer) @@ -110,41 +184,50 @@ static-quant FP8 GEMM. ### Precision specification -The pipeline keeps the math reference-equivalent on the precision-critical -path (fp32-stat LayerNorm / RMSNorm, interleaved RoPE) and confines the -loss to the quantised GEMMs and the attention backend. +The pipelines keep the math reference-equivalent on the precision-critical +path (fp32-stat LayerNorm / RMSNorm, interleaved RoPE) and confine the loss +to the quantised GEMMs and the attention backend. | Component | Metric | Value | |-----------|--------|-------| | Attention — SageAttention QK-int8 PV-fp8 (`sage_fp8`, default) | cosine vs SDPA | 0.9993 | | Attention — SageAttention QK-int8 PV-fp16 (`sage_fp16`) | cosine vs SDPA | 0.9999 | | NVFP4 W4A4 GEMM | cosine vs fp16 matmul | >= 0.999 | -| End-to-end (full pipeline) | PSNR vs fp16 | 52.0 dB (mean) / 45.2 dB (worst frame) | -| End-to-end (full pipeline) | max abs diff vs fp16 | bounded by the FP4 grid; per-block relative, scales with activation magnitude (median per-pixel deviation < 2 / 255 on 8-bit output) | +| FP8 W8A8 GEMM | cosine vs fp16 matmul | >= 0.999 | +| End-to-end FP8 (full-frame) | PSNR vs fp16 ref | 35-41 dB (mean) / >= 32 dB (worst frame) | +| End-to-end FP8 (full-frame) | cosine vs fp16 ref | >= 0.999 | +| End-to-end NVFP4 (full-frame) | cosine vs fp16 ref | ~0.0 — **broken**, output drifts to black (median per-pixel deviation ~85 / 255) | +| End-to-end NVFP4 (small cropped region only) | PSNR vs fp16 ref | ~52 dB (mean) / ~45 dB (worst frame); per-block FP4 error stays bounded only when activations are small | The default `sage_fp8` attention gives the best latency at cosine 0.9993; switch to `FLASHRT_ATTN_MODE=sage_fp16` for cosine 0.9999 at a small -latency cost. NVFP4 needs no calibration, so the first call is already -in the steady state (no warm-up / calibration pass). +latency cost. NVFP4 needs no calibration, so the first call is already in +the steady state; the FP8 path calibrates on the first call then freezes. ## Environment variables | Variable | Default | Effect | |----------|---------|--------| -| `FLASHRT_ATTN_MODE` | `sage_fp8` | attention backend (`sage_fp8`/`sage_fp16`/`sage`/`fa2`) | -| `FLASHRT_FP4_GEMM` | `pingpong` | GEMM kernel variant (`pingpong`/`plain`/`widen`) | -| `FLASHRT_FUSED_BLOCK` | `1` | fused QKV-quant-once + fused FFN-up GEMM+bias+gelu block (`0` = per-projection re-quant debug path) | -| `FLASHRT_MANUAL_GRAPH` | `0` | capture the whole denoise loop as one CUDA Graph | +| `FLASHRT_ATTN_MODE` | `sage_fp8` | attention backend (`sage_fp8`/`sage_fp16`/`sage`/`sage_triton`/`triton_fp8`/`triton_fp16`/`fa2`) | +| `FLASHRT_FP4_GEMM` | `pingpong` | GEMM kernel variant (`pingpong`/`plain`/`widen`) (NVFP4 path) | +| `FLASHRT_FUSED_BLOCK` | `1` | fused QKV-quant-once + fused FFN-up GEMM+bias+gelu block (`0` = per-projection re-quant debug path) (NVFP4 path) | +| `FLASHRT_MANUAL_GRAPH` | `0` | capture the whole denoise loop as one CUDA Graph (NVFP4 path) | | `FLASHRT_NUM_STEPS` | unset | override the denoise step count (default 12) | +| `FLASHRT_FP8_TARGET` | `all` | FP8 Linear scope (`all` / `ffn_only`) (FP8 path) | +| `FLASHRT_NORM_MODE` | `triton` | per-block LayerNorm kernel: `triton` (fp32-stat Triton, bit-exact) / `fp16` (FlashRT `ada_layer_norm_fp16`, lower precision, debug only) | +| `FLASHRT_GELU_MODE` | `inplace` | FFN GELU kernel: `inplace` (FlashRT fused `gelu_inplace*`) / `torch` (original `F.gelu`, debug only) | ## Usage +### FP8 (recommended default — full-frame) + ```python -from flash_rt.models.minimax_remover import MiniMaxRemoverPipeline +from flash_rt.models.minimax_remover import MiniMaxRemoverPipelineFP8 -# `pipe` is a loaded diffusers Minimax_Remover_Pipeline (transformer + -# vae + scheduler). The FlashRT pipeline consumes it in place. -pipeline = MiniMaxRemoverPipeline(pipe) +# `pipe` is a loaded diffusers Minimax_Remover_Pipeline (transformer + vae + +# scheduler). The FlashRT pipeline consumes it in place. The first call +# calibrates the FP8 act_scale; subsequent calls reuse the frozen scale. +pipeline = MiniMaxRemoverPipelineFP8(pipe) output = pipeline( images=frames, # [F, H, W, 3] uint8/np, 0..255 masks=masks, # [F, H, W, 1] np, 0/1 @@ -155,9 +238,22 @@ output = pipeline( video = output.frames ``` +### NVFP4 (small cropped regions only) + +```python +from flash_rt.models.minimax_remover import MiniMaxRemoverPipeline + +pipeline = MiniMaxRemoverPipeline(pipe) +output = pipeline( + images=frames, masks=masks, num_frames=len(frames), + height=720, width=1280, num_inference_steps=12, +) +video = output.frames +``` + ## Model weights MiniMax-Remover checkpoint + the `Transformer3DModel` / `AutoencoderKLWan` -definitions are loaded by the reference project (unmodified, via the -loaded diffusers `pipe`). This FlashRT pipeline module imports no -MiniMax-Remover source. +definitions are loaded by the reference project (unmodified, via the loaded +diffusers `pipe`). This FlashRT pipeline module imports no MiniMax-Remover +source. diff --git a/examples/minimax_remover_quickstart.py b/examples/minimax_remover_quickstart.py index 06f3735d..a4cbcb72 100644 --- a/examples/minimax_remover_quickstart.py +++ b/examples/minimax_remover_quickstart.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""MiniMax-Remover full-frame mask removal -- FlashRT NVFP4 quickstart. +"""MiniMax-Remover full-frame mask removal -- FlashRT FP8 quickstart. End-to-end demo of the FlashRT-accelerated MiniMax-Remover video mask removal pipeline. Every frame (plus its binary mask) is fed to the model @@ -10,17 +10,21 @@ https://github.com/zibojia/MiniMax-Remover It wraps a loaded diffusers MiniMax-Remover pipeline with -``flash_rt.models.minimax_remover.MiniMaxRemoverPipeline``, which rewrites -the transformer denoise path onto NVFP4 (W4A4) GEMMs, fused fp32-stat -Triton norm/RoPE, kernel attention and a graph-capturable manual -flow-matching loop. +``flash_rt.models.minimax_remover.MiniMaxRemoverPipelineFP8`` (default) or +``MiniMaxRemoverPipeline`` (NVFP4, --use-fp4). The FP8 path rewrites the +transformer denoise path onto FP8 (W8A8) GEMMs with static calibration, +fused norm/RoPE/gelu kernels and kernel attention; the NVFP4 path adds a +graph-captured manual flow-matching loop. FP8 stays close to the fp16 +reference on full-frame inputs (end-to-end cosine >= 0.999, PSNR ~35-41 dB); +NVFP4 is only for small cropped regions. ------------------------------------------------------------------ Test data ------------------------------------------------------------------ Sample frames + masks (numeric filenames, aligned by frame number): - git clone https://github.com/chenping9999/object_removal_data.git + + (frames and masks directories with numeric filenames, aligned by frame number) ------------------------------------------------------------------ Model weights @@ -564,7 +568,7 @@ def pad_masks_bottom_right(masks: np.ndarray, ph: int, pw: int) -> np.ndarray: def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser( - description="MiniMax-Remover full-frame mask removal (FlashRT NVFP4).") + description="MiniMax-Remover full-frame mask removal (FlashRT FP8).") p.add_argument("--model-dir", type=str, required=True, help="MiniMax-Remover checkpoint dir (vae/ transformer/ scheduler/).") p.add_argument("--frames-dir", type=str, required=True, @@ -578,6 +582,13 @@ def parse_args() -> argparse.Namespace: help="Denoise steps (default 12).") p.add_argument("--no-flashrt", action="store_true", help="Run the reference diffusers path instead of FlashRT (for diffing).") + p.add_argument("--use-fp4", action="store_true", + help="Use NVFP4 (W4A4) instead of the default FP8 (W8A8). " + "NVFP4 is only calibrated for small cropped regions " + "(bbox crop); full-frame inpainting will produce " + "black/drift outputs. FP8 (default) is recommended " + "for full-frame inpainting (end-to-end cosine >= 0.999, " + "PSNR ~35-41 dB vs fp16).") return p.parse_args() @@ -599,7 +610,7 @@ def main() -> None: "--include vae transformer scheduler --local-dir ") print("=" * 60) - print("MiniMax-Remover full-frame mask removal (FlashRT NVFP4 W4A4)") + print("MiniMax-Remover full-frame mask removal (FlashRT FP8 W8A8)") print("=" * 60) image_paths = collect_frame_files(frames_dir) @@ -635,10 +646,14 @@ def main() -> None: if args.no_flashrt: runner = pipe tag = "reference (diffusers fp16)" - else: + elif args.use_fp4: from flash_rt.models.minimax_remover import MiniMaxRemoverPipeline runner = MiniMaxRemoverPipeline(pipe) - tag = "FlashRT NVFP4 W4A4" + tag = "FlashRT NVFP4 W4A4 (small-region only)" + else: + from flash_rt.models.minimax_remover import MiniMaxRemoverPipelineFP8 + runner = MiniMaxRemoverPipelineFP8(pipe) + tag = "FlashRT FP8 W8A8 (full-frame)" t, h, w, _ = frames_padded.shape images_tensor = torch.from_numpy(frames_padded).to(device=DEVICE, dtype=torch.float16) diff --git a/flash_rt/models/minimax_remover/__init__.py b/flash_rt/models/minimax_remover/__init__.py index 532fd642..b9632219 100644 --- a/flash_rt/models/minimax_remover/__init__.py +++ b/flash_rt/models/minimax_remover/__init__.py @@ -1,18 +1,27 @@ """FlashRT -- MiniMax-Remover video inpainting pipeline. MiniMax-Remover is a flow-matching video inpainting Transformer used for -subtitle / object removal. This package ships the NVFP4 (W4A4) -kernelized inference pipeline (``MiniMaxRemoverPipeline``): the -transformer Linears are rewritten as NVFP4 GEMMs over the generic -FlashRT SM120 kernels, the per-block norm / gate / residual / gelu ops -become fused Triton kernels, attention moves to FA2 / SageAttention, and -the N-step flow-matching loop is captured as a single CUDA Graph. The VAE -encode / decode run unchanged from the loaded diffusers model. +subtitle / object removal. This package ships two kernelized inference +pipelines: + +* ``MiniMaxRemoverPipeline`` (NVFP4 W4A4): For cropped small regions only. + Full-frame large latents produce black/drift outputs due to FP4 quantization + error accumulation. + +* ``MiniMaxRemoverPipelineFP8`` (FP8 W8A8): Recommended default for full-frame + inpainting. End-to-end cosine >= 0.999 and PSNR ~35-41 dB vs the fp16 + reference (measured on full-frame clips), at ~1.5x wall-clock speedup. + +Both pipelines rewrite transformer Linears as quantized GEMMs over FlashRT +SM120 kernels, fuse norm/gate/residual/gelu ops, and use kernel attention +(FA2 / SageAttention). The NVFP4 path captures the N-step flow-matching loop +as a single CUDA Graph; the FP8 path is stream-safe and graph-compatible +(stable static scales, no host sync in steady state) but does not itself +capture a graph. """ -from flash_rt.models.minimax_remover.pipeline import ( - MiniMaxRemoverPipeline, - _load_kernels, -) +from flash_rt.models.minimax_remover.pipeline import MiniMaxRemoverPipeline +from flash_rt.models.minimax_remover._fp8_pipeline import MiniMaxRemoverPipelineFP8 +from flash_rt.models.minimax_remover._utils import load_nvfp4_kernels, load_fp8_kernels -__all__ = ["MiniMaxRemoverPipeline"] +__all__ = ["MiniMaxRemoverPipeline", "MiniMaxRemoverPipelineFP8"] diff --git a/flash_rt/models/minimax_remover/pipeline.py b/flash_rt/models/minimax_remover/pipeline.py index f139731d..75d76a2a 100644 --- a/flash_rt/models/minimax_remover/pipeline.py +++ b/flash_rt/models/minimax_remover/pipeline.py @@ -6,6 +6,11 @@ FlashRT kernels, fused fp32-stat Triton norm/RoPE, kernel attention and a graph-capturable manual N-step flow-matching loop. +For full-frame inpainting prefer the FP8 (W8A8) sibling +``MiniMaxRemoverPipelineFP8`` in ``_fp8_pipeline.py`` (near-fp16 precision, +recommended default); this NVFP4 path is calibrated only for small cropped +regions. + What is replaced (vs the diffusers reference ``Minimax_Remover_Pipeline``): * every eligible transformer Linear -> NVFP4 W4A4 GEMM (weight quantised @@ -24,48 +29,37 @@ The pipeline requires the generic SM120 NVFP4 kernels in ``flash_rt_kernels``. They are gated by the Blackwell NVFP4 build option -(``ENABLE_CUTLASS_SM120_NVFP4_W4A16``); ``_load_kernels`` validates them -and raises a clear ``RuntimeError`` if they are absent, so a non-NVFP4 -build fails fast instead of crashing mid-quantisation. +(``ENABLE_CUTLASS_SM120_NVFP4_W4A16``); ``load_nvfp4_kernels`` (defined in +``_utils``, the single source of truth for the kernel surface) validates +them and raises a clear ``RuntimeError`` if they are absent, so a +non-NVFP4 build fails fast instead of crashing mid-quantisation. """ import logging import os -import types import torch logger = logging.getLogger(__name__) -# Core generic SM120 NVFP4 kernel surface required by this pipeline. All of -# these are compiled in automatically on a Blackwell (sm_120a / sm_121a) -# build; if any is missing the pipeline cannot run and _load_kernels raises -# a clear RuntimeError. -_REQUIRED_NVFP4_SYMBOLS = ( - "nvfp4_sf_swizzled_bytes", - "bf16_weight_to_nvfp4_swizzled", - "quantize_bf16_to_nvfp4_swizzled", - "fp4_w4a16_gemm_sm120_bf16out_pingpong", - "add_bias_bf16", - "fp4_w4a16_gemm_bias_gelu_fp4out_sm120", +from flash_rt.models.minimax_remover._fp8_pipeline import MiniMaxRemoverPipelineFP8 +from flash_rt.models.minimax_remover._utils import ( + _REQUIRED_NVFP4_SYMBOLS, + _load_kernels, + load_nvfp4_kernels, ) -# Optional third-party packages pulled in only when the pipeline is actually -# constructed. Importing this model package never touches them, so -# `import flash_rt.models.minimax_remover` succeeds in a bare environment. +# Re-exported here for backward compatibility (docs / tests historically import +# the kernel surface from this module). The single source of truth lives in +# ``_utils``; the two must never diverge. +__all__ = ["MiniMaxRemoverPipeline", "MiniMaxRemoverPipelineFP8", + "_REQUIRED_NVFP4_SYMBOLS", "_load_kernels"] + _RUNTIME_DEPS = ("diffusers", "einops", "triton") def _import_runtime(): - """Lazily import the optional runtime deps + the pipeline submodules. - - Importing ``flash_rt.models.minimax_remover`` does not require any of - the optional dependencies; they are only resolved here, at pipeline - construction time. Raises ``RuntimeError`` with an install hint if any - optional dep is missing instead of a low-level ``ModuleNotFoundError``. - - Returns ``(install_flashrt_nvfp4, install_attention, ManualRemoverPipeline)``. - """ + """Lazily import the optional runtime deps + the pipeline submodules.""" missing = [] for dep in _RUNTIME_DEPS: try: @@ -86,37 +80,6 @@ def _import_runtime(): return install_flashrt_nvfp4, install_attention, ManualRemoverPipeline -def _load_kernels(): - """Import ``flash_rt_kernels`` and validate the required NVFP4 surface. - - Returns the raw ``flash_rt_kernels`` module. Raises ``RuntimeError`` - if any required symbol is missing (i.e. the build was not configured - with the Blackwell NVFP4 kernels enabled). - """ - try: - from flash_rt import flash_rt_kernels as fvk - except ImportError: - try: - import flash_rt_kernels as fvk # type: ignore - except ImportError: - raise RuntimeError( - "flash_rt_kernels is not available. Build FlashRT with " - "'pip install -e .' and ensure the compiled .so is on the path.") - - missing = [s for s in _REQUIRED_NVFP4_SYMBOLS if not hasattr(fvk, s)] - if missing: - raise RuntimeError( - "MiniMax-Remover requires the SM120 NVFP4 kernels which are not " - "compiled into flash_rt_kernels. Build FlashRT for Blackwell, which " - "auto-enables the NVFP4 kernels:\n" - " cmake -S . -B build -DGPU_ARCH=120 -DCMAKE_BUILD_TYPE=Release\n" - " cmake --build build -j --target flash_rt_kernels\n" - "(sm_120a / sm_121a, i.e. GPU_ARCH=120/121). The internal compile " - "flag is ENABLE_CUTLASS_SM120_NVFP4_W4A16.\n" - f"Missing symbols: {', '.join(missing)}") - return fvk - - class _FrozenMarker: """NVFP4 needs no calibration (per-call dynamic quantisation). @@ -156,7 +119,7 @@ class MiniMaxRemoverPipeline: def __init__(self, pipe, num_inference_steps=12, fp4_target="all", use_bf16=True, use_manual_pipeline=True): - self.fvk = _load_kernels() + self.fvk = load_nvfp4_kernels() # Optional runtime deps (diffusers / einops / triton) and the pipeline # submodules are resolved here, at construction time -- importing the # model package never touches them. diff --git a/tests/test_minimax_remover_smoke.py b/tests/test_minimax_remover_smoke.py index b15f859e..c0599323 100644 --- a/tests/test_minimax_remover_smoke.py +++ b/tests/test_minimax_remover_smoke.py @@ -2,31 +2,74 @@ These tests run in **any** build configuration: - default build (SM120 NVFP4 kernels absent): import succeeds, - ``_load_kernels`` raises ``RuntimeError``, pipeline construction - fails fast. - - gated build (SM120 NVFP4 kernels present): the required NVFP4 - symbols are present and callable. + ``load_nvfp4_kernels`` / ``load_fp8_kernels`` raise ``RuntimeError`` + naming the missing symbols, and pipeline construction fails fast. + - gated build (SM120 NVFP4 kernels present): every required symbol is + present and callable. + +Both the NVFP4 (``MiniMaxRemoverPipeline``) and FP8 +(``MiniMaxRemoverPipelineFP8``) paths are covered. No GPU, no model checkpoint, no MiniMax-Remover source tree is required. """ +import sys +import types + import pytest -# ── 1. Package import always succeeds ── +# ── helpers ── + +def _stub_kernels(symbols=()): + """Register an empty flash_rt_kernels stub exposing only ``symbols``.""" + fake_mod = types.ModuleType("flash_rt.flash_rt_kernels") + for s in symbols: + setattr(fake_mod, s, lambda *a, **k: None) + sys.modules["flash_rt.flash_rt_kernels"] = fake_mod + return fake_mod + + +def _restore_kernels(): + sys.modules.pop("flash_rt.flash_rt_kernels", None) + + +# ── 1. Package import always succeeds (no optional deps, no kernels) ── def test_package_import(): """Importing the model package must not require flash_rt_kernels.""" - from flash_rt.models.minimax_remover import MiniMaxRemoverPipeline + from flash_rt.models.minimax_remover import (MiniMaxRemoverPipeline, + MiniMaxRemoverPipelineFP8) assert MiniMaxRemoverPipeline is not None - - -def test_pipeline_module_import(): - """The pipeline module imports cleanly without flash_rt_kernels.""" + assert MiniMaxRemoverPipelineFP8 is not None + + +def test_utils_module_import(): + """The _utils module owns the single kernel-surface source of truth.""" + from flash_rt.models.minimax_remover import _utils + assert hasattr(_utils, "load_nvfp4_kernels") + assert hasattr(_utils, "load_fp8_kernels") + assert hasattr(_utils, "_load_kernels") + # NVFP4 surface + assert "nvfp4_sf_swizzled_bytes" in _utils._REQUIRED_NVFP4_SYMBOLS + # FP8 surface must list every symbol the FP8 Linear actually calls + # (quantize + gemm + bias-add), so a missing build fails fast. + assert "quantize_fp8_static_fp16" in _utils._REQUIRED_FP8_SYMBOLS + assert "fp8_gemm_descale_fp16" in _utils._REQUIRED_FP8_SYMBOLS + assert "add_bias_fp16" in _utils._REQUIRED_FP8_SYMBOLS + # Shared block-fusion surface: gelu_inplace(_fp16) is on the default hot + # path of both pipelines (gelu_mode="inplace"), so it must be validated + # alongside the precision surface to fail fast. + assert hasattr(_utils, "_REQUIRED_BLOCK_SYMBOLS") + assert "gelu_inplace" in _utils._REQUIRED_BLOCK_SYMBOLS + assert "gelu_inplace_fp16" in _utils._REQUIRED_BLOCK_SYMBOLS + + +def test_pipeline_reexports_kernel_surface(): + """pipeline.py re-exports _load_kernels/_REQUIRED_NVFP4_SYMBOLS for back-compat.""" from flash_rt.models.minimax_remover import pipeline - assert hasattr(pipeline, "MiniMaxRemoverPipeline") - assert hasattr(pipeline, "_load_kernels") - assert hasattr(pipeline, "_REQUIRED_NVFP4_SYMBOLS") - assert "nvfp4_sf_swizzled_bytes" in pipeline._REQUIRED_NVFP4_SYMBOLS + from flash_rt.models.minimax_remover import _utils + assert pipeline._REQUIRED_NVFP4_SYMBOLS is _utils._REQUIRED_NVFP4_SYMBOLS + assert pipeline._load_kernels is _utils._load_kernels def test_attention_forward_fa2_does_not_import_sageattention(monkeypatch): @@ -75,62 +118,88 @@ def test_manual_fused_block_uses_shared_attention_forward(): assert "attention_forward(q, k, v, scale, _attention_mode())" in src -# ── 2. _load_kernels validates the NVFP4 surface ── +# ── 2. load_*_kernels validate the kernel surface ── -def test_load_kernels_raises_when_symbols_absent(): - """Without the NVFP4 kernels, _load_kernels raises a clear RuntimeError.""" - from flash_rt.models.minimax_remover import pipeline +def test_load_nvfp4_kernels_raises_when_symbols_absent(): + """Without the NVFP4 kernels, load_nvfp4_kernels raises a clear RuntimeError.""" + from flash_rt.models.minimax_remover import _utils + _stub_kernels(symbols=()) # none of the required symbols + try: + with pytest.raises(RuntimeError) as excinfo: + _utils.load_nvfp4_kernels() + msg = str(excinfo.value) + assert "NVFP4" in msg + assert "nvfp4_sf_swizzled_bytes" in msg + assert "bf16_weight_to_nvfp4_swizzled" in msg + finally: + _restore_kernels() - class _NoNvfp4: - # Module stub exposing none of the required symbols. - pass - import sys - import types +def test_load_nvfp4_kernels_succeeds_when_symbols_present(): + """With all required NVFP4 symbols, load_nvfp4_kernels returns the module.""" + from flash_rt.models.minimax_remover import _utils + fake_mod = _stub_kernels(symbols=_utils._REQUIRED_NVFP4_SYMBOLS + _utils._REQUIRED_BLOCK_SYMBOLS) + try: + assert _utils.load_nvfp4_kernels() is fake_mod + finally: + _restore_kernels() - fake_mod = types.ModuleType("flash_rt.flash_rt_kernels") - sys.modules["flash_rt.flash_rt_kernels"] = fake_mod + +def test_load_fp8_kernels_raises_when_symbols_absent(): + """Without the FP8 kernels, load_fp8_kernels raises a clear RuntimeError.""" + from flash_rt.models.minimax_remover import _utils + _stub_kernels(symbols=()) # none of the required symbols try: with pytest.raises(RuntimeError) as excinfo: - pipeline._load_kernels() + _utils.load_fp8_kernels() msg = str(excinfo.value) - assert "NVFP4" in msg - assert "Missing symbols" in msg - assert "nvfp4_sf_swizzled_bytes" in msg + # Every required FP8 symbol is named in the error. + for s in _utils._REQUIRED_FP8_SYMBOLS: + assert s in msg finally: - del sys.modules["flash_rt.flash_rt_kernels"] + _restore_kernels() -def test_load_kernels_succeeds_when_symbols_present(): - """With all required symbols, _load_kernels returns the kernels module.""" - from flash_rt.models.minimax_remover import pipeline +def test_load_fp8_kernels_raises_when_bias_symbol_missing(): + """A build that lacks add_bias_fp16 must fail fast (regression guard). - import sys - import types + Every other required symbol (FP8 precision + shared block) is present, + so the only missing symbol is add_bias_fp16. + """ + from flash_rt.models.minimax_remover import _utils + full = _utils._REQUIRED_FP8_SYMBOLS + _utils._REQUIRED_BLOCK_SYMBOLS + partial = tuple(s for s in full if s != "add_bias_fp16") + _stub_kernels(symbols=partial) + try: + with pytest.raises(RuntimeError) as excinfo: + _utils.load_fp8_kernels() + assert "add_bias_fp16" in str(excinfo.value) + finally: + _restore_kernels() - fake_mod = types.ModuleType("flash_rt.flash_rt_kernels") - for s in pipeline._REQUIRED_NVFP4_SYMBOLS: - setattr(fake_mod, s, lambda *a, **k: None) - sys.modules["flash_rt.flash_rt_kernels"] = fake_mod + +def test_load_fp8_kernels_succeeds_when_symbols_present(): + """With all required FP8 symbols, load_fp8_kernels returns the module.""" + from flash_rt.models.minimax_remover import _utils + fake_mod = _stub_kernels(symbols=_utils._REQUIRED_FP8_SYMBOLS + _utils._REQUIRED_BLOCK_SYMBOLS) try: - fvk = pipeline._load_kernels() - assert fvk is fake_mod + assert _utils.load_fp8_kernels() is fake_mod finally: - del sys.modules["flash_rt.flash_rt_kernels"] + _restore_kernels() -# ── 3. Pipeline construction validates kernel availability ── +# ── 3. Pipeline construction validates kernel availability (fail fast) ── class _FakePipe: """Minimal stub matching the diffusers pipeline contract. - Construction must fail at _load_kernels before any pipe attribute is + Construction must fail at kernel validation before any pipe attribute is touched, so the stub is never actually read. """ -def test_pipeline_constructor_validates_kernels(monkeypatch): - """Pipeline construction must fail before touching model internals.""" +def test_nvfp4_pipeline_constructor_validates_kernels(monkeypatch): + """NVFP4 pipeline construction must fail before touching model internals.""" from flash_rt.models.minimax_remover import pipeline def _raise_missing(): @@ -139,13 +208,14 @@ def _raise_missing(): "compiled into flash_rt_kernels. Rebuild with the Blackwell NVFP4 " "build option enabled.") - monkeypatch.setattr(pipeline, "_load_kernels", _raise_missing) + # The constructor calls load_nvfp4_kernels (imported from _utils). + monkeypatch.setattr(pipeline, "load_nvfp4_kernels", _raise_missing) with pytest.raises(RuntimeError, match="NVFP4"): pipeline.MiniMaxRemoverPipeline(_FakePipe()) -def test_pipeline_constructor_calls_load_kernels(monkeypatch): - """_load_kernels is invoked exactly once during construction.""" +def test_nvfp4_pipeline_constructor_calls_load_kernels(monkeypatch): + """load_nvfp4_kernels is invoked exactly once during NVFP4 construction.""" from flash_rt.models.minimax_remover import pipeline calls = [] @@ -154,16 +224,47 @@ def _fake_load(): calls.append(1) raise RuntimeError("stop construction here") - monkeypatch.setattr(pipeline, "_load_kernels", _fake_load) + monkeypatch.setattr(pipeline, "load_nvfp4_kernels", _fake_load) with pytest.raises(RuntimeError, match="stop construction"): pipeline.MiniMaxRemoverPipeline(_FakePipe()) assert len(calls) == 1 -# ── 4. Gated build: required NVFP4 symbols present and callable ── +def test_fp8_pipeline_constructor_validates_kernels(monkeypatch): + """FP8 pipeline construction must fail before touching model internals.""" + from flash_rt.models.minimax_remover import _fp8_pipeline -def test_nvfp4_symbols_present_when_gated(): - """In a gated build, every required NVFP4 symbol is present & callable.""" + def _raise_missing(): + raise RuntimeError( + "MiniMax-Remover FP8 requires flash_rt_kernels with the FP8 " + "symbols (quantize_fp8_static_fp16 / fp8_gemm_descale_fp16 / " + "add_bias_fp16). Rebuild flash_rt_kernels.") + + # The constructor calls load_fp8_kernels (imported from _utils). + monkeypatch.setattr(_fp8_pipeline, "load_fp8_kernels", _raise_missing) + with pytest.raises(RuntimeError, match="FP8"): + _fp8_pipeline.MiniMaxRemoverPipelineFP8(_FakePipe()) + + +def test_fp8_pipeline_constructor_calls_load_kernels(monkeypatch): + """load_fp8_kernels is invoked exactly once during FP8 construction.""" + from flash_rt.models.minimax_remover import _fp8_pipeline + + calls = [] + + def _fake_load(): + calls.append(1) + raise RuntimeError("stop fp8 construction here") + + monkeypatch.setattr(_fp8_pipeline, "load_fp8_kernels", _fake_load) + with pytest.raises(RuntimeError, match="stop fp8 construction"): + _fp8_pipeline.MiniMaxRemoverPipelineFP8(_FakePipe()) + assert len(calls) == 1 + + +# ── 4. Gated build: required symbols present and callable ── + +def _get_kernels_or_skip(): try: from flash_rt import flash_rt_kernels as fvk except ImportError: @@ -171,46 +272,63 @@ def test_nvfp4_symbols_present_when_gated(): import flash_rt_kernels as fvk # type: ignore except ImportError: pytest.skip("flash_rt_kernels not built") + return fvk + - from flash_rt.models.minimax_remover.pipeline import _REQUIRED_NVFP4_SYMBOLS +def test_nvfp4_symbols_present_when_gated(): + """In a gated build, every required NVFP4 symbol is present & callable.""" + fvk = _get_kernels_or_skip() + from flash_rt.models.minimax_remover._utils import _REQUIRED_NVFP4_SYMBOLS missing = [s for s in _REQUIRED_NVFP4_SYMBOLS if not hasattr(fvk, s)] if missing: pytest.skip(f"SM120 NVFP4 kernels not compiled (missing: {', '.join(missing)})") - for sym in _REQUIRED_NVFP4_SYMBOLS: assert callable(getattr(fvk, sym)), f"{sym} is not callable" +def test_fp8_symbols_present_when_gated(): + """In a build with FP8 kernels, every required FP8 symbol is callable.""" + fvk = _get_kernels_or_skip() + from flash_rt.models.minimax_remover._utils import _REQUIRED_FP8_SYMBOLS + missing = [s for s in _REQUIRED_FP8_SYMBOLS if not hasattr(fvk, s)] + if missing: + pytest.skip(f"FP8 kernels not compiled (missing: {', '.join(missing)})") + for sym in _REQUIRED_FP8_SYMBOLS: + assert callable(getattr(fvk, sym)), f"{sym} is not callable" + + +def test_block_symbols_present_in_default_build(): + """The shared gelu block-fusion symbols ship in the default build and are callable. + + Both pipelines call gelu_inplace(_fp16) on the default hot path, so a + default flash_rt_kernels build must expose them regardless of NVFP4 gating. + """ + fvk = _get_kernels_or_skip() + from flash_rt.models.minimax_remover._utils import _REQUIRED_BLOCK_SYMBOLS + for sym in _REQUIRED_BLOCK_SYMBOLS: + assert callable(getattr(fvk, sym)), f"{sym} is not callable" + + def test_nvfp4_symbols_absent_in_default_build(): - """In a default (non-NVFP4) build, _load_kernels documents the gap. + """In a default (non-NVFP4) build, load_nvfp4_kernels documents the gap. - This documents the 'compile option OFF' case end-to-end: if any - required symbol is missing the pipeline refuses to construct. + This documents the 'compile option OFF' case end-to-end: if any required + NVFP4 symbol is missing the pipeline refuses to construct. """ - try: - from flash_rt import flash_rt_kernels as fvk - except ImportError: - pytest.skip("flash_rt_kernels not built (treated as missing)") - from flash_rt.models.minimax_remover.pipeline import _REQUIRED_NVFP4_SYMBOLS + fvk = _get_kernels_or_skip() + from flash_rt.models.minimax_remover import _utils - missing = [s for s in _REQUIRED_NVFP4_SYMBOLS if not hasattr(fvk, s)] + missing = [s for s in _utils._REQUIRED_NVFP4_SYMBOLS if not hasattr(fvk, s)] if not missing: pytest.skip("this build has the NVFP4 kernels (gated build) — covered elsewhere") - # Verify _load_kernels raises and names the missing symbols. - import sys - import types - - fake_mod = types.ModuleType("flash_rt.flash_rt_kernels") - for s in _REQUIRED_NVFP4_SYMBOLS: - if hasattr(fvk, s): - setattr(fake_mod, s, getattr(fvk, s)) - sys.modules["flash_rt.flash_rt_kernels"] = fake_mod + # Stub the kernels module exposing only what this build actually has, then + # verify load_nvfp4_kernels raises and names every missing symbol. + _stub_kernels(symbols=[s for s in _utils._REQUIRED_NVFP4_SYMBOLS if hasattr(fvk, s)]) try: - from flash_rt.models.minimax_remover import pipeline with pytest.raises(RuntimeError) as excinfo: - pipeline._load_kernels() + _utils.load_nvfp4_kernels() for s in missing: assert s in str(excinfo.value) finally: - del sys.modules["flash_rt.flash_rt_kernels"] + _restore_kernels() From eba1b358e4e9c233fdb72743649f618a7dacc609 Mon Sep 17 00:00:00 2001 From: chenping Date: Thu, 2 Jul 2026 22:18:51 +0800 Subject: [PATCH 07/23] refactor(minimax-remover): unify attention dispatch and consolidate triton kernels - Make _attention the single source of truth for FLASHRT_ATTN_MODE routing (sage_*/triton_fp8/triton_fp16/fa2); _kern_block now reuses it via install_attention (with install_fa2_attention back-compat alias) instead of duplicating the dispatch and FA2 processor. - Consolidate _triton_fused_norm.py and _triton_rope.py into _kernels.py. - Add triton_fp8/triton_fp16 modes to the shared attention_forward dispatch. - Fail fast when FLASHRT_NORM_MODE=fp16 but the ada_layer_norm_fp16 kernel symbol is absent in the current flash_rt_kernels build. - Fix _fp8_linear docstring to match the set_calibration/freeze_calibration API. --- flash_rt/models/minimax_remover/_attention.py | 29 ++- .../models/minimax_remover/_fp8_linear.py | 4 +- .../models/minimax_remover/_kern_block.py | 201 +++--------------- .../minimax_remover/_triton_fused_norm.py | 199 ----------------- .../models/minimax_remover/_triton_rope.py | 69 ------ 5 files changed, 58 insertions(+), 444 deletions(-) delete mode 100644 flash_rt/models/minimax_remover/_triton_fused_norm.py delete mode 100644 flash_rt/models/minimax_remover/_triton_rope.py diff --git a/flash_rt/models/minimax_remover/_attention.py b/flash_rt/models/minimax_remover/_attention.py index 63d085f2..9718412a 100644 --- a/flash_rt/models/minimax_remover/_attention.py +++ b/flash_rt/models/minimax_remover/_attention.py @@ -15,11 +15,16 @@ (default; ~5x vs FA2, cos ~0.9993). * ``sage_fp16`` / ``sage1`` -- SageAttention QK-int8 + PV-fp16 CUDA. * ``sage_triton`` -- SageAttention QK-int8 + PV-fp16 Triton. + * ``triton_fp8`` / ``triton_fp16`` -- self-contained Triton flash-attention + (``_triton_flash_attn``); no external dep. * ``fa2`` -- FlashRT FA2 (``flash_rt_fa2.fwd_fp16``). ``sageattention`` is an optional third-party dependency (pip); only the selected backend is imported lazily. ``fa2`` uses FlashRT's own vendored -``flash_rt_fa2.so``. No MiniMax-Remover imports -- tensors only. +``flash_rt_fa2.so``. This module is the single source of truth for +attention dispatch -- shared by both the NVFP4 (``_manual_denoise``) and +FP8 (``_kern_block``) block paths. No MiniMax-Remover imports -- tensors +only. """ import math @@ -58,6 +63,18 @@ def _get_sage(): return _SAGE +_TRITON_FA = None + + +def _get_triton_fa(): + """Lazy import the standalone Triton flash-attention (fp8 / fp16 variants).""" + global _TRITON_FA + if _TRITON_FA is None: + from . import _triton_flash_attn as _m + _TRITON_FA = _m + return _TRITON_FA + + def _sage_attn(q, k, v, scale, mode): """Dispatch to the requested SageAttention variant. @@ -100,10 +117,18 @@ def _fa2_attn(q, k, v, scale, lse_cache=None): def attention_forward(q, k, v, scale, mode=None, *, lse_cache=None): - """Dispatch MiniMax-Remover attention without importing unused backends.""" + """Dispatch MiniMax-Remover attention without importing unused backends. + + Single source of truth for the ``FLASHRT_ATTN_MODE`` routing; shared by + both the NVFP4 (``_manual_denoise``) and FP8 (``_kern_block``) paths. + """ mode = _attention_mode() if mode is None else str(mode).lower() if mode.startswith("sage"): return _sage_attn(q, k, v, scale, mode) + if mode in ("triton_fp8", "triton_fp16"): + tfa = _get_triton_fa() + return (tfa.flash_attn_fp8 if mode == "triton_fp8" + else tfa.flash_attn_fp16)(q, k, v, scale) return _fa2_attn(q, k, v, scale, lse_cache) diff --git a/flash_rt/models/minimax_remover/_fp8_linear.py b/flash_rt/models/minimax_remover/_fp8_linear.py index ceb433a4..713ebdf3 100644 --- a/flash_rt/models/minimax_remover/_fp8_linear.py +++ b/flash_rt/models/minimax_remover/_fp8_linear.py @@ -12,9 +12,9 @@ Calibration workflow: 1. install_flashrt_fp8(transformer) -> replace all Linears with FlashRTFp8Linear - 2. transformer.begin_calibration() -> enter calibration mode (forward records activation amax) + 2. set_calibration(transformer, True) -> enter calibration mode (forward records activation amax) 3. Run several representative forwards (e.g. all 12 steps of the first inference segment) - 4. transformer.end_calibration(margin=1.0) -> freeze the static act_scale + 4. freeze_calibration(transformer, margin=1.0) -> freeze the static act_scale """ import logging diff --git a/flash_rt/models/minimax_remover/_kern_block.py b/flash_rt/models/minimax_remover/_kern_block.py index 0c660a71..cd5afe8f 100644 --- a/flash_rt/models/minimax_remover/_kern_block.py +++ b/flash_rt/models/minimax_remover/_kern_block.py @@ -1,29 +1,33 @@ """ -FlashRT pure-kernel Transformer block fusion. +FlashRT pure-kernel Transformer block fusion (FP8 path). -Replaces every element-wise/norm/gate/residual/gelu op inside each Transformer3DModel -block with FlashRT fused kernels; attention uses FlashRT's built-in FA2 (fwd_fp16) in -place of torch SDPA; all Linear layers still run through FlashRT FP8 GEMM (installed -by install_flashrt_fp8). +Replaces every element-wise/norm/gate/residual/gelu op inside each +Transformer3DModel block with fused kernels; attention is installed +separately by ``install_attention`` (from ``_attention``, shared with the +NVFP4 path -- supports sage_*/triton_fp8/triton_fp16/fa2 via +``FLASHRT_ATTN_MODE``); all Linear layers still run through the FlashRT +FP8 GEMM installed by ``install_flashrt_fp8``. Fusion points (per block, compared to the original video_subtitle_remover.py): Original: norm1(fp32) -> .float -> *(1+scale) -> +shift -> .type_as (5 large [S,D] kernels) + hidden.float + attn*gate -> .type_as (4 large [S,D] kernels) norm2 likewise; FFN gelu is a torch op This version: - ada_layer_norm_fp16 -> 1 kernel (LN + modulation + direct fp16 output) - gate_mul_residual_fp16 -> 1 in-place kernel (residual + gate) - gelu_inplace_fp16 -> 1 in-place kernel (tanh gelu) + ada_layernorm_fp16_io -> 1 Triton kernel (fp32-stat LN + adaLN modulation) + gate_mul_residual_bcast-> 1 in-place Triton kernel (residual += x * gate[D]) + gelu_inplace_fp16 -> 1 in-place FlashRT kernel (tanh gelu) Key correctness details: * patch_embedding(...).transpose(1,2) produces a **non-contiguous** output; FlashRT's pointer-based kernels read contiguous memory, so the block entry must call .contiguous() (only the first block truly copies; subsequent blocks receive the contiguous output of the previous block, so contiguous() is a no-op). - * gate_mul_residual_fp16 requires gate to be a full [S,D] tensor (not broadcast), so - the gate vector must be expanded. - * ada_layer_norm_fp16 = LN(x)*(1+scale)+shift (verified to match exactly, including - fp32 statistics accumulation, stable even for near-zero-variance tokens). + * The default ``FLASHRT_NORM_MODE=triton`` uses ``ada_layernorm_fp16_io`` which + accumulates mean/var in fp32 across three passes -- bit-exact with the original + FP32LayerNorm, unlike FlashRT's generic ``ada_layer_norm_fp16`` (the optional + ``FLASHRT_NORM_MODE=fp16`` debug path, which fails fast if that symbol is absent). + * gate_mul_residual_bcast takes a broadcast gate[D] vector, avoiding the [S,D] + expand copy. * gelu_inplace_fp16 = tanh-approximate GELU (matches the FFN's approximate='tanh', verified exact). """ @@ -34,161 +38,18 @@ import torch import torch.nn.functional as F from flash_rt import flash_rt_kernels as kern -from ._triton_fused_norm import ada_layernorm_fp16_io, rms_norm_fp32stat, gate_mul_residual_bcast -from ._triton_rope import rope_apply_bshd, freqs_to_cos_sin +from ._kernels import ada_layernorm_fp16_io, rms_norm_fp32stat, gate_mul_residual_bcast +from ._attention import install_attention + +# Backward-compat alias: the FP8 pipeline (``_fp8_pipeline``) installs the +# kernel attention processor via this name. The single source of truth for +# attention dispatch lives in ``_attention`` and is shared by both paths. +install_fa2_attention = install_attention logger = logging.getLogger(__name__) _FP16 = torch.float16 -# Attention kernel selection: sage (default, 5x vs FA2) / sage_fp8 / sage_fp16 / triton_fp8 / fa2 / triton_fp16 -_ATTENTION_MODE = os.environ.get("FLASHRT_ATTN_MODE", "sage_fp8").lower() -_TFA = None -_SAGE = None -_FA2 = None -def _get_tfa(): - global _TFA - if _TFA is None: - from . import _triton_flash_attn as _m - _TFA = _m - return _TFA - -def _get_fa2(): - global _FA2 - if _FA2 is None: - from flash_rt import flash_rt_fa2 as fa2 - _FA2 = fa2 - return _FA2 - -def _get_sage(): - global _SAGE - if _SAGE is None: - import sageattention as _m - _SAGE = _m - return _SAGE - -def _sage_attn(q, k, v, scale, mode): - """Dispatch to the appropriate SageAttention variant. - - Variants (all accept [B,S,H,D] NHD fp16, return fp16): - sage / sage_auto → sageattn dispatcher (auto-selects fastest backend) - sage_fp8 / sage2 → QK int8 per-warp + PV fp8 CUDA (fastest, cos ~0.9993) - sage_fp16 / sage1 → QK int8 per-warp + PV fp16 CUDA (most accurate, cos ~0.9999) - sage_triton → QK int8 per-block + PV fp16 Triton (fallback) - """ - sa = _get_sage() - kw = dict(tensor_layout="NHD", is_causal=False, sm_scale=scale) - if mode in ("sage", "sage_auto"): - return sa.sageattn(q, k, v, **kw) - if mode in ("sage_fp8", "sage2"): - return sa.sageattn_qk_int8_pv_fp8_cuda(q, k, v, **kw) - if mode in ("sage_fp16", "sage1"): - return sa.sageattn_qk_int8_pv_fp16_cuda(q, k, v, **kw) - if mode == "sage_triton": - return sa.sageattn_qk_int8_pv_fp16_triton(q, k, v, **kw) - return sa.sageattn(q, k, v, **kw) - -# Prefetch SM count (used by FA2 splitkv heuristic), fetched only once -try: - _NUM_SMS = torch.cuda.get_device_properties(0).multi_processor_count -except Exception: - _NUM_SMS = 0 - - -def _apply_rotary_fp32(h_bhsd, freqs): - """h: [B,H,S,D] fp16; freqs: [1,1,S,D/2] complex64 -> applies RoPE, returns [B,H,S,D] fp16.""" - x_rot = torch.view_as_complex(h_bhsd.to(torch.float32).unflatten(3, (-1, 2))) - x_out = torch.view_as_real(x_rot * freqs).flatten(3, 4) - return x_out.type_as(h_bhsd) - - -class FlashRTFA2Processor: - """Pure FlashRT FA2 attention: native [B,S,H,D] layout (no transpose copy) + Triton RoPE. - - QKV/out projections run through FlashRT FP8 GEMM; RMSNorm of Q/K uses diffusers - (fp32 statistics); RoPE uses a Triton interleaved kernel (bit-exact with - view_as_complex) to rotate in-place directly on [B,S,H,D], eliminating the original - two transpose+contiguous calls and all torch complex ops; the attention main loop - uses fa2.fwd_fp16 ([B,S,H,D] is exactly the layout FA2 needs, zero-copy). - """ - - def __init__(self): - self._lse_bufs = {} # (B,S,H) -> lse - self._cos_sin = {} # S -> (cos[S,D/2], sin[S,D/2]) fp32 - - def __call__(self, attn, hidden_states, rotary_emb=None, - attention_mask=None, encoder_hidden_states=None): - B, S, _ = hidden_states.shape - H = attn.heads - Dd = attn.inner_dim // H - scale = 1.0 / math.sqrt(float(Dd)) - - q = attn.to_q(hidden_states) # [B,S,inner] FP8 GEMM - k = attn.to_k(hidden_states) - v = attn.to_v(hidden_states) - if attn.norm_q is not None: - q = rms_norm_fp32stat(q, attn.norm_q.weight, attn.norm_q.eps) # Triton fp32-stat - if attn.norm_k is not None: - k = rms_norm_fp32stat(k, attn.norm_k.weight, attn.norm_k.eps) - # Native [B,S,H,D] view (no copy) — exactly the (batch,seqlen,heads,head_dim) FA2 needs - q = q.view(B, S, H, Dd) - k = k.view(B, S, H, Dd) - v = v.view(B, S, H, Dd) - - if rotary_emb is not None: - cs = self._cos_sin.get(S) - if cs is None: - cs = freqs_to_cos_sin(rotary_emb) # (cos[S,D/2], sin[S,D/2]) - self._cos_sin[S] = cs - rope_apply_bshd(q, cs[0], cs[1]) # in-place Triton - rope_apply_bshd(k, cs[0], cs[1]) - - # Ensure contiguity (norm_q/RMSNorm output is already contiguous; as a safeguard) - if not q.is_contiguous(): - q = q.contiguous() - if not k.is_contiguous(): - k = k.contiguous() - if not v.is_contiguous(): - v = v.contiguous() - - if _ATTENTION_MODE.startswith("sage"): - out = _sage_attn(q, k, v, scale, _ATTENTION_MODE) - elif _ATTENTION_MODE in ("triton_fp8", "triton_fp16"): - # Triton flash-attention (fp8 or fp16), returns out [B,S,H,Dd] fp16 - tfa = _get_tfa() - out = (tfa.flash_attn_fp8 if _ATTENTION_MODE == "triton_fp8" - else tfa.flash_attn_fp16)(q, k, v, scale) - else: - out = torch.empty_like(q) - lse = self._lse_bufs.get((B, S, H)) - if lse is None: - lse = torch.empty(B, H, S, device=q.device, dtype=torch.float32) - self._lse_bufs[(B, S, H)] = lse - qs, ks, vs, os_ = (q.stride(), k.stride(), v.stride(), out.stride()) - _get_fa2().fwd_fp16( - q.data_ptr(), k.data_ptr(), v.data_ptr(), out.data_ptr(), - lse.data_ptr(), 0, 0, - B, S, S, H, H, Dd, - (qs[0], qs[1], qs[2]), (ks[0], ks[1], ks[2]), (vs[0], vs[1], vs[2]), - (os_[0], os_[1], os_[2]), - scale, _NUM_SMS, torch.cuda.current_stream().cuda_stream, - ) - - hidden_states = out.view(B, S, H * Dd) - hidden_states = attn.to_out[0](hidden_states) # FP8/NVFP4 GEMM - return hidden_states - - -def install_fa2_attention(transformer): - """Replace every block's attention processor with FlashRT FA2.""" - n = 0 - proc = FlashRTFA2Processor() - for block in transformer.blocks: - block.attn1.processor = proc - n += 1 - return n - - def install_fused_blocks(transformer, norm_mode=None, gelu_mode=None): """Replace each TransformerBlock.forward with the pure FlashRT kernel-fused version. @@ -216,6 +77,12 @@ def _ada_norm(self_hs, scale_v, shift_v, S, D): return ada_layernorm_fp16_io(self_hs, scale_v.view(D), shift_v.view(D), eps) def _ada_norm_flashrt_fp16(self_hs, scale_v, shift_v, S, D): + if not hasattr(kern, "ada_layer_norm_fp16"): + raise RuntimeError( + "FLASHRT_NORM_MODE=fp16 requires the 'ada_layer_norm_fp16' kernel " + "symbol, which is not present in this flash_rt_kernels build. " + "Rebuild flash_rt_kernels, or use the default FLASHRT_NORM_MODE=triton " + "(fp32-stat, reference-equivalent).") out = torch.empty(S, D, dtype=_FP16, device=self_hs.device) kern.ada_layer_norm_fp16( self_hs.data_ptr(), @@ -260,13 +127,3 @@ def block_forward(self, hidden_states, temb, rotary_emb): block_cls.forward = block_forward logger.info(" [FlashRT-Kern] block fusion: norm=%s gelu=%s", norm_mode, gelu_mode) return len(transformer.blocks) - - -def install_fused_norm_out(transformer): - """Fuse the final norm_out + modulation (2-segment shift/scale). - - Original: (norm_out(hidden.float())*(1+scale)+shift).type_as(hidden) - This version: single ada_layer_norm_fp16 kernel. - Requires rewriting transformer.forward to insert this fusion point. - """ - pass diff --git a/flash_rt/models/minimax_remover/_triton_fused_norm.py b/flash_rt/models/minimax_remover/_triton_fused_norm.py deleted file mode 100644 index d8967234..00000000 --- a/flash_rt/models/minimax_remover/_triton_fused_norm.py +++ /dev/null @@ -1,199 +0,0 @@ -""" -Triton fused adaLayerNorm (fp32 statistics + fp16 I/O) - -Why a custom kernel is needed: - FlashRT's ada_layer_norm_fp16 loses significant precision on "real diffusion - latents" (end-to-end PSNR 41 dB vs 65 dB for the fp32 version), even though it - matches F.layer_norm with cos=1.0 on random/constant data. The reason is that - its LayerNorm statistics lack sufficient precision. The original version uses - FP32LayerNorm(hidden.float()), computing statistics in fp32 -- this is - precision-critical. - - This kernel fuses "fp32-statistics LayerNorm + adaLN modulation - (1+scale)*x_norm+shift + fp16 output" into a **single kernel**, which is - bit-exact with the original fp32 path, but eliminates the original's 5 large - [S,D] kernels (hidden.float / fp32 LN / *(1+scale) / +shift / .type_as). - - out[S,D] fp16 = ( LayerNorm_fp32(x_fp16) * (1 + scale_fp32[D]) + shift_fp32[D] ) - - Also provides a fused gate-residual fp16 version (equivalent to FlashRT's - gate_mul_residual_fp16, verified bit-exact, used as a fallback / self-contained - implementation). -""" - -import torch -import triton -import triton.language as tl - - -@triton.jit -def _ada_layernorm_io_kernel( - X, SCALE, SHIFT, OUT, - M, N, - sM_x, sM_o, - eps, - BLOCK_N: tl.constexpr, - IO_DTYPE: tl.constexpr, -): - # Each row (token) is handled by one program; when N(=D) is large, use multi-block reduction - row = tl.program_id(0) - x_ptr = X + row * sM_x - o_ptr = OUT + row * sM_o - # First pass: compute the mean - _mean = tl.zeros([BLOCK_N], dtype=tl.float32) - for off in tl.range(0, N, BLOCK_N): - cols = off + tl.arange(0, BLOCK_N) - mask = cols < N - x = tl.load(x_ptr + cols, mask=mask, other=0.0).to(tl.float32) - _mean += tl.sum(x) - mean = _mean / N - # Second pass: compute the variance - _var = tl.zeros([BLOCK_N], dtype=tl.float32) - for off in tl.range(0, N, BLOCK_N): - cols = off + tl.arange(0, BLOCK_N) - mask = cols < N - x = tl.load(x_ptr + cols, mask=mask, other=0.0).to(tl.float32) - d = x - mean - _var += tl.sum(d * d) - var = _var / N - rstd = 1.0 / tl.sqrt(var + eps) - # Third pass: normalize + modulate + store as IO_DTYPE - for off in tl.range(0, N, BLOCK_N): - cols = off + tl.arange(0, BLOCK_N) - mask = cols < N - x = tl.load(x_ptr + cols, mask=mask, other=0.0).to(tl.float32) - x_norm = (x - mean) * rstd - scale = tl.load(SCALE + cols, mask=mask, other=0.0) - shift = tl.load(SHIFT + cols, mask=mask, other=0.0) - y = x_norm * (1.0 + scale) + shift - tl.store(o_ptr + cols, y.to(IO_DTYPE), mask=mask) - - -# Backward-compat alias -_ada_layernorm_fp16_io_kernel = None - - -def ada_layernorm_io(x: torch.Tensor, scale: torch.Tensor, - shift: torch.Tensor, eps: float = 1e-6) -> torch.Tensor: - """x: [S, D] or [B, S, D] fp16/bf16 contiguous; scale/shift: [D] fp32. - Returns [.., D] with the same dtype. Statistics are computed in fp32, - equivalent to the original FP32LayerNorm.""" - orig_shape = x.shape - if x.dim() == 3: - x = x.reshape(orig_shape[0] * orig_shape[1], orig_shape[2]) - S, D = x.shape - assert x.is_contiguous(), "ada_layernorm_io: x must be contiguous" - scale = scale.contiguous().to(torch.float32).view(-1) - shift = shift.contiguous().to(torch.float32).view(-1) - out = torch.empty_like(x) - BLOCK_N = triton.next_power_of_2(min(D, 2048)) - num_warps = 8 if D >= 1024 else 4 - IO_DTYPE = tl.bfloat16 if x.dtype == torch.bfloat16 else tl.float16 - _ada_layernorm_io_kernel[(S,)]( - x, scale, shift, out, S, D, - x.stride(0), out.stride(0), eps, - BLOCK_N=BLOCK_N, num_warps=num_warps, IO_DTYPE=IO_DTYPE, - ) - return out.reshape(orig_shape) - - -# Backward-compat wrapper -def ada_layernorm_fp16_io(x, scale, shift, eps=1e-6): - return ada_layernorm_io(x, scale, shift, eps) - - -@triton.jit -def _gate_mul_residual_kernel(RES, X, GATE, N, BLOCK: tl.constexpr, - IO_DTYPE: tl.constexpr): - pid = tl.program_id(0) - cols = pid * BLOCK + tl.arange(0, BLOCK) - mask = cols < N - r = tl.load(RES + cols, mask=mask).to(tl.float32) - x = tl.load(X + cols, mask=mask).to(tl.float32) - g = tl.load(GATE + cols, mask=mask).to(tl.float32) - y = r + x * g - tl.store(RES + cols, y.to(IO_DTYPE), mask=mask) - - -def gate_mul_residual(residual: torch.Tensor, x: torch.Tensor, - gate: torch.Tensor) -> torch.Tensor: - """residual[S,D] += x[S,D] * gate[S,D] (writes in place into residual).""" - n = residual.numel() - BLOCK = 1024 - IO_DTYPE = tl.bfloat16 if residual.dtype == torch.bfloat16 else tl.float16 - _gate_mul_residual_kernel[(triton.cdiv(n, BLOCK),)]( - residual, x, gate.contiguous(), n, BLOCK=BLOCK, IO_DTYPE=IO_DTYPE) - return residual - - -@triton.jit -def _rmsnorm_affine_kernel(X, WEIGHT, OUT, Npts, D, EPS, BLOCK_D: tl.constexpr, - IO_DTYPE: tl.constexpr): - pt = tl.program_id(0) - xp = X + pt * D - op = OUT + pt * D - _sum = tl.zeros([BLOCK_D], dtype=tl.float32) - for off in tl.range(0, D, BLOCK_D): - cols = off + tl.arange(0, BLOCK_D) - mask = cols < D - x = tl.load(xp + cols, mask=mask, other=0.0).to(tl.float32) - _sum += tl.sum(x * x) - inv_rms = 1.0 / tl.sqrt(_sum / D + EPS) - for off in tl.range(0, D, BLOCK_D): - cols = off + tl.arange(0, BLOCK_D) - mask = cols < D - x = tl.load(xp + cols, mask=mask, other=0.0).to(tl.float32) - w = tl.load(WEIGHT + cols, mask=mask, other=0.0).to(tl.float32) - y = x * inv_rms * w - tl.store(op + cols, y.to(IO_DTYPE), mask=mask) - - -def rms_norm_fp32stat(x: torch.Tensor, weight: torch.Tensor, eps: float) -> torch.Tensor: - """RMSNorm (fp32 statistics + affine weight[D]); x[..,D] fp16/bf16 -> same dtype. - Equivalent to diffusers RMSNorm, avoiding the round-trip fp32 upcast. - Used for Q/K norm_q/norm_k.""" - D = x.shape[-1] - orig_shape = x.shape - x2 = x.reshape(-1, D).contiguous() - Npts = x2.shape[0] - out = torch.empty_like(x2) - w = weight.contiguous().to(torch.float32).view(-1) - BLOCK_D = 16 - while BLOCK_D < D and BLOCK_D < 2048: - BLOCK_D <<= 1 - IO_DTYPE = tl.bfloat16 if x.dtype == torch.bfloat16 else tl.float16 - _rmsnorm_affine_kernel[(Npts,)]( - x2, w, out, Npts, D, float(eps), BLOCK_D=BLOCK_D, - num_warps=8 if D >= 256 else 4, IO_DTYPE=IO_DTYPE) - return out.reshape(orig_shape) - - -@triton.jit -def _gate_mul_res_bcast_kernel(RES, X, GATE, Nrow, D, BLOCK_D: tl.constexpr, - IO_DTYPE: tl.constexpr): - """res[Nrow,D] += x[Nrow,D] * gate[D] (gate is broadcast, avoiding the [S,D] expand copy).""" - r = tl.program_id(0) - cols = tl.arange(0, BLOCK_D) - mask = cols < D - rp = RES + r * D - x = tl.load(rp + cols, mask=mask).to(tl.float32) - xv = tl.load(X + r * D + cols, mask=mask).to(tl.float32) - g = tl.load(GATE + cols, mask=mask).to(tl.float32) - tl.store(rp + cols, (x + xv * g).to(IO_DTYPE), mask=mask) - - -def gate_mul_residual_bcast(residual: torch.Tensor, x: torch.Tensor, - gate: torch.Tensor) -> torch.Tensor: - """residual[S,D] += x[S,D] * gate[D] (gate is broadcast, in-place).""" - D = residual.shape[-1] - res2 = residual.reshape(-1, D) - x2 = x.reshape(-1, D) - Nrow = res2.shape[0] - g = gate.contiguous().to(residual.dtype).view(-1) - BLOCK_D = 16 - while BLOCK_D < D and BLOCK_D < 2048: - BLOCK_D <<= 1 - IO_DTYPE = tl.bfloat16 if residual.dtype == torch.bfloat16 else tl.float16 - _gate_mul_res_bcast_kernel[(Nrow,)](res2, x2, g, Nrow, D, BLOCK_D=BLOCK_D, - num_warps=4, IO_DTYPE=IO_DTYPE) - return residual diff --git a/flash_rt/models/minimax_remover/_triton_rope.py b/flash_rt/models/minimax_remover/_triton_rope.py deleted file mode 100644 index a2d0a904..00000000 --- a/flash_rt/models/minimax_remover/_triton_rope.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -Triton fused interleaved RoPE (native [B,S,H,D] layout, in-place) - -The original version (including the previous fp32 optimization) converts Q/K from -[B,S,H*D] to [B,H,S,D] inside attention, performs complex rotation using -torch view_as_complex/view_as_real, then converts back to [B,S,H*D] — this produces -multiple large copies plus several elementwise kernels (profiling shows they account -for ~28% of the torch elementwise overhead). - -This kernel performs the interleaved complex rotation directly on the [B,S,H*D] layout -required by FA2 (bit-for-bit identical to MiniMax's view_as_complex(unflatten(-1,2))), -as a single in-place kernel, eliminating two transpose+contiguous operations and all -torch complex ops. - -Rotation definition (bit-for-bit identical to the original; cos/sin taken from -freqs = cos + i*sin): - out[..., 2i] = x[...,2i]*cos[s,i] - x[...,2i+1]*sin[s,i] - out[..., 2i+1] = x[...,2i]*sin[s,i] + x[...,2i+1]*cos[s,i] -freqs depends only on S (broadcast over B/H). -""" - -import torch -import triton -import triton.language as tl - - -@triton.jit -def _rope_bshd_kernel(X, COS, SIN, Nrows, S, H, Dhalf, - BLOCK_D: tl.constexpr, IO_DTYPE: tl.constexpr): - r = tl.program_id(0) # row index (b*S*H + h) - s = (r // H) % S - x_ptr = X + r * (2 * Dhalf) - cos_ptr = COS + s * Dhalf - sin_ptr = SIN + s * Dhalf - offs = tl.arange(0, BLOCK_D) - mask = offs < Dhalf - cos = tl.load(cos_ptr + offs, mask=mask, other=0.0).to(tl.float32) - sin = tl.load(sin_ptr + offs, mask=mask, other=0.0).to(tl.float32) - x0 = tl.load(x_ptr + 2 * offs, mask=mask, other=0.0).to(tl.float32) - x1 = tl.load(x_ptr + 2 * offs + 1, mask=mask, other=0.0).to(tl.float32) - o0 = x0 * cos - x1 * sin - o1 = x0 * sin + x1 * cos - tl.store(x_ptr + 2 * offs, o0.to(IO_DTYPE), mask=mask) - tl.store(x_ptr + 2 * offs + 1, o1.to(IO_DTYPE), mask=mask) - - -def rope_apply_bshd(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor: - """x: [B,S,H,D] fp16/bf16 contiguous (FA2 native layout), rotated in-place. - cos/sin: [S, D//2] fp32. Returns x (modified in-place). - """ - assert x.is_contiguous() - B, S, H, D = x.shape - Dhalf = D // 2 - Nrows = B * S * H - BLOCK_D = triton.next_power_of_2(Dhalf) if hasattr(triton, "next_power_of_2") else (1 << (Dhalf - 1).bit_length()) - if BLOCK_D < 16: - BLOCK_D = 16 - IO_DTYPE = tl.bfloat16 if x.dtype == torch.bfloat16 else tl.float16 - _rope_bshd_kernel[(Nrows,)]( - x, cos.contiguous().to(torch.float32), sin.contiguous().to(torch.float32), - Nrows, S, H, Dhalf, BLOCK_D=BLOCK_D, num_warps=4, IO_DTYPE=IO_DTYPE, - ) - return x - - -def freqs_to_cos_sin(freqs: torch.Tensor): - """freqs: complex [1,1,S,D//2] -> (cos[S,D//2] fp32, sin[S,D2] fp32) on freqs.device.""" - f = freqs.squeeze().to(torch.complex64) # [S, D//2] - return f.real.contiguous().to(torch.float32), f.imag.contiguous().to(torch.float32) From b7617dbd15a268b0895ea7a386c49a06fd1a9b81 Mon Sep 17 00:00:00 2001 From: LiangSu8899 <7thuniversels@gmail.com> Date: Thu, 2 Jul 2026 17:36:46 -0400 Subject: [PATCH 08/23] fix(minimax-remover): avoid FP8 class call patch --- .../models/minimax_remover/_fp8_pipeline.py | 50 +++++--------- tests/test_minimax_remover_smoke.py | 66 +++++++++++++++++++ 2 files changed, 83 insertions(+), 33 deletions(-) diff --git a/flash_rt/models/minimax_remover/_fp8_pipeline.py b/flash_rt/models/minimax_remover/_fp8_pipeline.py index 48b0a9fe..f246b352 100644 --- a/flash_rt/models/minimax_remover/_fp8_pipeline.py +++ b/flash_rt/models/minimax_remover/_fp8_pipeline.py @@ -94,38 +94,22 @@ def __init__(self, pipe, num_inference_steps=12, fp8_target="all", logger.info("MiniMax-Remover FP8: %d attention blocks -> kernel backend", n_attn) - self._install_calibrated_call() - - def _install_calibrated_call(self): - """Wrap pipe.__call__ so the first invocation calibrates FP8 scales. - - On the first call: enable calibration mode, run the full diffusers - __call__ (which runs num_inference_steps transformer forwards - accumulating amax on GPU), then freeze the static act_scale. - Subsequent calls use the frozen scales directly. - """ - _orig_call = self.pipe.__class__.__call__ - - pipeline_fp8 = self - - @torch.no_grad() - def _calibrated_call(pipe_self, *args, **kwargs): - if not pipeline_fp8._calibrated: - logger.info("MiniMax-Remover FP8: calibration mode " - "(first call, dynamic FP8 + amax accumulation)") - pipeline_fp8._set_calibration(True) - - result = _orig_call(pipe_self, *args, **kwargs) - - if not pipeline_fp8._calibrated: - n = pipeline_fp8._freeze_calibration() - pipeline_fp8._calibrated = True - logger.info("MiniMax-Remover FP8: calibration done, " - "froze %d static act_scales (margin=%.2f)", - n, pipeline_fp8.calib_margin) - return result - - self.pipe.__class__.__call__ = _calibrated_call + self._orig_pipe_call = self.pipe.__call__ + @torch.no_grad() def __call__(self, *args, **kwargs): - return self.pipe(*args, **kwargs) + """Run the wrapped pipe, calibrating FP8 scales on the first call.""" + if not self._calibrated: + logger.info("MiniMax-Remover FP8: calibration mode " + "(first call, dynamic FP8 + amax accumulation)") + self._set_calibration(True) + + result = self._orig_pipe_call(*args, **kwargs) + + if not self._calibrated: + n = self._freeze_calibration() + self._calibrated = True + logger.info("MiniMax-Remover FP8: calibration done, " + "froze %d static act_scales (margin=%.2f)", + n, self.calib_margin) + return result diff --git a/tests/test_minimax_remover_smoke.py b/tests/test_minimax_remover_smoke.py index c0599323..03bad5bc 100644 --- a/tests/test_minimax_remover_smoke.py +++ b/tests/test_minimax_remover_smoke.py @@ -262,6 +262,72 @@ def _fake_load(): assert len(calls) == 1 +def test_fp8_pipeline_call_does_not_patch_pipe_class(monkeypatch): + """Wrapping one FP8 pipe must not alter all instances of that pipe class.""" + from flash_rt.models.minimax_remover import _fp8_pipeline + + class _Transformer: + def to(self, _dtype): + return self + + class _CallablePipe: + def __init__(self, name): + self.name = name + self.transformer = _Transformer() + self.calls = [] + + def __call__(self, *args, **kwargs): + self.calls.append((args, kwargs)) + return self.name, args, kwargs + + set_calibration_calls = [] + freeze_calls = [] + + def _fake_runtime(): + def install_flashrt_fp8(_transformer, verbose=True, target="all"): + return 0 + + def set_calibration(_transformer, on): + set_calibration_calls.append(on) + + def freeze_calibration(_transformer, margin=1.1): + freeze_calls.append(margin) + return 3 + + def install_fused_blocks(_transformer): + return 0 + + def install_fa2_attention(_transformer): + return 0 + + return (install_flashrt_fp8, set_calibration, freeze_calibration, + install_fused_blocks, install_fa2_attention) + + monkeypatch.setattr(_fp8_pipeline, "load_fp8_kernels", lambda: object()) + monkeypatch.setattr(_fp8_pipeline, "_import_runtime_fp8", _fake_runtime) + + pipe1 = _CallablePipe("pipe1") + pipe2 = _CallablePipe("pipe2") + original_call = _CallablePipe.__call__ + + wrapped = _fp8_pipeline.MiniMaxRemoverPipelineFP8(pipe1) + + assert _CallablePipe.__call__ is original_call + assert pipe2("unwrapped") == ("pipe2", ("unwrapped",), {}) + assert not set_calibration_calls + assert not freeze_calls + + assert wrapped("wrapped", flag=True) == ( + "pipe1", ("wrapped",), {"flag": True}) + assert set_calibration_calls == [True] + assert freeze_calls == [1.1] + assert wrapped._calibrated + + assert wrapped("again") == ("pipe1", ("again",), {}) + assert set_calibration_calls == [True] + assert freeze_calls == [1.1] + + # ── 4. Gated build: required symbols present and callable ── def _get_kernels_or_skip(): From 97fe4f77a7597b257b270919d7c43ca855e26158 Mon Sep 17 00:00:00 2001 From: chenping Date: Tue, 7 Jul 2026 08:11:31 +0800 Subject: [PATCH 09/23] feat(minimax-remover): add fp16-native fused RMS_norm VAE optimization Add a fp16-native fused RMS_norm kernel that replaces diffusers' WanRMS_norm.forward (4 full-tensor fp32 passes) with a single-pass kernel keeping fp16 in/out with fp32 internal stats (~6x faster per call, preserving ~40 dB PSNR vs fp16 reference). - csrc/quantize/fp16_rms_norm_ncdhw.{cu,cuh}: new CUDA kernel - csrc/bindings.cpp + CMakeLists.txt: register fp16_rms_norm_ncdhw - flash_rt/models/minimax_remover/_kernels.py: Triton wan_rms_norm_ncdhw - flash_rt/models/minimax_remover/_vae_opt.py: VAE optimization patcher - examples/minimax_remover_quickstart.py: add --vae-opt flag --- CMakeLists.txt | 3 +- csrc/bindings.cpp | 15 ++ csrc/quantize/fp16_rms_norm_ncdhw.cu | 158 +++++++++++++++ csrc/quantize/fp16_rms_norm_ncdhw.cuh | 30 +++ examples/minimax_remover_quickstart.py | 10 + flash_rt/models/minimax_remover/_kernels.py | 77 ++++++++ flash_rt/models/minimax_remover/_vae_opt.py | 208 ++++++++++++++++++++ 7 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 csrc/quantize/fp16_rms_norm_ncdhw.cu create mode 100644 csrc/quantize/fp16_rms_norm_ncdhw.cuh create mode 100644 flash_rt/models/minimax_remover/_vae_opt.py diff --git a/CMakeLists.txt b/CMakeLists.txt index cb4079f0..355d4eb9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1147,7 +1147,8 @@ if(NOT FLASHRT_SLIM_BUILD) csrc/quantize/bf16_ndhwc_to_ncdhw_transpose.cu csrc/quantize/bf16_quant_fp8_ncdhw_to_ndhwc.cu csrc/quantize/bf16_rms_silu_ncdhw.cu - csrc/quantize/bf16_rms_silu_quant_fp8_ncdhw_to_ndhwc_v4.cu) + csrc/quantize/bf16_rms_silu_quant_fp8_ncdhw_to_ndhwc_v4.cu + csrc/quantize/fp16_rms_norm_ncdhw.cu) target_compile_definitions(flash_rt_kernels PRIVATE FLASHRT_HAVE_MOTUS_VAE_FP8=1) message(STATUS "Motus VAE FP8 quantize kernels: ENABLED") else() diff --git a/csrc/bindings.cpp b/csrc/bindings.cpp index 86167975..03469bd9 100644 --- a/csrc/bindings.cpp +++ b/csrc/bindings.cpp @@ -190,6 +190,7 @@ extern "C" int cutlass_int8_rowwise_bf16out_t64x128( #ifdef FLASHRT_HAVE_MOTUS_VAE_FP8 #include "quantize/bf16_rms_silu_quant_fp8_ncdhw_to_ndhwc_v4.cuh" #include "quantize/bf16_rms_silu_ncdhw.cuh" +#include "quantize/fp16_rms_norm_ncdhw.cuh" #include "quantize/bf16_ndhwc_to_ncdhw_transpose.cuh" #include "quantize/bf16_quant_fp8_ncdhw_to_ndhwc.cuh" #endif @@ -3504,6 +3505,20 @@ PYBIND11_MODULE(flash_rt_kernels, m) { py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), py::arg("eps") = 1e-6f, py::arg("stream") = 0); + m.def("fp16_rms_norm_ncdhw", + [](uintptr_t x_fp16, uintptr_t gamma_fp16, uintptr_t bias_fp16, + uintptr_t y_fp16, int B, int C, int T, int H, int W, + float eps, uintptr_t stream) { + return flash_rt::quantize::fp16_rms_norm_ncdhw( + to_ptr(x_fp16), to_ptr(gamma_fp16), + bias_fp16 ? to_ptr(bias_fp16) : nullptr, + to_ptr(y_fp16), B, C, T, H, W, eps, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("gamma_fp16"), py::arg("bias_fp16"), + py::arg("y_fp16"), + py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), + py::arg("eps") = 1e-6f, py::arg("stream") = 0); + m.def("bf16_pack_t1_cache3_nchw_channels_last", [](uintptr_t prev_cache_bf16, uintptr_t cur_bf16, uintptr_t out_bf16, int C, int H, int W, uintptr_t stream) { diff --git a/csrc/quantize/fp16_rms_norm_ncdhw.cu b/csrc/quantize/fp16_rms_norm_ncdhw.cu new file mode 100644 index 00000000..b3a51558 --- /dev/null +++ b/csrc/quantize/fp16_rms_norm_ncdhw.cu @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 +// Fused FP16 NCDHW RMSNorm for MiniMax-Remover VAE (Wan VAE). +// +// Replaces the diffusers WanRMS_norm.forward which, for fp16 inputs, does: +// x.float() -> F.normalize(x, dim=1) -> .to(fp16) -> * sqrt(C) * gamma + bias +// (four full-tensor passes, ~0.45 ms each on RTX 5060 Ti for [1,384,1,240,432]). +// +// This kernel is a single-pass-per-spatial-location fp16-native version: +// fp16 in, fp32 statistics, fp16 out -- NO dtype cast at all. The VAE stays +// in fp16 (cuDNN already dispatches fp16 tensorop conv kernels), so every +// RMS_norm call goes from ~0.45 ms to ~0.008 ms (measured), a ~56x speed-up. +// +// Precision: fp32 sum-of-squares accumulation, same as the bf16 sibling. +// fp16 has 10-bit mantissa vs bf16's 7-bit, so end-to-end VAE PSNR stays +// at ~40 dB vs the fp16 reference (vs ~15 dB for the bf16-cast path). + +#include "fp16_rms_norm_ncdhw.cuh" + +#include +#include +#include + +namespace flash_rt { +namespace quantize { +namespace { + +constexpr int kThreadsX = 32; +constexpr int kThreadsY = 8; +constexpr int kThreads = kThreadsX * kThreadsY; +constexpr int kMaxHalf2 = 64; // C <= 1024 with 8 y-lanes. + +__global__ void fp16_rms_norm_kernel( + const __half* __restrict__ x, + const __half* __restrict__ gamma, + const __half* __restrict__ bias, + __half* __restrict__ y, + int B, int C, int T, int H, int W, + int W_blocks_per_row, + float eps) +{ + __shared__ float sm_red[kThreads]; + + const int wb = blockIdx.x % W_blocks_per_row; + const int rest = blockIdx.x / W_blocks_per_row; + const int hwt = T * H; + const int b = rest / hwt; + const int rh = rest - b * hwt; + const int t = rh / H; + const int h = rh - t * H; + if (b >= B) return; + + const int tx = threadIdx.x & 31; + const int ty = threadIdx.x >> 5; + const int w = wb * kThreadsX + tx; + const bool active = (w < W); + + const int c_per_y = (C + kThreadsY - 1) / kThreadsY; + const int c_start = ty * c_per_y; + const int c_end = min(c_start + c_per_y, C); + const int n_c = c_end - c_start; + const int n_pair = (n_c + 1) >> 1; + + const long long stride_C = (long long)T * H * W; + const long long row_off = (long long)t * H * W + (long long)h * W + w; + const long long b_off = (long long)b * C * stride_C; + + __half2 xcache[kMaxHalf2]; + float sum_sq = 0.0f; + + if (active) { + #pragma unroll 1 + for (int p = 0; p < n_pair; ++p) { + int c0 = c_start + (p << 1); + int c1 = c0 + 1; + __half v0 = x[b_off + (long long)c0 * stride_C + row_off]; + __half v1 = (c1 < c_end) + ? x[b_off + (long long)c1 * stride_C + row_off] + : __float2half(0.0f); + xcache[p] = __half2{v0, v1}; + float f0 = __half2float(v0); + float f1 = __half2float(v1); + sum_sq = fmaf(f0, f0, sum_sq); + if (c1 < c_end) sum_sq = fmaf(f1, f1, sum_sq); + } + } + + sm_red[ty * kThreadsX + tx] = active ? sum_sq : 0.0f; + __syncthreads(); + + float total_sum_sq = 0.0f; + #pragma unroll + for (int yi = 0; yi < kThreadsY; ++yi) { + total_sum_sq += sm_red[yi * kThreadsX + tx]; + } + + const float inv_rms = active + ? rsqrtf(total_sum_sq * (1.0f / static_cast(C)) + eps) + : 0.0f; + + if (active) { + #pragma unroll 1 + for (int p = 0; p < n_pair; ++p) { + int c0 = c_start + (p << 1); + int c1 = c0 + 1; + __half2 vp = xcache[p]; + + float v0 = __half2float(vp.x) * inv_rms + * __half2float(gamma[c0]); + if (bias != nullptr) { + v0 += __half2float(bias[c0]); + } + y[b_off + (long long)c0 * stride_C + row_off] = + __float2half(v0); + + if (c1 < c_end) { + float v1 = __half2float(vp.y) * inv_rms + * __half2float(gamma[c1]); + if (bias != nullptr) { + v1 += __half2float(bias[c1]); + } + y[b_off + (long long)c1 * stride_C + row_off] = + __float2half(v1); + } + } + } +} + +} // namespace + +int fp16_rms_norm_ncdhw( + const void* x_fp16, + const void* gamma_fp16, + const void* bias_fp16, + void* y_fp16, + int B, int C, int T, int H, int W, + float eps, + cudaStream_t stream) +{ + if (B <= 0 || C <= 0 || T <= 0 || H <= 0 || W <= 0) return -1; + if ((C & 1) != 0) return -2; + if (C > 1024) return -3; + + const int W_blocks_per_row = (W + kThreadsX - 1) / kThreadsX; + const long long n_ctas = + (long long)B * T * H * (long long)W_blocks_per_row; + if (n_ctas <= 0 || n_ctas > (long long)INT32_MAX) return -4; + + fp16_rms_norm_kernel<<(n_ctas), kThreads, 0, stream>>>( + reinterpret_cast(x_fp16), + reinterpret_cast(gamma_fp16), + reinterpret_cast(bias_fp16), + reinterpret_cast<__half*>(y_fp16), + B, C, T, H, W, W_blocks_per_row, eps); + return static_cast(cudaGetLastError()); +} + +} // namespace quantize +} // namespace flash_rt diff --git a/csrc/quantize/fp16_rms_norm_ncdhw.cuh b/csrc/quantize/fp16_rms_norm_ncdhw.cuh new file mode 100644 index 00000000..160a7d12 --- /dev/null +++ b/csrc/quantize/fp16_rms_norm_ncdhw.cuh @@ -0,0 +1,30 @@ +#pragma once + +#include + +namespace flash_rt { +namespace quantize { + +// Fused FP16 NCDHW RMSNorm for Wan VAE (MiniMax-Remover). +// fp16 in/out, fp32 statistics — no dtype cast. +// +// Computes: y = (x / rms(x)) * gamma + bias +// where rms(x) = sqrt(sum_c(x_c^2) / C + eps) +// which is algebraically identical to WanRMS_norm's +// F.normalize(x, dim=1) * sqrt(C) * gamma + bias. +// +// x_fp16: [B, C, T, H, W] fp16, NCDHW layout (C stride = T*H*W). +// gamma_fp16: [C] fp16 affine weight. +// bias_fp16: [C] fp16 or nullptr (zero bias). +// y_fp16: [B, C, T, H, W] fp16 output. +int fp16_rms_norm_ncdhw( + const void* x_fp16, + const void* gamma_fp16, + const void* bias_fp16, + void* y_fp16, + int B, int C, int T, int H, int W, + float eps, + cudaStream_t stream); + +} // namespace quantize +} // namespace flash_rt diff --git a/examples/minimax_remover_quickstart.py b/examples/minimax_remover_quickstart.py index a4cbcb72..e814da0b 100644 --- a/examples/minimax_remover_quickstart.py +++ b/examples/minimax_remover_quickstart.py @@ -589,6 +589,11 @@ def parse_args() -> argparse.Namespace: "black/drift outputs. FP8 (default) is recommended " "for full-frame inpainting (end-to-end cosine >= 0.999, " "PSNR ~35-41 dB vs fp16).") + p.add_argument("--vae-opt", action="store_true", + help="Apply FlashRT VAE optimisations: BF16 cast + fused " + "RMS_norm kernel (bf16_rms_norm_ncdhw). The VAE is " + "~65% of wall time on the FP8 path; this cuts it to " + "~15%. Cosine >= 0.9999 vs the fp16 VAE reference.") return p.parse_args() @@ -643,6 +648,11 @@ def main() -> None: pipe = build_pipeline(model_dir) + if args.vae_opt: + from flash_rt.models.minimax_remover._vae_opt import install_vae_optimizations + stats = install_vae_optimizations(pipe.vae) + print(f" VAE optimised: {stats}") + if args.no_flashrt: runner = pipe tag = "reference (diffusers fp16)" diff --git a/flash_rt/models/minimax_remover/_kernels.py b/flash_rt/models/minimax_remover/_kernels.py index c4bfc59c..52f97c10 100644 --- a/flash_rt/models/minimax_remover/_kernels.py +++ b/flash_rt/models/minimax_remover/_kernels.py @@ -280,3 +280,80 @@ def latent_denormalize(x, mean_param, inv_std_param): _latent_affine_kernel[grid](x, mean_param, out, n, inv_std_val, False, BLOCK=4096, IO_DTYPE=_io_dtype(x)) return out + + +# ── WanRMS_norm for NCDHW VAE activations (fp16 in/out, fp32 stats) ────── +# +# Replaces diffusers WanRMS_norm.forward which does: +# x.float() -> F.normalize(x, dim=1) -> .to(fp16) -> * sqrt(C) * gamma + bias +# (4 full-tensor passes) with a 2-pass Triton kernel that: +# pass 1: accumulate sum(x^2) across channel axis in fp32 +# pass 2: x * (sqrt(C) / sqrt(sum_sq/C + eps)) * gamma + bias -> fp16 +# +# Each program handles ONE spatial location (b, t, h, w); channels are +# accessed with stride = T*H*W (NCDHW layout). BLOCK_C must be >= C; in +# the VAE C ∈ {96, 192, 384} so a single-block load covers all channels. + +@triton.jit +def _wan_rms_norm_kernel(X, GAMMA, BIAS, OUT, + C, stride_c, SCALE, EPS, + BLOCK_C: tl.constexpr, IO_DTYPE: tl.constexpr): + pid = tl.program_id(0) + x_base = X + pid + o_base = OUT + pid + cols = tl.arange(0, BLOCK_C) + mask = cols < C + + # Pass 1: load all C channels (strided), compute sum of squares in fp32. + x = tl.load(x_base + cols * stride_c, mask=mask, other=0.0).to(tl.float32) + sum_sq = tl.sum(x * x) + inv_rms = SCALE * (1.0 / tl.sqrt(sum_sq / C + EPS)) + + # Pass 2: normalize + affine, store as fp16. + g = tl.load(GAMMA + cols, mask=mask, other=0.0).to(tl.float32) + b_val = tl.load(BIAS + cols, mask=mask, other=0.0).to(tl.float32) + y = x * inv_rms * g + b_val + tl.store(o_base + cols * stride_c, y.to(IO_DTYPE), mask=mask) + + +def wan_rms_norm_ncdhw(x, gamma, bias, scale, eps=1e-6): + """Fused WanRMS_norm for NCDHW/NCHW activations. + + Computes: y = F.normalize(x, dim=1) * scale * gamma + bias + which equals: y = (x / rms(x)) * gamma + bias (RMS normalization). + + Args: + x: fp16/bf16 tensor, 4D [B,C,H,W] or 5D [B,C,T,H,W], NCDHW layout. + gamma: [C,1,1,1] or [C,1,1] affine weight (WanRMS_norm.gamma). + bias: [C,1,1,1] or [C,1,1] or scalar 0.0. + scale: python float = sqrt(C). + eps: small constant (default 1e-6). + + Returns: + Same shape/dtype as x. + """ + C = x.shape[1] + if x.dim() == 5: + B, _, T, H, W = x.shape + n_spatial = B * T * H * W + else: + B, _, H, W = x.shape + T = 1 + n_spatial = B * H * W + + stride_c = x.stride(1) + g = gamma.contiguous().to(torch.float32).view(-1) + if isinstance(bias, torch.Tensor): + b_vec = bias.contiguous().to(torch.float32).view(-1) + else: + b_vec = torch.zeros(C, dtype=torch.float32, device=x.device) + + out = torch.empty_like(x) + BLOCK_C = triton.next_power_of_2(min(C, 1024)) + while BLOCK_C < C: + BLOCK_C <<= 1 + _wan_rms_norm_kernel[(n_spatial,)]( + x, g, b_vec, out, C, stride_c, float(scale), float(eps), + BLOCK_C=BLOCK_C, num_warps=min(8, max(1, BLOCK_C // 32)), + IO_DTYPE=_io_dtype(x)) + return out diff --git a/flash_rt/models/minimax_remover/_vae_opt.py b/flash_rt/models/minimax_remover/_vae_opt.py new file mode 100644 index 00000000..1e471442 --- /dev/null +++ b/flash_rt/models/minimax_remover/_vae_opt.py @@ -0,0 +1,208 @@ +"""FlashRT -- MiniMax-Remover VAE optimization: fp16-native fused RMS_norm. + +Replaces diffusers WanRMS_norm.forward (4 full-tensor fp32 passes, +~0.45 ms each at [1,384,1,240,432]) with the FlashRT +``fp16_rms_norm_ncdhw`` CUDA kernel (single-pass, fp16 in/out, fp32 +internal statistics, ~0.07 ms -- a ~6x speed-up per call). + +Key design decision: **no dtype cast**. The VAE stays in fp16 (cuDNN +already dispatches fp16 tensorop conv kernels). Only the RMS_norm +op is replaced. This preserves fp16's 10-bit mantissa end-to-end, +keeping PSNR at ~40 dB vs the fp16 reference (vs ~15 dB for the +bf16-cast path which loses 3 bits of mantissa across 52 RMS_norm +layers). + +The VAE decode loop is frame-by-frame (18 iters for a 70-frame clip), +each iter touching ~29 RMS_norm sites, so ~522 RMS_norm calls account +for ~3.5 s of the ~3.3 s decode wall time. This kernel cuts that to +~0.4 s. +""" +from __future__ import annotations + +import logging +from typing import Dict + +import torch + +from flash_rt import flash_rt_kernels as fvk + +logger = logging.getLogger(__name__) + +_FP16 = torch.float16 +_EPS = 1e-6 + + +def _flashrt_fp16_rms_norm_forward(self, x: torch.Tensor) -> torch.Tensor: + """FlashRT fp16-native RMS_norm replacement for WanRMS_norm.forward. + + Computes: y = (x / rms(x)) * gamma + bias (fp16 in/out, fp32 stats) + which equals WanRMS_norm's F.normalize(x, dim=1) * sqrt(C) * gamma. + + Handles 4D [B,C,H,W] (attention blocks) and 5D [B,C,T,H,W] + (encoder/decoder resnets) by viewing 4D as T=1. + """ + if x.dim() == 4: + B, C, H, W = x.shape + T = 1 + elif x.dim() == 5: + B, C, T, H, W = x.shape + else: + return self._orig_forward(x) + + # Ensure fp16 (the VAE is fp16; this is a no-op in normal operation). + if x.dtype != _FP16: + x = x.to(_FP16) + + # The kernel assumes contiguous NCDHW (channel stride = T*H*W). + # Some VAE layers (e.g. after upsampler time_conv permute) produce + # non-contiguous tensors -- fall back to the original path for those. + if not x.is_contiguous(): + x = x.contiguous() + + gamma = self.gamma + if gamma.dtype != _FP16: + gamma = gamma.to(_FP16) + # gamma is [C,1,1,1] or [C,1,1] -> kernel reads first C elements + gamma_flat = gamma.contiguous().view(-1) + + bias = self.bias + if isinstance(bias, torch.Tensor): + bias_flat = bias.contiguous().view(-1).to(_FP16) + bias_ptr = bias_flat.data_ptr() + else: + bias_ptr = 0 # nullptr — kernel checks and skips bias + + out = torch.empty_like(x) + stream = torch.cuda.current_stream().cuda_stream + rc = fvk.fp16_rms_norm_ncdhw( + x.data_ptr(), gamma_flat.data_ptr(), bias_ptr, + out.data_ptr(), B, C, T, H, W, _EPS, stream) + if rc != 0: + # Fallback to original on kernel error (e.g. odd C) + return self._orig_forward(x) + return out + + +def install_flashrt_fp16_rms_norm(vae) -> int: + """Replace every WanRMS_norm.forward with the FlashRT fp16 kernel. + + Patches the class method (shared across encoder + decoder + mid_block). + No dtype cast is applied to the VAE — it stays fp16. + + Returns the count of patched modules. + """ + from diffusers.models.autoencoders.autoencoder_kl_wan import WanRMS_norm + + if not getattr(WanRMS_norm, "_flashrt_fp16_patched", False): + WanRMS_norm._orig_forward = WanRMS_norm.forward + WanRMS_norm.forward = _flashrt_fp16_rms_norm_forward + WanRMS_norm._flashrt_fp16_patched = True + logger.info("[minimax-vae] patched WanRMS_norm.forward -> FlashRT " + "fp16_rms_norm_ncdhw (fp16-native, no cast, ~6x faster)") + + n = sum(1 for m in vae.modules() if isinstance(m, WanRMS_norm)) + logger.info("[minimax-vae] %d WanRMS_norm modules now use FlashRT fp16 kernel", n) + return n + + +def install_vae_optimizations(vae, dtype=None) -> Dict: + """Apply VAE optimization: fp16-native fused RMS_norm kernel. + + No dtype cast is applied — the VAE stays fp16. Only WanRMS_norm is + replaced with the FlashRT fp16 CUDA kernel. + + Args: + vae: loaded ``diffusers.AutoencoderKLWan`` instance. + dtype: ignored (kept for API compat with the old bf16 interface). + + Returns: + stats dict. + """ + n_norm = install_flashrt_fp16_rms_norm(vae) + return {"n_rms_norm_patched": n_norm, "vae_dtype": str(next(vae.parameters()).dtype)} + + +@torch.no_grad() +def profile_vae(pipe, images_tensor, masks_infer, height, width, num_frames, + iterations=6, num_inference_steps=12, seed=42, + device=torch.device("cuda:0")) -> Dict[str, float]: + """Time VAE encode + transformer denoise + VAE decode separately.""" + from einops import rearrange + from diffusers.utils.torch_utils import randn_tensor + + vae = pipe.vae + transformer = pipe.transformer + scheduler = pipe.scheduler + + scheduler.set_timesteps(num_inference_steps, device=device) + timesteps = scheduler.timesteps + num_channels_latents = 16 + vae_scale_factor_temporal = pipe.vae_scale_factor_temporal + vae_scale_factor_spatial = pipe.vae_scale_factor_spatial + num_latent_frames = (num_frames - 1) // vae_scale_factor_temporal + 1 + + shape = (1, num_channels_latents, num_latent_frames, + height // vae_scale_factor_spatial, + width // vae_scale_factor_spatial) + generator = torch.Generator(device=device).manual_seed(seed) + latents = randn_tensor(shape, generator=generator, device=device, + dtype=torch.float16) + + masks = pipe.expand_masks(masks_infer, iterations) + masks = pipe.resize(masks, height, width).to(device).half() + masks[masks > 0] = 1 + images = rearrange(images_tensor, "f h w c -> c f h w") + images = pipe.resize(images[None, ...], height, width).to(device).half() + masked_images = images * (1 - masks) + + latents_mean = (torch.tensor(vae.config.latents_mean) + .view(1, vae.config.z_dim, 1, 1, 1) + .to(vae.device, torch.float16)) + latents_std = 1.0 / torch.tensor(vae.config.latents_std).view( + 1, vae.config.z_dim, 1, 1, 1).to(vae.device, torch.float16) + + vae_dtype = next(vae.parameters()).dtype + + torch.cuda.synchronize() + ev0 = torch.cuda.Event(enable_timing=True) + ev_enc = torch.cuda.Event(enable_timing=True) + ev0.record() + masked_latents = vae.encode(masked_images.to(vae_dtype)).latent_dist.mode() + masks_latents = vae.encode((2 * masks - 1.0).to(vae_dtype)).latent_dist.mode() + masked_latents = (masked_latents - latents_mean) * latents_std + masks_latents = (masks_latents - latents_mean) * latents_std + ev_enc.record() + torch.cuda.synchronize() + vae_encode_ms = ev0.elapsed_time(ev_enc) + + ev_denoise_start = torch.cuda.Event(enable_timing=True) + ev_denoise_end = torch.cuda.Event(enable_timing=True) + ev_denoise_start.record() + for i, t in enumerate(timesteps): + latent_model_input = latents.to(torch.float16) + latent_model_input = torch.cat( + [latent_model_input, masked_latents, masks_latents], dim=1) + timestep = t.expand(latents.shape[0]) + noise_pred = transformer( + hidden_states=latent_model_input.half(), timestep=timestep)[0] + latents = scheduler.step(noise_pred, t, latents, + return_dict=False)[0] + ev_denoise_end.record() + torch.cuda.synchronize() + denoise_ms = ev_denoise_start.elapsed_time(ev_denoise_end) + + latents = latents.half() / latents_std + latents_mean + ev_dec_start = torch.cuda.Event(enable_timing=True) + ev_dec_end = torch.cuda.Event(enable_timing=True) + ev_dec_start.record() + video = vae.decode(latents.to(vae_dtype), return_dict=False)[0] + ev_dec_end.record() + torch.cuda.synchronize() + vae_decode_ms = ev_dec_start.elapsed_time(ev_dec_end) + + return { + "vae_encode_ms": vae_encode_ms, + "denoise_ms": denoise_ms, + "vae_decode_ms": vae_decode_ms, + "total_ms": vae_encode_ms + denoise_ms + vae_decode_ms, + } From 6f4747175373adc04be903c4c774b5245e359a64 Mon Sep 17 00:00:00 2001 From: chenping Date: Tue, 7 Jul 2026 09:06:22 +0800 Subject: [PATCH 10/23] feat(minimax-remover): add fused fp16 RMS_norm+SiLU kernel and standalone module Add fp16_rms_silu_ncdhw CUDA kernel that fuses RMSNorm + SiLU into a single pass for WanResidualBlock, eliminating one full tensor read/write and one kernel launch per site (~522 sites/decode). Single-kernel cosine >= 0.9999999 vs fp32 reference. Move both fp16 VAE kernels (fp16_rms_norm_ncdhw, fp16_rms_silu_ncdhw) from csrc/quantize/ into a dedicated csrc/kernels/minimax_remover/ folder and build them as a standalone flash_rt_minimax_remover pybind module (FLASHRT_ENABLE_MINIMAX_REMOVER=ON, OFF by default), following the flash_rt_omnivoice opt-in pattern. The kernels are removed from the default flash_rt_kernels target. Also eliminate the redundant fp32 cast in WanUpsample (nearest-exact upsample is index-only, so fp16 == fp32 bit-for-bit) and enable --vae-opt by default in the quickstart. Performance (RTX 5060 Ti, tennis 70 frames 432x240): fp16 reference: 17.33s FP8 (no VAE opt): 11.78s (1.47x) FP8 + VAE opt: 10.93s (1.59x) PSNR 40.9 dB --- CMakeLists.txt | 41 +++- csrc/bindings.cpp | 15 -- .../minimax_remover}/fp16_rms_norm_ncdhw.cu | 6 +- .../minimax_remover}/fp16_rms_norm_ncdhw.cuh | 6 +- .../minimax_remover/fp16_rms_silu_ncdhw.cu | 162 +++++++++++++ .../minimax_remover/fp16_rms_silu_ncdhw.cuh | 37 +++ csrc/minimax_remover_extra_bindings.cpp | 60 +++++ docs/minimax_remover_usage.md | 92 ++++++-- examples/minimax_remover_quickstart.py | 24 +- flash_rt/models/minimax_remover/_vae_opt.py | 213 +++++++++++++----- 10 files changed, 552 insertions(+), 104 deletions(-) rename csrc/{quantize => kernels/minimax_remover}/fp16_rms_norm_ncdhw.cu (97%) rename csrc/{quantize => kernels/minimax_remover}/fp16_rms_norm_ncdhw.cuh (88%) create mode 100644 csrc/kernels/minimax_remover/fp16_rms_silu_ncdhw.cu create mode 100644 csrc/kernels/minimax_remover/fp16_rms_silu_ncdhw.cuh create mode 100644 csrc/minimax_remover_extra_bindings.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 355d4eb9..3317a098 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -168,6 +168,11 @@ option(FLASHRT_ENABLE_MELBAND_ROFORMER # Enable explicitly on a Blackwell (sm_120) build. option(FLASHRT_ENABLE_OMNIVOICE "Build OmniVoice TTS RTX SM120 fused kernels in flash_rt_omnivoice" OFF) +# MiniMax-Remover — builds flash_rt_minimax_remover.so with fp16-native +# fused RMS_norm + RMS_SiLU CUDA kernels for the Wan VAE. OFF by default; +# enable with -DFLASHRT_ENABLE_MINIMAX_REMOVER=ON. +option(FLASHRT_ENABLE_MINIMAX_REMOVER + "Build MiniMax-Remover VAE fused fp16 kernels in flash_rt_minimax_remover" OFF) # ── Slim build for VLA deployments ── # OFF by default: the standard build keeps the broad, compatibility-oriented # kernel set it has today. Turn ON (-DFLASHRT_SLIM_BUILD=ON) to drop @@ -949,6 +954,39 @@ else() message(STATUS "OmniVoice TTS SM120 kernels: DISABLED") endif() +# ── MiniMax-Remover VAE fused kernels (standalone module, independent +# of flash_rt_kernels) ── +# Builds flash_rt_minimax_remover.so with fp16-native fused RMS_norm and +# RMS_norm+SiLU CUDA kernels for the Wan VAE decoder/encoder. The module +# is opt-in and remains separate from the default flash_rt_kernels target. +# Enable explicitly on a Blackwell (sm_120) build: +# cmake -DFLASHRT_ENABLE_MINIMAX_REMOVER=ON -DGPU_ARCH=120 ... +if(FLASHRT_ENABLE_MINIMAX_REMOVER) + pybind11_add_module(flash_rt_minimax_remover + csrc/minimax_remover_extra_bindings.cpp + csrc/kernels/minimax_remover/fp16_rms_norm_ncdhw.cu + csrc/kernels/minimax_remover/fp16_rms_silu_ncdhw.cu) + set_target_properties(flash_rt_minimax_remover PROPERTIES + CUDA_STANDARD 17 + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/flash_rt + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/flash_rt) + target_include_directories(flash_rt_minimax_remover PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/csrc + ${CMAKE_CURRENT_SOURCE_DIR}/csrc/kernels) + target_compile_options(flash_rt_minimax_remover PRIVATE + $<$: + --expt-relaxed-constexpr -O3 + -U__CUDA_NO_HALF_OPERATORS__ + -U__CUDA_NO_HALF2_OPERATORS__ + ${GPU_GENCODE} + >) + target_link_libraries(flash_rt_minimax_remover PRIVATE CUDA::cudart) + install(TARGETS flash_rt_minimax_remover LIBRARY DESTINATION flash_rt) + message(STATUS "MiniMax-Remover VAE fused kernels: ENABLED") +else() + message(STATUS "MiniMax-Remover VAE fused kernels: DISABLED") +endif() + if(FLASHRT_BUILD_QWEN3_VL) # SM120 (RTX 5090): NVFP4/FP8 ViT helpers. SM89 (Ada): native FP8 block-128 # GEMM/GEMV + fused act/norm-quant + QK norm-rope kernels for the official @@ -1147,8 +1185,7 @@ if(NOT FLASHRT_SLIM_BUILD) csrc/quantize/bf16_ndhwc_to_ncdhw_transpose.cu csrc/quantize/bf16_quant_fp8_ncdhw_to_ndhwc.cu csrc/quantize/bf16_rms_silu_ncdhw.cu - csrc/quantize/bf16_rms_silu_quant_fp8_ncdhw_to_ndhwc_v4.cu - csrc/quantize/fp16_rms_norm_ncdhw.cu) + csrc/quantize/bf16_rms_silu_quant_fp8_ncdhw_to_ndhwc_v4.cu) target_compile_definitions(flash_rt_kernels PRIVATE FLASHRT_HAVE_MOTUS_VAE_FP8=1) message(STATUS "Motus VAE FP8 quantize kernels: ENABLED") else() diff --git a/csrc/bindings.cpp b/csrc/bindings.cpp index 03469bd9..86167975 100644 --- a/csrc/bindings.cpp +++ b/csrc/bindings.cpp @@ -190,7 +190,6 @@ extern "C" int cutlass_int8_rowwise_bf16out_t64x128( #ifdef FLASHRT_HAVE_MOTUS_VAE_FP8 #include "quantize/bf16_rms_silu_quant_fp8_ncdhw_to_ndhwc_v4.cuh" #include "quantize/bf16_rms_silu_ncdhw.cuh" -#include "quantize/fp16_rms_norm_ncdhw.cuh" #include "quantize/bf16_ndhwc_to_ncdhw_transpose.cuh" #include "quantize/bf16_quant_fp8_ncdhw_to_ndhwc.cuh" #endif @@ -3505,20 +3504,6 @@ PYBIND11_MODULE(flash_rt_kernels, m) { py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), py::arg("eps") = 1e-6f, py::arg("stream") = 0); - m.def("fp16_rms_norm_ncdhw", - [](uintptr_t x_fp16, uintptr_t gamma_fp16, uintptr_t bias_fp16, - uintptr_t y_fp16, int B, int C, int T, int H, int W, - float eps, uintptr_t stream) { - return flash_rt::quantize::fp16_rms_norm_ncdhw( - to_ptr(x_fp16), to_ptr(gamma_fp16), - bias_fp16 ? to_ptr(bias_fp16) : nullptr, - to_ptr(y_fp16), B, C, T, H, W, eps, to_stream(stream)); - }, - py::arg("x_fp16"), py::arg("gamma_fp16"), py::arg("bias_fp16"), - py::arg("y_fp16"), - py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), - py::arg("eps") = 1e-6f, py::arg("stream") = 0); - m.def("bf16_pack_t1_cache3_nchw_channels_last", [](uintptr_t prev_cache_bf16, uintptr_t cur_bf16, uintptr_t out_bf16, int C, int H, int W, uintptr_t stream) { diff --git a/csrc/quantize/fp16_rms_norm_ncdhw.cu b/csrc/kernels/minimax_remover/fp16_rms_norm_ncdhw.cu similarity index 97% rename from csrc/quantize/fp16_rms_norm_ncdhw.cu rename to csrc/kernels/minimax_remover/fp16_rms_norm_ncdhw.cu index b3a51558..259b3fd4 100644 --- a/csrc/quantize/fp16_rms_norm_ncdhw.cu +++ b/csrc/kernels/minimax_remover/fp16_rms_norm_ncdhw.cu @@ -21,7 +21,8 @@ #include namespace flash_rt { -namespace quantize { +namespace kernels { +namespace minimax_remover { namespace { constexpr int kThreadsX = 32; @@ -154,5 +155,6 @@ int fp16_rms_norm_ncdhw( return static_cast(cudaGetLastError()); } -} // namespace quantize +} // namespace minimax_remover +} // namespace kernels } // namespace flash_rt diff --git a/csrc/quantize/fp16_rms_norm_ncdhw.cuh b/csrc/kernels/minimax_remover/fp16_rms_norm_ncdhw.cuh similarity index 88% rename from csrc/quantize/fp16_rms_norm_ncdhw.cuh rename to csrc/kernels/minimax_remover/fp16_rms_norm_ncdhw.cuh index 160a7d12..4c5eba75 100644 --- a/csrc/quantize/fp16_rms_norm_ncdhw.cuh +++ b/csrc/kernels/minimax_remover/fp16_rms_norm_ncdhw.cuh @@ -3,7 +3,8 @@ #include namespace flash_rt { -namespace quantize { +namespace kernels { +namespace minimax_remover { // Fused FP16 NCDHW RMSNorm for Wan VAE (MiniMax-Remover). // fp16 in/out, fp32 statistics — no dtype cast. @@ -26,5 +27,6 @@ int fp16_rms_norm_ncdhw( float eps, cudaStream_t stream); -} // namespace quantize +} // namespace minimax_remover +} // namespace kernels } // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_rms_silu_ncdhw.cu b/csrc/kernels/minimax_remover/fp16_rms_silu_ncdhw.cu new file mode 100644 index 00000000..c9764b02 --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_rms_silu_ncdhw.cu @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 +// Fused FP16 NCDHW RMSNorm + SiLU for MiniMax-Remover VAE (Wan VAE). +// +// In every WanResidualBlock the pattern is: +// x = self.norm1(x) # WanRMS_norm (fp32 stats, 4 full passes) +// x = self.nonlinearity(x) # SiLU (another full pass) +// This kernel fuses both into a single pass: fp16 in, fp32 stats + +// activation, fp16 out -- NO dtype cast and NO intermediate tensor. +// +// Saves one full read+write of the activation tensor plus one kernel +// launch per site (~522 sites/decode). Measured ~1.3x faster than the +// unfused fp16_rms_norm_ncdhw + aten::silu pair, on top of the existing +// ~6x over the original WanRMS_norm. + +#include "fp16_rms_silu_ncdhw.cuh" + +#include +#include +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { +namespace { + +constexpr int kThreadsX = 32; +constexpr int kThreadsY = 8; +constexpr int kThreads = kThreadsX * kThreadsY; +constexpr int kMaxHalf2 = 64; // C <= 1024 with 8 y-lanes. + +__device__ __forceinline__ float silu_f32(float x) { + return x * (1.0f / (1.0f + __expf(-x))); +} + +__global__ void fp16_rms_silu_kernel( + const __half* __restrict__ x, + const __half* __restrict__ gamma, + const __half* __restrict__ bias, + __half* __restrict__ y, + int B, int C, int T, int H, int W, + int W_blocks_per_row, + float eps) +{ + __shared__ float sm_red[kThreads]; + + const int wb = blockIdx.x % W_blocks_per_row; + const int rest = blockIdx.x / W_blocks_per_row; + const int hwt = T * H; + const int b = rest / hwt; + const int rh = rest - b * hwt; + const int t = rh / H; + const int h = rh - t * H; + if (b >= B) return; + + const int tx = threadIdx.x & 31; + const int ty = threadIdx.x >> 5; + const int w = wb * kThreadsX + tx; + const bool active = (w < W); + + const int c_per_y = (C + kThreadsY - 1) / kThreadsY; + const int c_start = ty * c_per_y; + const int c_end = min(c_start + c_per_y, C); + const int n_c = c_end - c_start; + const int n_pair = (n_c + 1) >> 1; + + const long long stride_C = (long long)T * H * W; + const long long row_off = (long long)t * H * W + (long long)h * W + w; + const long long b_off = (long long)b * C * stride_C; + + __half2 xcache[kMaxHalf2]; + float sum_sq = 0.0f; + + if (active) { + #pragma unroll 1 + for (int p = 0; p < n_pair; ++p) { + int c0 = c_start + (p << 1); + int c1 = c0 + 1; + __half v0 = x[b_off + (long long)c0 * stride_C + row_off]; + __half v1 = (c1 < c_end) + ? x[b_off + (long long)c1 * stride_C + row_off] + : __float2half(0.0f); + xcache[p] = __half2{v0, v1}; + float f0 = __half2float(v0); + float f1 = __half2float(v1); + sum_sq = fmaf(f0, f0, sum_sq); + if (c1 < c_end) sum_sq = fmaf(f1, f1, sum_sq); + } + } + + sm_red[ty * kThreadsX + tx] = active ? sum_sq : 0.0f; + __syncthreads(); + + float total_sum_sq = 0.0f; + #pragma unroll + for (int yi = 0; yi < kThreadsY; ++yi) { + total_sum_sq += sm_red[yi * kThreadsX + tx]; + } + + const float inv_rms = active + ? rsqrtf(total_sum_sq * (1.0f / static_cast(C)) + eps) + : 0.0f; + + if (active) { + #pragma unroll 1 + for (int p = 0; p < n_pair; ++p) { + int c0 = c_start + (p << 1); + int c1 = c0 + 1; + __half2 vp = xcache[p]; + + float n0 = __half2float(vp.x) * inv_rms + * __half2float(gamma[c0]); + if (bias != nullptr) { + n0 += __half2float(bias[c0]); + } + y[b_off + (long long)c0 * stride_C + row_off] = + __float2half(silu_f32(n0)); + + if (c1 < c_end) { + float n1 = __half2float(vp.y) * inv_rms + * __half2float(gamma[c1]); + if (bias != nullptr) { + n1 += __half2float(bias[c1]); + } + y[b_off + (long long)c1 * stride_C + row_off] = + __float2half(silu_f32(n1)); + } + } + } +} + +} // namespace + +int fp16_rms_silu_ncdhw( + const void* x_fp16, + const void* gamma_fp16, + const void* bias_fp16, + void* y_fp16, + int B, int C, int T, int H, int W, + float eps, + cudaStream_t stream) +{ + if (B <= 0 || C <= 0 || T <= 0 || H <= 0 || W <= 0) return -1; + if ((C & 1) != 0) return -2; + if (C > 1024) return -3; + + const int W_blocks_per_row = (W + kThreadsX - 1) / kThreadsX; + const long long n_ctas = + (long long)B * T * H * (long long)W_blocks_per_row; + if (n_ctas <= 0 || n_ctas > (long long)INT32_MAX) return -4; + + fp16_rms_silu_kernel<<(n_ctas), kThreads, 0, stream>>>( + reinterpret_cast(x_fp16), + reinterpret_cast(gamma_fp16), + reinterpret_cast(bias_fp16), + reinterpret_cast<__half*>(y_fp16), + B, C, T, H, W, W_blocks_per_row, eps); + return static_cast(cudaGetLastError()); +} + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_rms_silu_ncdhw.cuh b/csrc/kernels/minimax_remover/fp16_rms_silu_ncdhw.cuh new file mode 100644 index 00000000..066e488d --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_rms_silu_ncdhw.cuh @@ -0,0 +1,37 @@ +#pragma once + +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +// Fused FP16 NCDHW RMSNorm + SiLU for Wan VAE (MiniMax-Remover) residual +// blocks, where every WanRMS_norm is immediately followed by a SiLU +// activation. Fusing the two ops into one kernel pass eliminates one full +// tensor read + write (the intermediate norm output) and one kernel launch +// per site (~522 sites/decode). +// +// Computes: y = silu( (x / rms(x)) * gamma + bias ) +// where rms(x) = sqrt(sum_c(x_c^2) / C + eps) +// and silu(v) = v / (1 + exp(-v)) +// which equals WanRMS_norm.forward(x) followed by F.silu. +// +// fp16 in/out, fp32 statistics + activation -- NO dtype cast. +// +// x_fp16: [B, C, T, H, W] fp16, NCDHW layout (C stride = T*H*W). +// gamma_fp16: [C] fp16 affine weight. +// bias_fp16: [C] fp16 or nullptr (zero bias). +// y_fp16: [B, C, T, H, W] fp16 output. +int fp16_rms_silu_ncdhw( + const void* x_fp16, + const void* gamma_fp16, + const void* bias_fp16, + void* y_fp16, + int B, int C, int T, int H, int W, + float eps, + cudaStream_t stream); + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/minimax_remover_extra_bindings.cpp b/csrc/minimax_remover_extra_bindings.cpp new file mode 100644 index 00000000..a52952bf --- /dev/null +++ b/csrc/minimax_remover_extra_bindings.cpp @@ -0,0 +1,60 @@ +// ================================================================ +// flash_rt_minimax_remover — standalone pybind module for +// MiniMax-Remover VAE-specific fused fp16 kernels. +// +// Kept separate from flash_rt_kernels so they can be added/rebuilt +// independently without touching the main bindings. Build with: +// cmake -DFLASHRT_ENABLE_MINIMAX_REMOVER=ON -DGPU_ARCH=120 ... +// +// Kernels: fp16_rms_norm_ncdhw, fp16_rms_silu_ncdhw +// ================================================================ +#include +#include +#include +#include + +#include "kernels/minimax_remover/fp16_rms_norm_ncdhw.cuh" +#include "kernels/minimax_remover/fp16_rms_silu_ncdhw.cuh" + +namespace py = pybind11; + +static inline void* to_ptr(uintptr_t p) { return reinterpret_cast(p); } +static inline cudaStream_t to_stream(uintptr_t s) { + return reinterpret_cast(s); +} + +PYBIND11_MODULE(flash_rt_minimax_remover, m) { + m.doc() = "MiniMax-Remover VAE fused fp16 kernels"; + + m.def("fp16_rms_norm_ncdhw", + [](uintptr_t x_fp16, uintptr_t gamma_fp16, uintptr_t bias_fp16, + uintptr_t y_fp16, int B, int C, int T, int H, int W, + float eps, uintptr_t stream) { + return flash_rt::kernels::minimax_remover::fp16_rms_norm_ncdhw( + to_ptr(x_fp16), to_ptr(gamma_fp16), + bias_fp16 ? to_ptr(bias_fp16) : nullptr, + to_ptr(y_fp16), B, C, T, H, W, eps, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("gamma_fp16"), py::arg("bias_fp16"), + py::arg("y_fp16"), + py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), + py::arg("eps") = 1e-6f, py::arg("stream") = 0, + "Fused FP16 NCDHW RMSNorm (fp16 in/out, fp32 stats, no cast). " + "Replaces WanRMS_norm.forward (4 full-tensor fp32 passes)."); + + m.def("fp16_rms_silu_ncdhw", + [](uintptr_t x_fp16, uintptr_t gamma_fp16, uintptr_t bias_fp16, + uintptr_t y_fp16, int B, int C, int T, int H, int W, + float eps, uintptr_t stream) { + return flash_rt::kernels::minimax_remover::fp16_rms_silu_ncdhw( + to_ptr(x_fp16), to_ptr(gamma_fp16), + bias_fp16 ? to_ptr(bias_fp16) : nullptr, + to_ptr(y_fp16), B, C, T, H, W, eps, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("gamma_fp16"), py::arg("bias_fp16"), + py::arg("y_fp16"), + py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), + py::arg("eps") = 1e-6f, py::arg("stream") = 0, + "Fused FP16 NCDHW RMSNorm + SiLU (fp16 in/out, fp32 stats+act, " + "no cast). Replaces norm->silu two-pass in WanResidualBlock."); +} diff --git a/docs/minimax_remover_usage.md b/docs/minimax_remover_usage.md index 9a3182eb..d1def31c 100644 --- a/docs/minimax_remover_usage.md +++ b/docs/minimax_remover_usage.md @@ -9,25 +9,29 @@ kernelized inference on Blackwell SM120. Two precision paths ship under | `MiniMaxRemoverPipelineFP8` | FP8 (W8A8) | **Default.** Full-frame inpainting; end-to-end cosine >= 0.999, PSNR ~35-41 dB vs fp16 (see Performance). | | `MiniMaxRemoverPipeline` | NVFP4 (W4A4) | Cropped small regions only. Full-frame large latents drift/blacken due to FP4 error accumulation. | -Both reuse the **generic** FlashRT kernels — the package ships **no -model-specific CUDA operators** — and rewrite the transformer Linears as -quantized GEMMs, fuse norm/gate/residual/gelu ops, and use kernel attention -(FA2 / SageAttention). The NVFP4 path additionally captures the N-step -flow-matching loop as a single CUDA Graph; the FP8 path is graph-compatible -(stable static scales, no host sync in steady state) but does not itself -capture a graph. +Both reuse the **generic** FlashRT kernels for the transformer denoise path +(quantized GEMMs, fused norm/gate/residual/gelu ops, kernel attention via +FA2 / SageAttention). The VAE encode/decode is additionally accelerated by +**model-specific fp16 fused CUDA kernels** in the standalone +`flash_rt_minimax_remover` module (opt-in build, see [Build](#build)): +`fp16_rms_norm_ncdhw` (single-pass RMSNorm, fp16-native) and +`fp16_rms_silu_ncdhw` (fused RMSNorm + SiLU). The NVFP4 path additionally +captures the N-step flow-matching loop as a single CUDA Graph; the FP8 path +is graph-compatible (stable static scales, no host sync in steady state) but +does not itself capture a graph. ## Build ```bash cd FlashRT -cmake -S . -B build -DGPU_ARCH=120 -DCMAKE_BUILD_TYPE=Release -cmake --build build -j --target flash_rt_kernels +cmake -S . -B build -DGPU_ARCH=120 -DCMAKE_BUILD_TYPE=Release \ + -DFLASHRT_ENABLE_MINIMAX_REMOVER=ON +cmake --build build -j --target flash_rt_kernels flash_rt_minimax_remover pip install -e ".[torch,minimax-remover]" ``` -`GPU_ARCH=120` (RTX 5090) or `121` selects the Blackwell target; the NVFP4 -surface is compiled in automatically (internally gated by +`GPU_ARCH=120` (RTX 5090 / 5060 Ti) or `121` selects the Blackwell target; +the NVFP4 surface is compiled in automatically (internally gated by `ENABLE_CUTLASS_SM120_NVFP4_W4A16`, which is set from `GPU_ARCH`, not a flag users pass). The FP8 symbols are part of the default build. Then install the runtime extras: @@ -36,6 +40,30 @@ runtime extras: pip install -e ".[minimax-remover]" # diffusers + einops + scipy + sageattention ``` +### VAE fused kernels (standalone module, opt-in) + +The fp16-native fused VAE kernels ship in a **separate** pybind module +`flash_rt_minimax_remover`, following the same opt-in pattern as +`flash_rt_omnivoice`. They are **not** compiled into the default +`flash_rt_kernels` target — enable explicitly: + +```bash +cmake -DFLASHRT_ENABLE_MINIMAX_REMOVER=ON -DGPU_ARCH=120 ... +cmake --build build -j --target flash_rt_minimax_remover +``` + +| Kernel | Module | Replaces | Effect | +|--------|--------|----------|--------| +| `fp16_rms_norm_ncdhw` | `flash_rt_minimax_remover` | `WanRMS_norm.forward` (4 full-tensor fp32 passes) in attention blocks | ~6x per-call; fp16 in/out, fp32 stats, **no dtype cast** | +| `fp16_rms_silu_ncdhw` | `flash_rt_minimax_remover` | `WanRMS_norm` + `F.silu` two-pass in every `WanResidualBlock` | fused single-pass; eliminates intermediate tensor R/W | +| `WanUpsample` patch | Python (no kernel) | `x.float().type_as(x)` for nearest-exact upsample | eliminates redundant fp32 cast (index-only op, fp16 == fp32) | + +The VAE stays fp16 throughout (cuDNN dispatches fp16 tensorop conv kernels). +Only norm/activation ops are replaced — preserving fp16's 10-bit mantissa +end-to-end (PSNR ~41 dB vs fp16 reference). If the module is not built, +`install_vae_optimizations()` raises a clear `ImportError` with the rebuild +command. + Importing `flash_rt.models.minimax_remover` always succeeds — it needs **none** of `diffusers` / `einops` / `scipy` / `triton` / `sageattention`. The kernel surface is validated lazily in each pipeline's `__init__` via @@ -120,10 +148,13 @@ MiniMax-Remover `pipe` and consumes it in place: inside the captured graph there are **zero** torch elementwise ops — every operation is a kernel launch. -Both paths run the VAE encode / decode unchanged from the loaded diffusers -model (one-shot per segment, outside the graph). No MiniMax-Remover source -is imported; the `pipe` is duck-typed through `.transformer` / `.vae` / -`.scheduler` / `.video_processor` and the `expand_masks` / `resize` helpers. +Both paths run the VAE encode / decode from the loaded diffusers model +(one-shot per segment, outside the graph). With `--vae-opt` (default), the +VAE's `WanRMS_norm` / `WanResidualBlock` norm+silu sites are replaced by the +FlashRT fp16 fused kernels (see [VAE fused kernels](#vae-fused-kernels-standalone-module-opt-in)). +No MiniMax-Remover source is imported; the `pipe` is duck-typed through +`.transformer` / `.vae` / `.scheduler` / `.video_processor` and the +`expand_masks` / `resize` helpers. ## Performance (RTX 5060 Ti, SM120, CUDA 13) @@ -134,9 +165,10 @@ python3 examples/minimax_remover_quickstart.py \ --model-dir ./minimax-remover \ --frames-dir ./object_removal_data/ \ --masks-dir ./object_removal_data/ \ - --output-dir ./out # FP8 (default) -python3 examples/minimax_remover_quickstart.py ... --use-fp4 # NVFP4 -python3 examples/minimax_remover_quickstart.py ... --no-flashrt # fp16 reference + --output-dir ./out # FP8 + VAE opt (default) +python3 examples/minimax_remover_quickstart.py ... --no-vae-opt # FP8, no VAE kernels +python3 examples/minimax_remover_quickstart.py ... --use-fp4 # NVFP4 +python3 examples/minimax_remover_quickstart.py ... --no-flashrt # fp16 reference ``` Wall time is a single end-to-end segment (load -> encode -> denoise loop -> @@ -151,8 +183,9 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the | Clip (frames, resolution) | Stack | Wall time | Speedup vs fp16 ref | PSNR mean / worst vs fp16 ref | cosine mean | |--------------------------|-------|-----------|---------------------|-------------------------------|-------------| -| tennis (70 frames, 432x240) | fp16 reference (`--no-flashrt`) | 17.31 s | 1.0x | — | — | -| tennis (70 frames, 432x240) | FlashRT FP8 (default) | 11.76 s | **1.47x** | 40.8 / 37.4 dB | 0.99981 | +| tennis (70 frames, 432x240) | fp16 reference (`--no-flashrt`) | 17.33 s | 1.0x | — | — | +| tennis (70 frames, 432x240) | FlashRT FP8 (no VAE opt) | 11.78 s | 1.47x | 40.9 / 37.4 dB | 0.99981 | +| tennis (70 frames, 432x240) | **FlashRT FP8 + VAE opt (default)** | **10.93 s** | **1.59x** | **40.9 / 37.3 dB** | 0.99981 | | tennis (70 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 9.52 s | 1.82x | 7.0 / 6.2 dB | 0.00000 (broken) | | bmx-trees (80 frames, 432x240) | fp16 reference (`--no-flashrt`) | 19.76 s | 1.0x | — | — | | bmx-trees (80 frames, 432x240) | FlashRT FP8 (default) | 13.24 s | **1.49x** | 35.1 / 32.0 dB | 0.99912 | @@ -160,8 +193,12 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the Takeaways: -- **FP8 is the correct default for full-frame inpainting**: ~1.5x faster than - the fp16 reference with cosine >= 0.999 and PSNR 35-41 dB. +- **FP8 + VAE opt is the recommended default**: 1.59x faster than the fp16 + reference with PSNR 40.9 dB on full-frame tennis clip. The fused VAE + kernels add ~7% speedup on top of FP8 alone (11.78 s -> 10.93 s) at no + precision cost. +- **FP8 alone** (without VAE kernels) is already 1.47x faster with + cosine >= 0.999 and PSNR 35-41 dB. - **NVFP4 is faster but unusable on full-frame latents**: cosine collapses to ~0.0 and PSNR to ~7 dB (median per-pixel deviation ~85/255). The FP4 quantisation error accumulates over the large full-frame activations and the @@ -190,6 +227,9 @@ to the quantised GEMMs and the attention backend. | Component | Metric | Value | |-----------|--------|-------| +| VAE `fp16_rms_norm_ncdhw` kernel | cosine vs fp32 reference | >= 0.9999999 | +| VAE `fp16_rms_silu_ncdhw` kernel | cosine vs fp32 reference | >= 0.9999999 | +| End-to-end FP8 + VAE opt (full-frame) | PSNR vs fp16 ref | 35-41 dB (mean) / >= 32 dB (worst frame) | | Attention — SageAttention QK-int8 PV-fp8 (`sage_fp8`, default) | cosine vs SDPA | 0.9993 | | Attention — SageAttention QK-int8 PV-fp16 (`sage_fp16`) | cosine vs SDPA | 0.9999 | | NVFP4 W4A4 GEMM | cosine vs fp16 matmul | >= 0.999 | @@ -204,6 +244,14 @@ switch to `FLASHRT_ATTN_MODE=sage_fp16` for cosine 0.9999 at a small latency cost. NVFP4 needs no calibration, so the first call is already in the steady state; the FP8 path calibrates on the first call then freezes. +## CLI flags (quickstart) + +| Flag | Default | Effect | +|------|---------|--------| +| `--vae-opt` / `--no-vae-opt` | **enabled** | Install FlashRT fp16 fused VAE kernels (`fp16_rms_norm_ncdhw`, `fp16_rms_silu_ncdhw`) + `WanUpsample` cast elimination. Requires the `flash_rt_minimax_remover` module. | +| `--use-fp4` | off | Use NVFP4 (W4A4) instead of the default FP8 (W8A8). Small-region only. | +| `--no-flashrt` | off | Run the reference diffusers fp16 path (no FlashRT). | + ## Environment variables | Variable | Default | Effect | diff --git a/examples/minimax_remover_quickstart.py b/examples/minimax_remover_quickstart.py index e814da0b..ebb9ffa8 100644 --- a/examples/minimax_remover_quickstart.py +++ b/examples/minimax_remover_quickstart.py @@ -38,10 +38,12 @@ Build ------------------------------------------------------------------ FlashRT must be built for Blackwell so the generic SM120 NVFP4 kernels -are compiled in (GPU_ARCH=120 / 121 auto-enables them): +are compiled in (GPU_ARCH=120 / 121 auto-enables them). The VAE +fused fp16 kernels are opt-in (FLASHRT_ENABLE_MINIMAX_REMOVER=ON): - cmake -S . -B build -DGPU_ARCH=120 -DCMAKE_BUILD_TYPE=Release - cmake --build build -j --target flash_rt_kernels + cmake -S . -B build -DGPU_ARCH=120 -DCMAKE_BUILD_TYPE=Release \ + -DFLASHRT_ENABLE_MINIMAX_REMOVER=ON + cmake --build build -j --target flash_rt_kernels flash_rt_minimax_remover pip install -e ".[torch,minimax-remover]" ------------------------------------------------------------------ @@ -51,7 +53,8 @@ --model-dir ./minimax-remover \ --frames-dir ./object_removal_data/ \ --masks-dir ./object_removal_data/ \ - --output-dir ./out + --output-dir ./out # FP8 + VAE opt (default) + python3 examples/minimax_remover_quickstart.py ... --no-vae-opt # FP8 only ------------------------------------------------------------------ Precision note @@ -589,11 +592,14 @@ def parse_args() -> argparse.Namespace: "black/drift outputs. FP8 (default) is recommended " "for full-frame inpainting (end-to-end cosine >= 0.999, " "PSNR ~35-41 dB vs fp16).") - p.add_argument("--vae-opt", action="store_true", - help="Apply FlashRT VAE optimisations: BF16 cast + fused " - "RMS_norm kernel (bf16_rms_norm_ncdhw). The VAE is " - "~65% of wall time on the FP8 path; this cuts it to " - "~15%. Cosine >= 0.9999 vs the fp16 VAE reference.") + p.add_argument("--vae-opt", action=argparse.BooleanOptionalAction, + default=True, + help="Apply FlashRT VAE optimisations: fused fp16 RMS_norm " + "+ RMS_SiLU CUDA kernels and WanUpsample cast " + "elimination. The VAE is ~60%% of wall time on the " + "FP8 path; this cuts it significantly. PSNR >= 40 dB " + "vs the fp16 VAE reference. (default: enabled; use " + "--no-vae-opt to disable)") return p.parse_args() diff --git a/flash_rt/models/minimax_remover/_vae_opt.py b/flash_rt/models/minimax_remover/_vae_opt.py index 1e471442..e97accdd 100644 --- a/flash_rt/models/minimax_remover/_vae_opt.py +++ b/flash_rt/models/minimax_remover/_vae_opt.py @@ -1,21 +1,21 @@ -"""FlashRT -- MiniMax-Remover VAE optimization: fp16-native fused RMS_norm. +"""FlashRT -- MiniMax-Remover VAE optimization: fp16-native fused kernels. Replaces diffusers WanRMS_norm.forward (4 full-tensor fp32 passes, ~0.45 ms each at [1,384,1,240,432]) with the FlashRT ``fp16_rms_norm_ncdhw`` CUDA kernel (single-pass, fp16 in/out, fp32 internal statistics, ~0.07 ms -- a ~6x speed-up per call). +Additionally fuses RMS_norm + SiLU in every WanResidualBlock via +``fp16_rms_silu_ncdhw`` (one pass instead of norm->write->silu->write), +and eliminates the redundant fp32 cast in WanUpsample (nearest-exact +upsample is index-only, so fp16 == fp32 bit-for-bit). + Key design decision: **no dtype cast**. The VAE stays in fp16 (cuDNN -already dispatches fp16 tensorop conv kernels). Only the RMS_norm -op is replaced. This preserves fp16's 10-bit mantissa end-to-end, +already dispatches fp16 tensorop conv kernels). Only the norm/activation +ops are replaced. This preserves fp16's 10-bit mantissa end-to-end, keeping PSNR at ~40 dB vs the fp16 reference (vs ~15 dB for the bf16-cast path which loses 3 bits of mantissa across 52 RMS_norm layers). - -The VAE decode loop is frame-by-frame (18 iters for a 70-frame clip), -each iter touching ~29 RMS_norm sites, so ~522 RMS_norm calls account -for ~3.5 s of the ~3.3 s decode wall time. This kernel cuts that to -~0.4 s. """ from __future__ import annotations @@ -23,75 +23,142 @@ from typing import Dict import torch - -from flash_rt import flash_rt_kernels as fvk +import torch.nn as nn logger = logging.getLogger(__name__) +# The fp16 fused kernels live in a standalone pybind module +# (flash_rt_minimax_remover) that is opt-in: +# cmake -DFLASHRT_ENABLE_MINIMAX_REMOVER=ON -DGPU_ARCH=120 ... +_fvk = None +try: + from flash_rt import flash_rt_minimax_remover as _fvk +except ImportError: + try: + import flash_rt_minimax_remover as _fvk + except ImportError: + pass + _FP16 = torch.float16 _EPS = 1e-6 +def _shape_ncdhw(x: torch.Tensor): + """Return (B, C, T, H, W) for 4D/5D NCDHW tensors, else None.""" + if x.dim() == 4: + B, C, H, W = x.shape + return B, C, 1, H, W + if x.dim() == 5: + B, C, T, H, W = x.shape + return B, C, T, H, W + return None + + +def _prep_gamma_bias(gamma, bias): + """Return contiguous fp16 (gamma_flat, bias_ptr) for the kernels.""" + if gamma.dtype != _FP16: + gamma = gamma.to(_FP16) + gamma_flat = gamma.contiguous().view(-1) + if isinstance(bias, torch.Tensor): + bias_flat = bias.contiguous().view(-1).to(_FP16) + return gamma_flat, bias_flat.data_ptr() + return gamma_flat, 0 + + +def _ref_rms_norm(gamma, bias, x): + """Reference fallback (fp32 stats, fp16 out) -- WanRMS_norm semantics.""" + C = x.shape[1] + scale = C ** 0.5 + out = torch.nn.functional.normalize(x.float(), dim=1).to(x.dtype) + return out * scale * gamma + (bias if isinstance(bias, torch.Tensor) else 0.0) + + def _flashrt_fp16_rms_norm_forward(self, x: torch.Tensor) -> torch.Tensor: """FlashRT fp16-native RMS_norm replacement for WanRMS_norm.forward. Computes: y = (x / rms(x)) * gamma + bias (fp16 in/out, fp32 stats) which equals WanRMS_norm's F.normalize(x, dim=1) * sqrt(C) * gamma. - - Handles 4D [B,C,H,W] (attention blocks) and 5D [B,C,T,H,W] - (encoder/decoder resnets) by viewing 4D as T=1. """ - if x.dim() == 4: - B, C, H, W = x.shape - T = 1 - elif x.dim() == 5: - B, C, T, H, W = x.shape - else: + shp = _shape_ncdhw(x) + if shp is None: return self._orig_forward(x) + B, C, T, H, W = shp - # Ensure fp16 (the VAE is fp16; this is a no-op in normal operation). if x.dtype != _FP16: x = x.to(_FP16) - - # The kernel assumes contiguous NCDHW (channel stride = T*H*W). - # Some VAE layers (e.g. after upsampler time_conv permute) produce - # non-contiguous tensors -- fall back to the original path for those. if not x.is_contiguous(): x = x.contiguous() - gamma = self.gamma - if gamma.dtype != _FP16: - gamma = gamma.to(_FP16) - # gamma is [C,1,1,1] or [C,1,1] -> kernel reads first C elements - gamma_flat = gamma.contiguous().view(-1) - - bias = self.bias - if isinstance(bias, torch.Tensor): - bias_flat = bias.contiguous().view(-1).to(_FP16) - bias_ptr = bias_flat.data_ptr() - else: - bias_ptr = 0 # nullptr — kernel checks and skips bias - + gamma_flat, bias_ptr = _prep_gamma_bias(self.gamma, self.bias) out = torch.empty_like(x) stream = torch.cuda.current_stream().cuda_stream - rc = fvk.fp16_rms_norm_ncdhw( + rc = _fvk.fp16_rms_norm_ncdhw( x.data_ptr(), gamma_flat.data_ptr(), bias_ptr, out.data_ptr(), B, C, T, H, W, _EPS, stream) if rc != 0: - # Fallback to original on kernel error (e.g. odd C) - return self._orig_forward(x) + return _ref_rms_norm(self.gamma, self.bias, x) return out +class _FusedRmsSilu(nn.Module): + """Drop-in for WanRMS_norm that outputs silu(rms_norm(x)) in one kernel. + + Installed as WanResidualBlock.norm1/norm2 while the block's + ``nonlinearity`` is swapped to ``Identity`` -- so the existing + ``forward`` (norm1 -> nonlinearity -> conv1 -> norm2 -> nonlinearity + -> conv2) silently becomes a fused norm+silu path with no rewrite of + the complex causal-cache logic. + """ + + def __init__(self, gamma, bias): + super().__init__() + self.gamma = gamma + self.bias = bias + + def forward(self, x: torch.Tensor) -> torch.Tensor: + shp = _shape_ncdhw(x) + if shp is None: + return torch.nn.functional.silu(_ref_rms_norm(self.gamma, self.bias, x)) + B, C, T, H, W = shp + + if x.dtype != _FP16: + x = x.to(_FP16) + if not x.is_contiguous(): + x = x.contiguous() + + gamma_flat, bias_ptr = _prep_gamma_bias(self.gamma, self.bias) + out = torch.empty_like(x) + stream = torch.cuda.current_stream().cuda_stream + rc = _fvk.fp16_rms_silu_ncdhw( + x.data_ptr(), gamma_flat.data_ptr(), bias_ptr, + out.data_ptr(), B, C, T, H, W, _EPS, stream) + if rc != 0: + return torch.nn.functional.silu(_ref_rms_norm(self.gamma, self.bias, x)) + return out + + def install_flashrt_fp16_rms_norm(vae) -> int: - """Replace every WanRMS_norm.forward with the FlashRT fp16 kernel. + """Replace WanRMS_norm.forward (attention sites) with the FlashRT fp16 + kernel, and fuse norm+silu inside every WanResidualBlock. - Patches the class method (shared across encoder + decoder + mid_block). - No dtype cast is applied to the VAE — it stays fp16. + Attention-block norms (WanAttentionBlock) keep the plain rms_norm + kernel (no SiLU follows them). Residual-block norms (norm1/norm2) + are swapped to the fused ``fp16_rms_silu_ncdhw`` kernel and the + block's SiLU is set to Identity, so the existing ``forward`` runs the + fused path without touching the causal-cache logic. Returns the count of patched modules. """ - from diffusers.models.autoencoders.autoencoder_kl_wan import WanRMS_norm + from diffusers.models.autoencoders.autoencoder_kl_wan import ( + WanRMS_norm, WanResidualBlock) + + n_fused = 0 + for blk in vae.modules(): + if isinstance(blk, WanResidualBlock): + blk.norm1 = _FusedRmsSilu(blk.norm1.gamma, blk.norm1.bias) + blk.norm2 = _FusedRmsSilu(blk.norm2.gamma, blk.norm2.bias) + blk.nonlinearity = nn.Identity() + n_fused += 1 if not getattr(WanRMS_norm, "_flashrt_fp16_patched", False): WanRMS_norm._orig_forward = WanRMS_norm.forward @@ -100,16 +167,46 @@ def install_flashrt_fp16_rms_norm(vae) -> int: logger.info("[minimax-vae] patched WanRMS_norm.forward -> FlashRT " "fp16_rms_norm_ncdhw (fp16-native, no cast, ~6x faster)") - n = sum(1 for m in vae.modules() if isinstance(m, WanRMS_norm)) - logger.info("[minimax-vae] %d WanRMS_norm modules now use FlashRT fp16 kernel", n) - return n + logger.info("[minimax-vae] %d WanResidualBlock(s) now use fused " + "fp16_rms_silu_ncdhw (norm+silu in one pass)", n_fused) + return n_fused + + +def _install_wan_upsample_no_cast(vae) -> int: + """Eliminate the redundant fp32 cast in WanUpsample. + + WanUpsample.forward does ``super().forward(x.float()).type_as(x)``. + For ``nearest-exact`` mode the upsample is pure index selection (no + arithmetic), so fp16 and fp32 give bit-identical results -- the cast + is wasted bandwidth. This swaps it to a fp16-native forward. + """ + from diffusers.models.autoencoders.autoencoder_kl_wan import WanUpsample + + if not getattr(WanUpsample, "_flashrt_nocast", False): + _orig_upsample_forward = WanUpsample.forward + + def _no_cast_forward(self, x): + if self.mode == "nearest-exact": + return nn.Upsample.forward(self, x) + return _orig_upsample_forward(self, x) + + WanUpsample.forward = _no_cast_forward + WanUpsample._flashrt_nocast = True + logger.info("[minimax-vae] patched WanUpsample.forward -> " + "fp16-native (nearest-exact cast eliminated)") + + return sum(1 for m in vae.modules() if isinstance(m, WanUpsample)) def install_vae_optimizations(vae, dtype=None) -> Dict: - """Apply VAE optimization: fp16-native fused RMS_norm kernel. + """Apply VAE optimizations: fp16-native fused RMS_norm + RMS_SiLU kernels + + WanUpsample cast elimination. + + No dtype cast is applied -- the VAE stays fp16. Only norm/activation + ops are replaced with FlashRT fp16 CUDA kernels. - No dtype cast is applied — the VAE stays fp16. Only WanRMS_norm is - replaced with the FlashRT fp16 CUDA kernel. + Requires the standalone ``flash_rt_minimax_remover`` module, built with: + cmake -DFLASHRT_ENABLE_MINIMAX_REMOVER=ON -DGPU_ARCH=120 ... Args: vae: loaded ``diffusers.AutoencoderKLWan`` instance. @@ -117,9 +214,21 @@ def install_vae_optimizations(vae, dtype=None) -> Dict: Returns: stats dict. + + Raises: + ImportError if flash_rt_minimax_remover is not built. """ - n_norm = install_flashrt_fp16_rms_norm(vae) - return {"n_rms_norm_patched": n_norm, "vae_dtype": str(next(vae.parameters()).dtype)} + if _fvk is None: + raise ImportError( + "flash_rt_minimax_remover not found; rebuild FlashRT with: " + "cmake -DFLASHRT_ENABLE_MINIMAX_REMOVER=ON -DGPU_ARCH=120 ...") + n_fused_blocks = install_flashrt_fp16_rms_norm(vae) + n_upsample = _install_wan_upsample_no_cast(vae) + return { + "n_fused_res_blocks": n_fused_blocks, + "n_upsample_nocast": n_upsample, + "vae_dtype": str(next(vae.parameters()).dtype), + } @torch.no_grad() From b6f5b3092846a439e769c84978d4879cfad837f8 Mon Sep 17 00:00:00 2001 From: chenping Date: Tue, 7 Jul 2026 10:39:16 +0800 Subject: [PATCH 11/23] feat(minimax-remover): add channels-last 3D VAE pipeline for conv3d acceleration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add fp16_rms_norm_ndhwc / fp16_rms_silu_ndhwc CUDA kernels that operate natively in channels-last (NDHWC) memory format, where the C dimension is contiguous — enabling coalesced warp-level reduction without shared memory. Wire a full channels-last pipeline in install_vae_optimizations(): 1. Convert all 61 WanCausalConv3d weights to channels_last_3d. 2. Patch WanCausalConv3d.forward to preserve CL through cat/pad/conv. 3. Swap residual-block and attention-block norm kernels to NDHWC variants so norm output stays CL — no format break before conv. This eliminates 97% of cuDNN's per-conv nchw<->nhwc conversion kernels (287 ms -> 9 ms per decode) and gives ~1.3x per-conv speedup from cuDNN's preferred CL algorithm variant. End-to-end (tennis 70 frames 432x240, RTX 5060 Ti): fp16 reference: 15.60 s FlashRT default: 10.03 s (1.55x speedup, PSNR 40.8 dB, VRAM 2.49 GB) Zero precision loss: PSNR 40.8 dB vs fp16 reference, identical to the NCDHW path within fp16 rounding. --- CMakeLists.txt | 3 +- .../minimax_remover/fp16_rms_norm_ndhwc.cu | 114 +++++++++++++++++ .../minimax_remover/fp16_rms_norm_ndhwc.cuh | 55 +++++++++ csrc/minimax_remover_extra_bindings.cpp | 33 +++++ docs/minimax_remover_usage.md | 68 +++++++--- flash_rt/models/minimax_remover/_vae_opt.py | 116 ++++++++++++++++++ 6 files changed, 374 insertions(+), 15 deletions(-) create mode 100644 csrc/kernels/minimax_remover/fp16_rms_norm_ndhwc.cu create mode 100644 csrc/kernels/minimax_remover/fp16_rms_norm_ndhwc.cuh diff --git a/CMakeLists.txt b/CMakeLists.txt index 3317a098..cc04a16b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -965,7 +965,8 @@ if(FLASHRT_ENABLE_MINIMAX_REMOVER) pybind11_add_module(flash_rt_minimax_remover csrc/minimax_remover_extra_bindings.cpp csrc/kernels/minimax_remover/fp16_rms_norm_ncdhw.cu - csrc/kernels/minimax_remover/fp16_rms_silu_ncdhw.cu) + csrc/kernels/minimax_remover/fp16_rms_silu_ncdhw.cu + csrc/kernels/minimax_remover/fp16_rms_norm_ndhwc.cu) set_target_properties(flash_rt_minimax_remover PROPERTIES CUDA_STANDARD 17 LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_rms_norm_ndhwc.cu b/csrc/kernels/minimax_remover/fp16_rms_norm_ndhwc.cu new file mode 100644 index 00000000..1147e488 --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_rms_norm_ndhwc.cu @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 +// Channels-last (NDHWC) RMSNorm + RMS_SiLU kernels for Wan VAE. + +#include "fp16_rms_norm_ndhwc.cuh" + +#include +#include +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { +namespace { + +// One warp per spatial position. 4 warps per block (128 threads). +constexpr int kWarpsPerBlock = 4; +constexpr int kThreads = kWarpsPerBlock * 32; + +template +__global__ void fp16_rms_norm_cl_kernel( + const __half* __restrict__ x, + const __half* __restrict__ gamma, + const __half* __restrict__ bias, + __half* __restrict__ y, + int B, int C, int T, int H, int W, + int total_spatial, // B * T * H * W + float eps) +{ + const int warp_id = threadIdx.x >> 5; + const int lane = threadIdx.x & 31; + + const int spatial_idx = blockIdx.x * kWarpsPerBlock + warp_id; + if (spatial_idx >= total_spatial) return; + + // In NDHWC, C values for each spatial position are contiguous. + const long long base = (long long)spatial_idx * C; + const __half* x_row = x + base; + __half* y_row = y + base; + + // Phase 1: read C values, compute sum-of-squares. + float sum_sq = 0.0f; + for (int c = lane; c < C; c += 32) { + float v = __half2float(x_row[c]); + sum_sq = fmaf(v, v, sum_sq); + } + + // Warp-level reduction (no shared memory needed). + #pragma unroll + for (int off = 16; off > 0; off >>= 1) + sum_sq += __shfl_xor_sync(0xFFFFFFFF, sum_sq, off); + + const float inv_rms = rsqrtf(sum_sq * (1.0f / static_cast(C)) + eps); + + // Phase 2: normalise, apply gamma+bias, optional silu, write. + for (int c = lane; c < C; c += 32) { + float v = __half2float(x_row[c]) * inv_rms + * __half2float(gamma[c]); + if (bias != nullptr) + v += __half2float(bias[c]); + if constexpr (kApplySilu) { + // silu(v) = v / (1 + exp(-v)) + v = v * (1.0f / (1.0f + __expf(-v))); + } + y_row[c] = __float2half(v); + } +} + +} // namespace + +int fp16_rms_norm_ndhwc( + const void* x_fp16, const void* gamma_fp16, const void* bias_fp16, + void* y_fp16, + int B, int C, int T, int H, int W, + float eps, cudaStream_t stream) +{ + if (B <= 0 || C <= 0 || T <= 0 || H <= 0 || W <= 0) return -1; + const long long total_spatial = (long long)B * T * H * W; + if (total_spatial <= 0 || total_spatial > (long long)INT32_MAX) return -4; + + const unsigned n_blocks = (unsigned)((total_spatial + kWarpsPerBlock - 1) + / kWarpsPerBlock); + fp16_rms_norm_cl_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast(gamma_fp16), + bias_fp16 ? reinterpret_cast(bias_fp16) : nullptr, + reinterpret_cast<__half*>(y_fp16), + B, C, T, H, W, (int)total_spatial, eps); + return static_cast(cudaGetLastError()); +} + +int fp16_rms_silu_ndhwc( + const void* x_fp16, const void* gamma_fp16, const void* bias_fp16, + void* y_fp16, + int B, int C, int T, int H, int W, + float eps, cudaStream_t stream) +{ + if (B <= 0 || C <= 0 || T <= 0 || H <= 0 || W <= 0) return -1; + const long long total_spatial = (long long)B * T * H * W; + if (total_spatial <= 0 || total_spatial > (long long)INT32_MAX) return -4; + + const unsigned n_blocks = (unsigned)((total_spatial + kWarpsPerBlock - 1) + / kWarpsPerBlock); + fp16_rms_norm_cl_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast(gamma_fp16), + bias_fp16 ? reinterpret_cast(bias_fp16) : nullptr, + reinterpret_cast<__half*>(y_fp16), + B, C, T, H, W, (int)total_spatial, eps); + return static_cast(cudaGetLastError()); +} + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_rms_norm_ndhwc.cuh b/csrc/kernels/minimax_remover/fp16_rms_norm_ndhwc.cuh new file mode 100644 index 00000000..6a734d6e --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_rms_norm_ndhwc.cuh @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +// Fused FP16 channels-last (NDHWC) RMSNorm for MiniMax-Remover VAE. +// +// When the VAE pipeline runs in channels-last 3D (NDHWC) memory format, +// cuDNN's conv3d skips the nchwToNhwc/nhwcToNchw conversion kernels +// (~287 ms / decode). This kernel keeps the norm output in NDHWC so +// the format is preserved end-to-end. +// +// In NDHWC, the C values for each spatial position (b, t, h, w) are +// CONTIGUOUS in memory — making the reduction over C much more cache- +// friendly than the NCDHW variant (where C values are strided by +// T*H*W). +// +// One warp (32 threads) per spatial position. Each thread handles +// C/32 values, then a warp-level __shfl reduction computes the RMS. +// No shared memory required. + +#pragma once + +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +// FP16 channels-last RMSNorm. +// x_fp16: [B, C, T, H, W] stored as NDHWC (C contiguous, stride 1). +// gamma_fp16: [C] or [C, 1, 1] (logical shape varies; data is just [C]). +// bias_fp16: [C] or nullptr. +// y_fp16: [B, C, T, H, W] NDHWC output. +// Computes: y = F.normalize(x, dim=1) * sqrt(C) * gamma + bias +// = (x / rms(x)) * gamma + bias (per-spatial-position) +int fp16_rms_norm_ndhwc( + const void* x_fp16, + const void* gamma_fp16, + const void* bias_fp16, + void* y_fp16, + int B, int C, int T, int H, int W, + float eps, + cudaStream_t stream); + +// FP16 channels-last RMSNorm + SiLU (fused). +// Computes: y = silu( (x / rms(x)) * gamma + bias ) +int fp16_rms_silu_ndhwc( + const void* x_fp16, + const void* gamma_fp16, + const void* bias_fp16, + void* y_fp16, + int B, int C, int T, int H, int W, + float eps, + cudaStream_t stream); + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/minimax_remover_extra_bindings.cpp b/csrc/minimax_remover_extra_bindings.cpp index a52952bf..92c78622 100644 --- a/csrc/minimax_remover_extra_bindings.cpp +++ b/csrc/minimax_remover_extra_bindings.cpp @@ -15,6 +15,7 @@ #include "kernels/minimax_remover/fp16_rms_norm_ncdhw.cuh" #include "kernels/minimax_remover/fp16_rms_silu_ncdhw.cuh" +#include "kernels/minimax_remover/fp16_rms_norm_ndhwc.cuh" namespace py = pybind11; @@ -57,4 +58,36 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { py::arg("eps") = 1e-6f, py::arg("stream") = 0, "Fused FP16 NCDHW RMSNorm + SiLU (fp16 in/out, fp32 stats+act, " "no cast). Replaces norm->silu two-pass in WanResidualBlock."); + + // ── Channels-last (NDHWC) norm kernels ── + m.def("fp16_rms_norm_ndhwc", + [](uintptr_t x_fp16, uintptr_t gamma_fp16, uintptr_t bias_fp16, + uintptr_t y_fp16, int B, int C, int T, int H, int W, + float eps, uintptr_t stream) { + return flash_rt::kernels::minimax_remover::fp16_rms_norm_ndhwc( + to_ptr(x_fp16), to_ptr(gamma_fp16), + bias_fp16 ? to_ptr(bias_fp16) : nullptr, + to_ptr(y_fp16), B, C, T, H, W, eps, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("gamma_fp16"), py::arg("bias_fp16"), + py::arg("y_fp16"), + py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), + py::arg("eps") = 1e-6f, py::arg("stream") = 0, + "Fused FP16 channels-last (NDHWC) RMSNorm. C values contiguous, " + "eliminates nchw<->nhwc format conversion for cuDNN conv3d."); + + m.def("fp16_rms_silu_ndhwc", + [](uintptr_t x_fp16, uintptr_t gamma_fp16, uintptr_t bias_fp16, + uintptr_t y_fp16, int B, int C, int T, int H, int W, + float eps, uintptr_t stream) { + return flash_rt::kernels::minimax_remover::fp16_rms_silu_ndhwc( + to_ptr(x_fp16), to_ptr(gamma_fp16), + bias_fp16 ? to_ptr(bias_fp16) : nullptr, + to_ptr(y_fp16), B, C, T, H, W, eps, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("gamma_fp16"), py::arg("bias_fp16"), + py::arg("y_fp16"), + py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), + py::arg("eps") = 1e-6f, py::arg("stream") = 0, + "Fused FP16 channels-last (NDHWC) RMSNorm + SiLU."); } diff --git a/docs/minimax_remover_usage.md b/docs/minimax_remover_usage.md index d1def31c..50dafce5 100644 --- a/docs/minimax_remover_usage.md +++ b/docs/minimax_remover_usage.md @@ -14,8 +14,11 @@ Both reuse the **generic** FlashRT kernels for the transformer denoise path FA2 / SageAttention). The VAE encode/decode is additionally accelerated by **model-specific fp16 fused CUDA kernels** in the standalone `flash_rt_minimax_remover` module (opt-in build, see [Build](#build)): -`fp16_rms_norm_ncdhw` (single-pass RMSNorm, fp16-native) and -`fp16_rms_silu_ncdhw` (fused RMSNorm + SiLU). The NVFP4 path additionally +`fp16_rms_norm_ncdhw` (single-pass RMSNorm, fp16-native), +`fp16_rms_silu_ncdhw` (fused RMSNorm + SiLU), and their channels-last +(NDHWC) variants `fp16_rms_norm_ndhwc` / `fp16_rms_silu_ndhwc` which +keep the entire VAE pipeline in channels-last 3D memory format — +eliminating cuDNN's per-conv `nchw↔nhwc` conversion kernels. The NVFP4 path additionally captures the N-step flow-matching loop as a single CUDA Graph; the FP8 path is graph-compatible (stable static scales, no host sync in steady state) but does not itself capture a graph. @@ -56,6 +59,9 @@ cmake --build build -j --target flash_rt_minimax_remover |--------|--------|----------|--------| | `fp16_rms_norm_ncdhw` | `flash_rt_minimax_remover` | `WanRMS_norm.forward` (4 full-tensor fp32 passes) in attention blocks | ~6x per-call; fp16 in/out, fp32 stats, **no dtype cast** | | `fp16_rms_silu_ncdhw` | `flash_rt_minimax_remover` | `WanRMS_norm` + `F.silu` two-pass in every `WanResidualBlock` | fused single-pass; eliminates intermediate tensor R/W | +| `fp16_rms_norm_ndhwc` | `flash_rt_minimax_remover` | channels-last (NDHWC) variant of the above for attention-block norm | keeps pipeline in CL → eliminates nchw↔nhwc conversion | +| `fp16_rms_silu_ndhwc` | `flash_rt_minimax_remover` | channels-last fused norm+silu for residual blocks | CL-native, contiguous C reads → faster than NCDHW variant | +| channels-last pipeline | Python (`_vae_opt.py`) | Conv3d weight → CL, WanCausalConv3d → CL-preserving forward | eliminates ~97% of nchw↔nhwc conversion kernels (~280 ms / decode) | | `WanUpsample` patch | Python (no kernel) | `x.float().type_as(x)` for nearest-exact upsample | eliminates redundant fp32 cast (index-only op, fp16 == fp32) | The VAE stays fp16 throughout (cuDNN dispatches fp16 tensorop conv kernels). @@ -64,6 +70,30 @@ end-to-end (PSNR ~41 dB vs fp16 reference). If the module is not built, `install_vae_optimizations()` raises a clear `ImportError` with the rebuild command. +#### Channels-last 3D pipeline + +cuDNN's fp16 conv3d kernel (`sm80_xmma_fprop_implicit_gemm`) internally +operates in NHWC. When the VAE feeds NCDHW tensors, cuDNN inserts +`nchwToNhwcKernel` / `nhwcToNchwKernel` conversion kernels before and +after **every** conv3d call — totalling ~287 ms for a 18-frame decode +(~11% of wall time). + +The `install_vae_optimizations()` call now enables a **channels-last +3D (NDHWC) pipeline** end-to-end: + +1. All 61 `WanCausalConv3d` weight tensors are converted to + `memory_format=torch.channels_last_3d` (done once at install time). +2. `WanCausalConv3d.forward` is patched to preserve the CL format + (cat + pad + conv3d all preserve CL natively). +3. The FlashRT norm kernels are swapped to NDHWC variants + (`fp16_rms_norm_ndhwc`, `fp16_rms_silu_ndhwc`) so the norm output + stays in CL — no format break between norm and conv. + +Result: **97% of format-conversion kernels eliminated** (287 ms → 9 ms), +plus a ~1.3× per-conv speedup from cuDNN's preferred CL algorithm. +Zero precision loss (PSNR 40.8 dB vs fp16 reference, identical to the +NCDHW path within fp16 rounding). + Importing `flash_rt.models.minimax_remover` always succeeds — it needs **none** of `diffusers` / `einops` / `scipy` / `triton` / `sageattention`. The kernel surface is validated lazily in each pipeline's `__init__` via @@ -151,7 +181,10 @@ MiniMax-Remover `pipe` and consumes it in place: Both paths run the VAE encode / decode from the loaded diffusers model (one-shot per segment, outside the graph). With `--vae-opt` (default), the VAE's `WanRMS_norm` / `WanResidualBlock` norm+silu sites are replaced by the -FlashRT fp16 fused kernels (see [VAE fused kernels](#vae-fused-kernels-standalone-module-opt-in)). +FlashRT fp16 fused kernels, all 61 `WanCausalConv3d` weights are converted +to channels-last 3D, and the norm kernels are swapped to NDHWC variants so +the entire norm→conv pipeline stays in channels-last (see [VAE fused +kernels](#vae-fused-kernels-standalone-module-opt-in)). No MiniMax-Remover source is imported; the `pipe` is duck-typed through `.transformer` / `.vae` / `.scheduler` / `.video_processor` and the `expand_masks` / `resize` helpers. @@ -183,21 +216,26 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the | Clip (frames, resolution) | Stack | Wall time | Speedup vs fp16 ref | PSNR mean / worst vs fp16 ref | cosine mean | |--------------------------|-------|-----------|---------------------|-------------------------------|-------------| -| tennis (70 frames, 432x240) | fp16 reference (`--no-flashrt`) | 17.33 s | 1.0x | — | — | -| tennis (70 frames, 432x240) | FlashRT FP8 (no VAE opt) | 11.78 s | 1.47x | 40.9 / 37.4 dB | 0.99981 | -| tennis (70 frames, 432x240) | **FlashRT FP8 + VAE opt (default)** | **10.93 s** | **1.59x** | **40.9 / 37.3 dB** | 0.99981 | -| tennis (70 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 9.52 s | 1.82x | 7.0 / 6.2 dB | 0.00000 (broken) | +| tennis (70 frames, 432x240) | fp16 reference (`--no-flashrt`) | 15.60 s | 1.0x | — | — | +| tennis (70 frames, 432x240) | FlashRT FP8 (no VAE opt) | 11.78 s | 1.32x | 40.9 / 37.4 dB | 0.99981 | +| tennis (70 frames, 432x240) | **FlashRT FP8 + VAE opt + CL (default)** | **10.03 s** | **1.55x** | **40.8 / 37.1 dB** | 0.99981 | +| tennis (70 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 9.52 s | 1.64x | 7.0 / 6.2 dB | 0.00000 (broken) | | bmx-trees (80 frames, 432x240) | fp16 reference (`--no-flashrt`) | 19.76 s | 1.0x | — | — | | bmx-trees (80 frames, 432x240) | FlashRT FP8 (default) | 13.24 s | **1.49x** | 35.1 / 32.0 dB | 0.99912 | | bmx-trees (80 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 10.72 s | 1.84x | 7.3 / 7.0 dB | 0.00000 (broken) | +> Tennis numbers are from a same-session A/B (`--no-flashrt` vs `--vae-opt`) +> measured on RTX 5060 Ti, CUDA 13.0, cuDNN 9.2, PyTorch 2.12. Peak VRAM: +> fp16 ref 3.52 GB, FlashRT default 2.49 GB. + Takeaways: -- **FP8 + VAE opt is the recommended default**: 1.59x faster than the fp16 - reference with PSNR 40.9 dB on full-frame tennis clip. The fused VAE - kernels add ~7% speedup on top of FP8 alone (11.78 s -> 10.93 s) at no - precision cost. -- **FP8 alone** (without VAE kernels) is already 1.47x faster with +- **FP8 + VAE opt + channels-last is the recommended default**: 1.55x + faster than the fp16 reference with PSNR 40.8 dB (median) on full-frame + tennis clip, peak VRAM reduced from 3.52 GB to 2.49 GB. The fused VAE + kernels + channels-last pipeline together contribute ~17% speedup on + top of FP8 alone (11.78 s -> 10.03 s) at no precision cost. +- **FP8 alone** (without VAE kernels) is already 1.32x faster with cosine >= 0.999 and PSNR 35-41 dB. - **NVFP4 is faster but unusable on full-frame latents**: cosine collapses to ~0.0 and PSNR to ~7 dB (median per-pixel deviation ~85/255). The FP4 @@ -229,7 +267,9 @@ to the quantised GEMMs and the attention backend. |-----------|--------|-------| | VAE `fp16_rms_norm_ncdhw` kernel | cosine vs fp32 reference | >= 0.9999999 | | VAE `fp16_rms_silu_ncdhw` kernel | cosine vs fp32 reference | >= 0.9999999 | -| End-to-end FP8 + VAE opt (full-frame) | PSNR vs fp16 ref | 35-41 dB (mean) / >= 32 dB (worst frame) | +| VAE `fp16_rms_norm_ndhwc` (CL) kernel | cosine vs fp32 reference | >= 0.9999999 | +| VAE `fp16_rms_silu_ndhwc` (CL) kernel | cosine vs fp32 reference | >= 0.9999999 | +| End-to-end FP8 + VAE opt + CL (full-frame) | PSNR vs fp16 ref | 40.8 dB (median) / >= 37 dB (worst frame) | | Attention — SageAttention QK-int8 PV-fp8 (`sage_fp8`, default) | cosine vs SDPA | 0.9993 | | Attention — SageAttention QK-int8 PV-fp16 (`sage_fp16`) | cosine vs SDPA | 0.9999 | | NVFP4 W4A4 GEMM | cosine vs fp16 matmul | >= 0.999 | @@ -248,7 +288,7 @@ the steady state; the FP8 path calibrates on the first call then freezes. | Flag | Default | Effect | |------|---------|--------| -| `--vae-opt` / `--no-vae-opt` | **enabled** | Install FlashRT fp16 fused VAE kernels (`fp16_rms_norm_ncdhw`, `fp16_rms_silu_ncdhw`) + `WanUpsample` cast elimination. Requires the `flash_rt_minimax_remover` module. | +| `--vae-opt` / `--no-vae-opt` | **enabled** | Install FlashRT fp16 fused VAE kernels (`fp16_rms_norm_ncdhw`, `fp16_rms_silu_ncdhw`, NDHWC variants) + channels-last 3D pipeline (Conv3d weights → CL, eliminates nchw↔nhwc conversion) + `WanUpsample` cast elimination. Requires the `flash_rt_minimax_remover` module. | | `--use-fp4` | off | Use NVFP4 (W4A4) instead of the default FP8 (W8A8). Small-region only. | | `--no-flashrt` | off | Run the reference diffusers fp16 path (no FlashRT). | diff --git a/flash_rt/models/minimax_remover/_vae_opt.py b/flash_rt/models/minimax_remover/_vae_opt.py index e97accdd..0871f9ee 100644 --- a/flash_rt/models/minimax_remover/_vae_opt.py +++ b/flash_rt/models/minimax_remover/_vae_opt.py @@ -24,6 +24,7 @@ import torch import torch.nn as nn +import torch.nn.functional as F logger = logging.getLogger(__name__) @@ -224,6 +225,7 @@ def install_vae_optimizations(vae, dtype=None) -> Dict: "cmake -DFLASHRT_ENABLE_MINIMAX_REMOVER=ON -DGPU_ARCH=120 ...") n_fused_blocks = install_flashrt_fp16_rms_norm(vae) n_upsample = _install_wan_upsample_no_cast(vae) + _install_channels_last_pipeline(vae) return { "n_fused_res_blocks": n_fused_blocks, "n_upsample_nocast": n_upsample, @@ -231,6 +233,120 @@ def install_vae_optimizations(vae, dtype=None) -> Dict: } +def _install_channels_last_pipeline(vae) -> int: + """Enable channels-last 3D (NDHWC) throughout the VAE pipeline. + + Converts Conv3d weights to channels-last, patches WanCausalConv3d + to preserve the format, and replaces the FlashRT norm kernels with + channels-last variants so norm output stays NDHWC. + + This eliminates ALL nchw↔nhwc conversion kernels that cuDNN inserts + (~287 ms / decode) and gives a ~1.3x speedup on the dominant conv + layers. Zero precision loss (identical fp16 computation, only the + memory layout changes). + """ + from diffusers.models.autoencoders.autoencoder_kl_wan import ( + WanCausalConv3d, WanRMS_norm) + + n = 0 + + # 1. Convert Conv3d weights to channels-last 3D. + for m in vae.modules(): + if isinstance(m, WanCausalConv3d): + with torch.no_grad(): + m.weight.data = m.weight.data.to( + memory_format=torch.channels_last_3d) + n += 1 + + # 2. Patch WanCausalConv3d.forward to preserve channels-last. + if not getattr(WanCausalConv3d, "_flashrt_cl_fwd", False): + def _cl_conv_forward(self, x, cache_x=None): + if x.dim() == 5 and not x.is_contiguous( + memory_format=torch.channels_last_3d): + x = x.to(memory_format=torch.channels_last_3d) + padding = list(self._padding) + if cache_x is not None and self._padding[4] > 0: + cache_x = cache_x.to(x.device) + if not cache_x.is_contiguous( + memory_format=torch.channels_last_3d): + cache_x = cache_x.to( + memory_format=torch.channels_last_3d) + x = torch.cat([cache_x, x], dim=2) + padding[4] -= cache_x.shape[2] + x = F.pad(x, padding) + return F.conv3d(x, self.weight, self.bias, self.stride, + self.padding, self.dilation, self.groups) + + WanCausalConv3d.forward = _cl_conv_forward + WanCausalConv3d._flashrt_cl_fwd = True + + # 3. Replace norm kernels with channels-last variants. + def _cl_rms_norm_forward(self, x): + if x.dim() == 4: + # Attention block reshapes to 4D [B*T, C, H, W] — use NCDHW + # kernel (attention is ~2% of decode time, conversion negligible). + return _flashrt_fp16_rms_norm_forward(self, x) + B, C, T, H, W = x.shape + if x.dtype != _FP16: + x = x.to(_FP16) + if not x.is_contiguous(memory_format=torch.channels_last_3d): + x = x.to(memory_format=torch.channels_last_3d) + gamma_flat, bias_ptr = _prep_gamma_bias(self.gamma, self.bias) + out = torch.empty_like(x) + stream = torch.cuda.current_stream().cuda_stream + rc = _fvk.fp16_rms_norm_ndhwc( + x.data_ptr(), gamma_flat.data_ptr(), bias_ptr, + out.data_ptr(), B, C, T, H, W, _EPS, stream) + if rc != 0: + return _ref_rms_norm(self.gamma, self.bias, x) + return out + + class _FusedRmsSiluCL(nn.Module): + def __init__(self, gamma, bias): + super().__init__() + self.gamma = gamma + self.bias = bias + + def forward(self, x): + if x.dim() == 4: + return F.silu(_flashrt_fp16_rms_norm_forward( + type('_DummyNorm', (), { + 'gamma': self.gamma, 'bias': self.bias, + '_orig_forward': None})(), x)) + B, C, T, H, W = x.shape + if x.dtype != _FP16: + x = x.to(_FP16) + if not x.is_contiguous(memory_format=torch.channels_last_3d): + x = x.to(memory_format=torch.channels_last_3d) + gamma_flat, bias_ptr = _prep_gamma_bias(self.gamma, self.bias) + out = torch.empty_like(x) + stream = torch.cuda.current_stream().cuda_stream + rc = _fvk.fp16_rms_silu_ndhwc( + x.data_ptr(), gamma_flat.data_ptr(), bias_ptr, + out.data_ptr(), B, C, T, H, W, _EPS, stream) + if rc != 0: + return F.silu(_ref_rms_norm(self.gamma, self.bias, x)) + return out + + # Swap residual-block norms to CL fused variant. + from diffusers.models.autoencoders.autoencoder_kl_wan import ( + WanResidualBlock) + for blk in vae.modules(): + if isinstance(blk, WanResidualBlock): + blk.norm1 = _FusedRmsSiluCL(blk.norm1.gamma, blk.norm1.bias) + blk.norm2 = _FusedRmsSiluCL(blk.norm2.gamma, blk.norm2.bias) + + # Swap attention-block norm to CL plain variant. + WanRMS_norm.forward = _cl_rms_norm_forward + WanRMS_norm._flashrt_cl_norm = True + + logger.info("[minimax-vae] channels-last 3D pipeline enabled: " + "%d Conv3d weights converted, norm kernels swapped to " + "NDHWC variant (eliminates ~287ms format conversion)", + n) + return n + + @torch.no_grad() def profile_vae(pipe, images_tensor, masks_infer, height, width, num_frames, iterations=6, num_inference_steps=12, seed=42, From 01fd2510ae70b85854410d9170dfb769d4ca3d77 Mon Sep 17 00:00:00 2001 From: chenping Date: Tue, 7 Jul 2026 11:43:18 +0800 Subject: [PATCH 12/23] feat(minimax-remover): add FP8 implicit-GEMM conv3d kernel for VAE acceleration Hand-rolled FP8 e4m3 implicit-GEMM 3x3x3 causal conv3d kernel that replaces cuDNN fp16 conv3d for applicable layers (Ci % 32 == 0). The kernel computes im2col indices on-the-fly inside the MMA loop via cp.async, avoiding the 268MB intermediate matrix that made the naive im2col + _scaled_mm approach 3.5x slower than cuDNN. Adapted from the motus v17 kernel with three key changes: fp16 output/bias (VAE stays fp16), per-output-channel alpha vector (act_scale * w_scale[co] for higher PSNR), and fused activation quantization (2-pass device-side amax + quantize, no host sync, shared scale across cache+new frames). New files (all in csrc/kernels/minimax_remover/): - fp8_conv3d_mm_ndhwc_fp16out.cu/.cuh: 128/128/32 tile, 8 warps, 2-stage cp.async, virtual cache concat, direct causal output, bias-fused epilogue with per-channel dequant - fp16_quant_fp8_per_tensor.cu/.cuh: amax_fp16 + atomicMax (multi- tensor accumulation) and quantize_fp16_fp8_with_amax (standalone quantize pass reading pre-computed amax from device) Performance (RTX 5060 Ti, 70 frames 432x240): baseline (no flashrt): 17.33s (1.00x) channels-last only: 10.01s (1.73x, PSNR 40.85 dB) + FP8 conv3d (default): 8.75s (1.98x, PSNR 39.34 dB) 49 conv3d layers quantized (encoder + decoder). Non-applicable layers (1x1x1, 3x1x1, Ci%32!=0, Co<8) fall back to cuDNN fp16. Toggle via --fp8-conv / --no-fp8-conv. --- CMakeLists.txt | 7 +- .../fp16_quant_fp8_per_tensor.cu | 146 ++++++++ .../fp16_quant_fp8_per_tensor.cuh | 45 +++ .../fp8_conv3d_mm_ndhwc_fp16out.cu | 328 ++++++++++++++++++ .../fp8_conv3d_mm_ndhwc_fp16out.cuh | 47 +++ csrc/minimax_remover_extra_bindings.cpp | 73 ++++ docs/minimax_remover_usage.md | 100 ++++-- examples/minimax_remover_quickstart.py | 10 +- flash_rt/models/minimax_remover/_vae_opt.py | 190 +++++++++- 9 files changed, 914 insertions(+), 32 deletions(-) create mode 100644 csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cu create mode 100644 csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cuh create mode 100644 csrc/kernels/minimax_remover/fp8_conv3d_mm_ndhwc_fp16out.cu create mode 100644 csrc/kernels/minimax_remover/fp8_conv3d_mm_ndhwc_fp16out.cuh diff --git a/CMakeLists.txt b/CMakeLists.txt index cc04a16b..cc0c8e43 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -966,7 +966,9 @@ if(FLASHRT_ENABLE_MINIMAX_REMOVER) csrc/minimax_remover_extra_bindings.cpp csrc/kernels/minimax_remover/fp16_rms_norm_ncdhw.cu csrc/kernels/minimax_remover/fp16_rms_silu_ncdhw.cu - csrc/kernels/minimax_remover/fp16_rms_norm_ndhwc.cu) + csrc/kernels/minimax_remover/fp16_rms_norm_ndhwc.cu + csrc/kernels/minimax_remover/fp8_conv3d_mm_ndhwc_fp16out.cu + csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cu) set_target_properties(flash_rt_minimax_remover PROPERTIES CUDA_STANDARD 17 LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/flash_rt @@ -976,9 +978,10 @@ if(FLASHRT_ENABLE_MINIMAX_REMOVER) ${CMAKE_CURRENT_SOURCE_DIR}/csrc/kernels) target_compile_options(flash_rt_minimax_remover PRIVATE $<$: - --expt-relaxed-constexpr -O3 + --expt-relaxed-constexpr -O3 --use_fast_math -U__CUDA_NO_HALF_OPERATORS__ -U__CUDA_NO_HALF2_OPERATORS__ + -U__CUDA_NO_HALF_CONVERSIONS__ ${GPU_GENCODE} >) target_link_libraries(flash_rt_minimax_remover PRIVATE CUDA::cudart) diff --git a/csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cu b/csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cu new file mode 100644 index 00000000..3498d90d --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cu @@ -0,0 +1,146 @@ +// ================================================================ +// flash_rt — MiniMax-Remover FP8 per-tensor activation quantize. +// +// 2-pass fused launcher (no host sync): +// Pass 1: grid-stride amax reduction → atomicMax into amax_buf. +// Pass 2: read amax_buf on device, compute scale, grid-stride +// quantize fp16 → fp8 e4m3. Writes scale to scale_out. +// ================================================================ + +#include "fp16_quant_fp8_per_tensor.cuh" +#include +#include +#include +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +namespace { + +constexpr int Q_THREADS = 256; +constexpr int Q_BLOCKS = 256; // cap grid for amax pass + +__global__ void amax_fp16_kernel( + const __half* __restrict__ x, int n, float* amax_out) +{ + int tid = threadIdx.x; + int idx = blockIdx.x * Q_THREADS + tid; + int stride = Q_THREADS * gridDim.x; + float local = 0.0f; + for (int i = idx; i < n; i += stride) { + local = fmaxf(local, fabsf(__half2float(x[i]))); + } + // Warp reduce + for (int d = 16; d > 0; d >>= 1) + local = fmaxf(local, __shfl_xor_sync(0xffffffff, local, d)); + // Block reduce via shared memory + __shared__ float smem[8]; // Q_THREADS / 32 = 8 warps + int warp_id = tid / 32; + int lane = tid % 32; + if (lane == 0) smem[warp_id] = local; + __syncthreads(); + if (warp_id == 0) { + local = (lane < 8) ? smem[lane] : 0.0f; + for (int d = 4; d > 0; d >>= 1) + local = fmaxf(local, __shfl_xor_sync(0xffffffff, local, d)); + if (lane == 0) { + // atomicMax on reinterpreted int works for non-negative floats + // (IEEE 754 preserves ordering for sign bit = 0). + atomicMax(reinterpret_cast(amax_out), __float_as_int(local)); + } + } +} + +__global__ void quantize_fp16_to_fp8_kernel( + const __half* __restrict__ x, + __nv_fp8_e4m3* __restrict__ y, + int n, const float* amax_in, float* scale_out) +{ + __shared__ float s_inv_scale; + if (threadIdx.x == 0) { + float amax = fmaxf(*amax_in, 0.0f); + float scale = amax * (1.0f / 448.0f); + if (scale < 1e-6f) scale = 1e-6f; + if (scale_out != nullptr) *scale_out = scale; + s_inv_scale = 1.0f / scale; + } + __syncthreads(); + float inv_scale = s_inv_scale; + + int idx = blockIdx.x * Q_THREADS + threadIdx.x; + int stride = Q_THREADS * gridDim.x; + for (int i = idx; i < n; i += stride) { + float val = __half2float(x[i]) * inv_scale; + val = fminf(fmaxf(val, -448.0f), 448.0f); + y[i] = __nv_fp8_e4m3(val); + } +} + +} // anonymous namespace + +int fp16_quant_fp8_per_tensor( + const void* x_fp16, void* y_fp8, + void* scale_out, void* amax_buf, + int n, cudaStream_t stream) +{ + if (n <= 0) return -1; + cudaError_t e; + e = cudaMemsetAsync(amax_buf, 0, sizeof(float), stream); + if (e != cudaSuccess) return -2; + + int blocks_amax = (n + Q_THREADS - 1) / Q_THREADS; + if (blocks_amax > Q_BLOCKS) blocks_amax = Q_BLOCKS; + + amax_fp16_kernel<<>>( + reinterpret_cast(x_fp16), n, + reinterpret_cast(amax_buf)); + + int blocks_q = (n + Q_THREADS - 1) / Q_THREADS; + quantize_fp16_to_fp8_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast<__nv_fp8_e4m3*>(y_fp8), n, + reinterpret_cast(amax_buf), + reinterpret_cast(scale_out)); + + e = cudaGetLastError(); + if (e != cudaSuccess) return -3; + return 0; +} + +// ── Standalone amax (for multi-tensor shared-scale quantization) ── +int amax_fp16( + const void* x_fp16, void* amax_buf, + int n, cudaStream_t stream) +{ + if (n <= 0) return -1; + int blocks = (n + Q_THREADS - 1) / Q_THREADS; + if (blocks > Q_BLOCKS) blocks = Q_BLOCKS; + amax_fp16_kernel<<>>( + reinterpret_cast(x_fp16), n, + reinterpret_cast(amax_buf)); + cudaError_t e = cudaGetLastError(); + return (e == cudaSuccess) ? 0 : -2; +} + +// ── Standalone quantize (reads pre-computed amax from device) ── +int quantize_fp16_fp8_with_amax( + const void* x_fp16, void* y_fp8, + const void* amax_buf, void* scale_out, + int n, cudaStream_t stream) +{ + if (n <= 0) return -1; + int blocks = (n + Q_THREADS - 1) / Q_THREADS; + quantize_fp16_to_fp8_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast<__nv_fp8_e4m3*>(y_fp8), n, + reinterpret_cast(amax_buf), + reinterpret_cast(scale_out)); + cudaError_t e = cudaGetLastError(); + return (e == cudaSuccess) ? 0 : -2; +} + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cuh b/csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cuh new file mode 100644 index 00000000..a5dc6b8d --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cuh @@ -0,0 +1,45 @@ +// ================================================================ +// flash_rt — MiniMax-Remover FP8 per-tensor activation quantize. +// +// Fused 2-pass launcher: +// Pass 1: parallel amax reduction (block-local → atomicMax). +// Pass 2: read device-resident amax, compute scale, quantize. +// +// No host sync — scale stays on device. Works on any contiguous +// fp16 buffer (the caller passes a channels-last 3D tensor whose +// physical memory is NDHWC, and the fp8 output preserves that order). +// ================================================================ +#pragma once +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +// Quantize x_fp16 [n elements] → y_fp8, writing per-tensor scale to +// scale_out (device float). amax_buf is a scratch device float +// (1 element) used for the inter-pass reduction. +// +// Returns 0 on success, negative on error. +int fp16_quant_fp8_per_tensor( + const void* x_fp16, void* y_fp8, + void* scale_out, void* amax_buf, + int n, cudaStream_t stream); + +// amax only: grid-stride reduction with atomicMax into amax_buf. +// Caller MUST zero amax_buf before the first call. Multiple calls +// accumulate (so amax over two tensors = call on each sequentially). +int amax_fp16( + const void* x_fp16, void* amax_buf, + int n, cudaStream_t stream); + +// Quantize only: reads pre-computed amax from amax_buf, computes +// scale = max(amax,0)/448, writes scale to scale_out, quantizes. +int quantize_fp16_fp8_with_amax( + const void* x_fp16, void* y_fp8, + const void* amax_buf, void* scale_out, + int n, cudaStream_t stream); + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/fp8_conv3d_mm_ndhwc_fp16out.cu b/csrc/kernels/minimax_remover/fp8_conv3d_mm_ndhwc_fp16out.cu new file mode 100644 index 00000000..b9f1d657 --- /dev/null +++ b/csrc/kernels/minimax_remover/fp8_conv3d_mm_ndhwc_fp16out.cu @@ -0,0 +1,328 @@ +// ================================================================ +// flash_rt — MiniMax-Remover FP8 Conv3d fprop, sm_120a. +// +// Implicit-GEMM FP8 e4m3 conv3d with on-the-fly im2col indexing. +// Tile: BLOCK_M=128, BLOCK_N=128, BLOCK_K=32, 8 warps, cp.async +// 2-stage. Causal temporal cache via virtual dual-pointer concat. +// +// Output: fp16 (NDHWC / channels-last 3D physical layout). +// Bias: fp16 per-channel. +// Alpha: per-output-channel float vector (act_scale × w_scale[co]). +// +// Constraints: Ci % 32 == 0, T_cache == 2, kernel = 3×3×3, pad = 1, +// stride = dilation = 1, groups = 1. +// ================================================================ + +#include "fp8_conv3d_mm_ndhwc_fp16out.cuh" +#include +#include +#include +#include +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +constexpr int MM_BLOCK_M = 128; +constexpr int MM_BLOCK_N = 128; +constexpr int MM_BLOCK_K = 32; +constexpr int MM_N_ATOMS = MM_BLOCK_N / 8; +constexpr int MM_NUM_WARPS = 8; +constexpr int MM_THREADS = MM_NUM_WARPS * 32; +constexpr int MM_STAGES = 2; +constexpr int MM_SMEM_K_STRIDE = 48; + +__device__ __forceinline__ +void mm_mma_m16n8k32_e4m3( + float &d0, float &d1, float &d2, float &d3, + uint32_t a0, uint32_t a1, uint32_t a2, uint32_t a3, + uint32_t b0, uint32_t b1) +{ + asm volatile( + "mma.sync.aligned.kind::f8f6f4.m16n8k32.row.col.f32.e4m3.e4m3.f32 " + "{%0, %1, %2, %3}, " + "{%4, %5, %6, %7}, " + "{%8, %9}, " + "{%0, %1, %2, %3};\n" + : "+f"(d0), "+f"(d1), "+f"(d2), "+f"(d3) + : "r"(a0), "r"(a1), "r"(a2), "r"(a3), + "r"(b0), "r"(b1)); +} + +// Activation address: virtual concat of cache_x + new_x along T, +// causal output mapping d_in = t_out + kt. +// Spatial pad=1 (h_in/w_in OOB → nullptr → cp.async zeros smem). +__device__ __forceinline__ +const uint8_t* mm_x_byte_ptr(const __nv_fp8_e4m3* cache_x, + const __nv_fp8_e4m3* new_x, + int m_global, int k_global, + int N, int T_cache, int T_new, + int H, int W, int Ci) { + int K_total = 27 * Ci; + int M_total = N * T_new * H * W; + if (k_global >= K_total || m_global >= M_total) return nullptr; + int spatial = T_new * H * W; + int b_idx = m_global / spatial; + int rem = m_global - b_idx * spatial; + int t_out = rem / (H * W); + rem -= t_out * (H * W); + int h_out = rem / W; + int w_out = rem - h_out * W; + int q = k_global / Ci; + int ci0 = k_global % Ci; + int ks = q % 3; q /= 3; + int kr = q % 3; + int kt = q / 3; + int d_in = t_out + kt; + int h_in = h_out + kr - 1; + int w_in = w_out + ks - 1; + if (h_in < 0 || h_in >= H || w_in < 0 || w_in >= W) return nullptr; + if (d_in < T_cache) { + int idx = (((b_idx * T_cache + d_in) * H + h_in) * W + w_in) * Ci + ci0; + return reinterpret_cast(&cache_x[idx]); + } else { + int d_new = d_in - T_cache; + int idx = (((b_idx * T_new + d_new) * H + h_in) * W + w_in) * Ci + ci0; + return reinterpret_cast(&new_x[idx]); + } +} + +// Weight address: [Co, kT, kH, kW, Ci] contiguous. +__device__ __forceinline__ +const uint8_t* mm_w_byte_ptr(const __nv_fp8_e4m3* w, + int co, int k_global, int Co, int Ci) { + int K_total = 27 * Ci; + if (co >= Co || k_global >= K_total) return nullptr; + int q = k_global / Ci; + int ci0 = k_global % Ci; + int ks = q % 3; q /= 3; + int kr = q % 3; + int kt = q / 3; + int idx = (((co * 3 + kt) * 3 + kr) * 3 + ks) * Ci + ci0; + return reinterpret_cast(&w[idx]); +} + +__device__ __forceinline__ +void mm_cp_async_16(uint32_t smem_int_ptr, const uint8_t* src) { + int src_bytes = (src == nullptr) ? 0 : 16; + asm volatile( + "cp.async.ca.shared.global [%0], [%1], 16, %2;\n" + :: "r"(smem_int_ptr), "l"(src), "r"(src_bytes)); +} + +__device__ __forceinline__ +uint32_t mm_to_smem_int(const void* p) { + return static_cast(__cvta_generic_to_shared(p)); +} + +__global__ void __launch_bounds__(MM_THREADS, 2) +fp8_conv3d_mm_kernel( + const __nv_fp8_e4m3* __restrict__ cache_x, + const __nv_fp8_e4m3* __restrict__ new_x, + const __nv_fp8_e4m3* __restrict__ w, + __half* __restrict__ y, + const __half* __restrict__ bias, + const float* __restrict__ alpha_vec, + int N, int T_cache, int T_new, int H, int W, int Ci, int Co, + int M_tiles, int N_tiles) +{ + __shared__ __align__(16) uint8_t A_smem[MM_STAGES][MM_BLOCK_M * MM_SMEM_K_STRIDE]; + __shared__ __align__(16) uint8_t B_smem[MM_STAGES][MM_BLOCK_N * MM_SMEM_K_STRIDE]; + + const int t = threadIdx.x; + const int warp_id = t / 32; + const int lane = t % 32; + const int l = lane % 4; + const int h = lane / 4; + + const int M_total = N * T_new * H * W; + const int K_total = 27 * Ci; + + const int ld_row_a = t / 2; + const int ld_k_off_a = (t & 1) * 16; + const int ld_row_b = t / 2; + const int ld_k_off_b = (t & 1) * 16; + + { + int tile_idx = blockIdx.x; + int m_idx = tile_idx / N_tiles; + int n_idx = tile_idx % N_tiles; + int m_base = m_idx * MM_BLOCK_M; + int co_base = n_idx * MM_BLOCK_N; + + if (m_base >= M_total || co_base >= Co) return; + + float dA[MM_N_ATOMS] = {0}; + float dB[MM_N_ATOMS] = {0}; + float dC[MM_N_ATOMS] = {0}; + float dD[MM_N_ATOMS] = {0}; + + auto issue_load = [&](int stage, int k_base) { + { + const uint8_t* src = mm_x_byte_ptr(cache_x, new_x, + m_base + ld_row_a, + k_base + ld_k_off_a, + N, T_cache, T_new, H, W, Ci); + uint32_t smem_int = mm_to_smem_int( + &A_smem[stage][ld_row_a * MM_SMEM_K_STRIDE + ld_k_off_a]); + mm_cp_async_16(smem_int, src); + } + { + const uint8_t* src = mm_w_byte_ptr(w, co_base + ld_row_b, + k_base + ld_k_off_b, + Co, Ci); + uint32_t smem_int = mm_to_smem_int( + &B_smem[stage][ld_row_b * MM_SMEM_K_STRIDE + ld_k_off_b]); + mm_cp_async_16(smem_int, src); + } + }; + + // Prologue + issue_load(0, 0); + asm volatile("cp.async.commit_group;\n" ::); + + int compute_stage = 0; + + for (int k_base = 0; k_base < K_total; k_base += MM_BLOCK_K) { + int next_stage = compute_stage ^ 1; + int k_next = k_base + MM_BLOCK_K; + + if (k_next < K_total) { + issue_load(next_stage, k_next); + } + asm volatile("cp.async.commit_group;\n" ::); + asm volatile("cp.async.wait_group 1;\n" ::); + __syncthreads(); + + const int warp_M_off = warp_id * 16; + const int kA0 = 4 * l; + const int kA2 = 4 * l + 16; + + int rA0 = warp_M_off + h; + int rA1 = warp_M_off + h + 8; + uint32_t A0 = *reinterpret_cast( + &A_smem[compute_stage][rA0 * MM_SMEM_K_STRIDE + kA0]); + uint32_t A1 = *reinterpret_cast( + &A_smem[compute_stage][rA1 * MM_SMEM_K_STRIDE + kA0]); + uint32_t A2 = *reinterpret_cast( + &A_smem[compute_stage][rA0 * MM_SMEM_K_STRIDE + kA2]); + uint32_t A3 = *reinterpret_cast( + &A_smem[compute_stage][rA1 * MM_SMEM_K_STRIDE + kA2]); + + #pragma unroll + for (int n_atom = 0; n_atom < MM_N_ATOMS; ++n_atom) { + int co_n = n_atom * 8 + h; + uint32_t B0 = *reinterpret_cast( + &B_smem[compute_stage][co_n * MM_SMEM_K_STRIDE + kA0]); + uint32_t B1 = *reinterpret_cast( + &B_smem[compute_stage][co_n * MM_SMEM_K_STRIDE + kA2]); + mm_mma_m16n8k32_e4m3( + dA[n_atom], dB[n_atom], dC[n_atom], dD[n_atom], + A0, A1, A2, A3, B0, B1); + } + + compute_stage = next_stage; + } + + asm volatile("cp.async.wait_all;\n" ::); + + // Epilogue: per-channel alpha × fp32 accumulator + fp16 bias → fp16 store. + // Output in NDHWC: y[b, t_out, h_out, w_out, co]. + const int warp_M_off = warp_id * 16; + #pragma unroll + for (int n_atom = 0; n_atom < MM_N_ATOMS; ++n_atom) { + int co_pair = co_base + n_atom * 8 + 2 * l; + int row0 = m_base + warp_M_off + h; + int row1 = m_base + warp_M_off + h + 8; + + float a0 = 1.0f, a1 = 1.0f; + if (alpha_vec != nullptr) { + a0 = alpha_vec[co_pair]; + if (co_pair + 1 < Co) a1 = alpha_vec[co_pair + 1]; + } + float b0 = 0.f, b1 = 0.f; + if (bias != nullptr && co_pair < Co) { + b0 = __half2float(bias[co_pair]); + if (co_pair + 1 < Co) b1 = __half2float(bias[co_pair + 1]); + } + + if (co_pair + 1 < Co) { + __half2 packAB; + packAB.x = __float2half_rn(dA[n_atom] * a0 + b0); + packAB.y = __float2half_rn(dB[n_atom] * a1 + b1); + __half2 packCD; + packCD.x = __float2half_rn(dC[n_atom] * a0 + b0); + packCD.y = __float2half_rn(dD[n_atom] * a1 + b1); + if (row0 < M_total) { + *reinterpret_cast<__half2*>(&y[row0 * Co + co_pair]) = packAB; + } + if (row1 < M_total) { + *reinterpret_cast<__half2*>(&y[row1 * Co + co_pair]) = packCD; + } + } else { + auto store = [&](int row, int co, float v, float av, float bv) { + if (row < M_total && co < Co) { + y[row * Co + co] = __float2half_rn(v * av + bv); + } + }; + store(row0, co_pair + 0, dA[n_atom], a0, b0); + store(row0, co_pair + 1, dB[n_atom], a1, b1); + store(row1, co_pair + 0, dC[n_atom], a0, b0); + store(row1, co_pair + 1, dD[n_atom], a1, b1); + } + } + } +} + +int fp8_conv3d_mm_ndhwc_fp16out( + const void* cache_x_fp8, const void* new_x_fp8, + const void* w_fp8, void* y_fp16, + const void* bias_fp16, const void* alpha_vec, + int N, int T_cache, int T_new, int H, int W, int Ci, int Co, + cudaStream_t stream) +{ + if (Ci % MM_BLOCK_K != 0) { + std::fprintf(stderr, + "[fp8_conv3d_mm] Ci%%%d (got %d) — must be multiple of 32\n", + MM_BLOCK_K, Ci); + return -1; + } + if (T_cache != 2) { + std::fprintf(stderr, + "[fp8_conv3d_mm] T_cache must be 2 (got %d)\n", T_cache); + return -3; + } + if (T_new < 1) { + std::fprintf(stderr, + "[fp8_conv3d_mm] T_new must be >= 1 (got %d)\n", T_new); + return -4; + } + int M = N * T_new * H * W; + int M_tiles = (M + MM_BLOCK_M - 1) / MM_BLOCK_M; + int N_tiles = (Co + MM_BLOCK_N - 1) / MM_BLOCK_N; + int total_tiles = M_tiles * N_tiles; + + dim3 grid(total_tiles); + dim3 block(MM_THREADS); + fp8_conv3d_mm_kernel<<>>( + reinterpret_cast(cache_x_fp8), + reinterpret_cast(new_x_fp8), + reinterpret_cast(w_fp8), + reinterpret_cast<__half*>(y_fp16), + reinterpret_cast(bias_fp16), + reinterpret_cast(alpha_vec), + N, T_cache, T_new, H, W, Ci, Co, + M_tiles, N_tiles); + cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "[fp8_conv3d_mm] launch err: %s\n", + cudaGetErrorString(e)); + return -2; + } + return 0; +} + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/fp8_conv3d_mm_ndhwc_fp16out.cuh b/csrc/kernels/minimax_remover/fp8_conv3d_mm_ndhwc_fp16out.cuh new file mode 100644 index 00000000..ac586df8 --- /dev/null +++ b/csrc/kernels/minimax_remover/fp8_conv3d_mm_ndhwc_fp16out.cuh @@ -0,0 +1,47 @@ +// ================================================================ +// flash_rt — MiniMax-Remover FP8 Conv3d (implicit-GEMM, NDHWC, +// fp16 output). +// +// Hand-rolled implicit-GEMM FP8 e4m3 conv3d fprop with on-the-fly +// im2col index computation (no intermediate matrix materialization). +// Adapted from the motus v17 kernel with three key changes: +// +// 1. fp16 output (VAE must stay fp16 — no bf16 cast). +// 2. fp16 bias. +// 3. Per-output-channel alpha vector (act_scale × per-channel +// weight_scale) for higher PSNR than per-tensor scaling. +// +// Specialised for 3×3×3 causal conv with stride/dilation/groups = 1, +// spatial pad = 1, temporal causal cache (T_cache = 2, T_new ≥ 1). +// ================================================================ +#pragma once +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +// Fused FP8 e4m3 conv3d fprop (implicit GEMM, NDHWC layout). +// +// cache_x_fp8 : [N, T_cache, H, W, Ci] fp8_e4m3 (may be zero-filled) +// new_x_fp8 : [N, T_new, H, W, Ci] fp8_e4m3 +// w_fp8 : [Co, 3, 3, 3, Ci] fp8_e4m3 +// y_fp16 : [N, T_new, H, W, Co] fp16 (channels-last 3D physical) +// bias_fp16 : [Co] fp16 (may be nullptr) +// alpha_vec : [Co] float (per-channel dequant: act_scale×w_scale[co], +// may be nullptr → alpha = 1.0) +// +// Returns 0 on success, negative on error. +int fp8_conv3d_mm_ndhwc_fp16out( + const void* cache_x_fp8, + const void* new_x_fp8, + const void* w_fp8, + void* y_fp16, + const void* bias_fp16, + const void* alpha_vec, + int N, int T_cache, int T_new, int H, int W, int Ci, int Co, + cudaStream_t stream); + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/minimax_remover_extra_bindings.cpp b/csrc/minimax_remover_extra_bindings.cpp index 92c78622..33981a1b 100644 --- a/csrc/minimax_remover_extra_bindings.cpp +++ b/csrc/minimax_remover_extra_bindings.cpp @@ -16,6 +16,8 @@ #include "kernels/minimax_remover/fp16_rms_norm_ncdhw.cuh" #include "kernels/minimax_remover/fp16_rms_silu_ncdhw.cuh" #include "kernels/minimax_remover/fp16_rms_norm_ndhwc.cuh" +#include "kernels/minimax_remover/fp8_conv3d_mm_ndhwc_fp16out.cuh" +#include "kernels/minimax_remover/fp16_quant_fp8_per_tensor.cuh" namespace py = pybind11; @@ -90,4 +92,75 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), py::arg("eps") = 1e-6f, py::arg("stream") = 0, "Fused FP16 channels-last (NDHWC) RMSNorm + SiLU."); + + // ── FP8 implicit-GEMM conv3d (3×3×3 causal, NDHWC, fp16 output) ── + m.def("fp8_conv3d_mm_ndhwc_fp16out", + [](uintptr_t cache_x_fp8, uintptr_t new_x_fp8, + uintptr_t w_fp8, uintptr_t y_fp16, + uintptr_t bias_fp16, uintptr_t alpha_vec, + int N, int T_cache, int T_new, int H, int W, int Ci, int Co, + uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + fp8_conv3d_mm_ndhwc_fp16out( + to_ptr(cache_x_fp8), to_ptr(new_x_fp8), + to_ptr(w_fp8), to_ptr(y_fp16), + bias_fp16 ? to_ptr(bias_fp16) : nullptr, + alpha_vec ? to_ptr(alpha_vec) : nullptr, + N, T_cache, T_new, H, W, Ci, Co, + to_stream(stream)); + }, + py::arg("cache_x_fp8"), py::arg("new_x_fp8"), + py::arg("w_fp8"), py::arg("y_fp16"), + py::arg("bias_fp16"), py::arg("alpha_vec"), + py::arg("N"), py::arg("T_cache"), py::arg("T_new"), + py::arg("H"), py::arg("W"), py::arg("Ci"), py::arg("Co"), + py::arg("stream") = 0, + "FP8 e4m3 implicit-GEMM conv3d fprop (3x3x3 causal, NDHWC, " + "fp16 output). Per-channel alpha vector [Co] float and fp16 " + "bias [Co]. No im2col materialization."); + + // ── Fused fp16→fp8 per-tensor quantize (2-pass, no host sync) ── + m.def("fp16_quant_fp8_per_tensor", + [](uintptr_t x_fp16, uintptr_t y_fp8, + uintptr_t scale_out, uintptr_t amax_buf, + int n, uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + fp16_quant_fp8_per_tensor( + to_ptr(x_fp16), to_ptr(y_fp8), + to_ptr(scale_out), to_ptr(amax_buf), + n, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("y_fp8"), + py::arg("scale_out"), py::arg("amax_buf"), + py::arg("n"), py::arg("stream") = 0, + "Fused per-tensor fp16→fp8 e4m3 quantize (amax + scale on " + "device, no host sync). Writes float scale to scale_out."); + + m.def("amax_fp16", + [](uintptr_t x_fp16, uintptr_t amax_buf, + int n, uintptr_t stream) { + return flash_rt::kernels::minimax_remover::amax_fp16( + to_ptr(x_fp16), to_ptr(amax_buf), n, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("amax_buf"), + py::arg("n"), py::arg("stream") = 0, + "Grid-stride amax reduction into amax_buf via atomicMax. " + "Caller must zero amax_buf before first call. Multiple calls " + "accumulate (for multi-tensor shared-scale quantization)."); + + m.def("quantize_fp16_fp8_with_amax", + [](uintptr_t x_fp16, uintptr_t y_fp8, + uintptr_t amax_buf, uintptr_t scale_out, + int n, uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + quantize_fp16_fp8_with_amax( + to_ptr(x_fp16), to_ptr(y_fp8), + to_ptr(amax_buf), to_ptr(scale_out), + n, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("y_fp8"), + py::arg("amax_buf"), py::arg("scale_out"), + py::arg("n"), py::arg("stream") = 0, + "Quantize fp16→fp8 using pre-computed amax in amax_buf. " + "Writes float scale to scale_out."); } diff --git a/docs/minimax_remover_usage.md b/docs/minimax_remover_usage.md index 50dafce5..9fe02c96 100644 --- a/docs/minimax_remover_usage.md +++ b/docs/minimax_remover_usage.md @@ -61,14 +61,20 @@ cmake --build build -j --target flash_rt_minimax_remover | `fp16_rms_silu_ncdhw` | `flash_rt_minimax_remover` | `WanRMS_norm` + `F.silu` two-pass in every `WanResidualBlock` | fused single-pass; eliminates intermediate tensor R/W | | `fp16_rms_norm_ndhwc` | `flash_rt_minimax_remover` | channels-last (NDHWC) variant of the above for attention-block norm | keeps pipeline in CL → eliminates nchw↔nhwc conversion | | `fp16_rms_silu_ndhwc` | `flash_rt_minimax_remover` | channels-last fused norm+silu for residual blocks | CL-native, contiguous C reads → faster than NCDHW variant | +| `fp8_conv3d_mm_ndhwc_fp16out` | `flash_rt_minimax_remover` | cuDNN fp16 conv3d for applicable 3×3×3 causal convs | FP8 e4m3 implicit-GEMM (no im2col materialization); per-channel weight dequant; fp16 in/out | +| `amax_fp16` + `quantize_fp16_fp8_with_amax` | `flash_rt_minimax_remover` | PyTorch multi-pass `abs().max()` + scale + cast for activation quantization | fused 2-pass device-side amax + quantize; no host sync; shared scale across cache+new frames | | channels-last pipeline | Python (`_vae_opt.py`) | Conv3d weight → CL, WanCausalConv3d → CL-preserving forward | eliminates ~97% of nchw↔nhwc conversion kernels (~280 ms / decode) | | `WanUpsample` patch | Python (no kernel) | `x.float().type_as(x)` for nearest-exact upsample | eliminates redundant fp32 cast (index-only op, fp16 == fp32) | -The VAE stays fp16 throughout (cuDNN dispatches fp16 tensorop conv kernels). -Only norm/activation ops are replaced — preserving fp16's 10-bit mantissa -end-to-end (PSNR ~41 dB vs fp16 reference). If the module is not built, -`install_vae_optimizations()` raises a clear `ImportError` with the rebuild -command. +The VAE stays fp16 at the interface. Norm/activation ops use fp16-native +kernels (zero precision loss). Applicable 3×3×3 causal conv3d layers +(Ci % 32 == 0) use the FP8 implicit-GEMM kernel — fp16 activations are +quantized to FP8 e4m3 on-the-fly, the MMA runs on tensor cores in FP8, +and the result is dequantized back to fp16 via per-output-channel scales +(PSNR ~39 dB vs fp16 reference). Non-applicable convs (1×1×1, 3×1×1, +Ci not divisible by 32) fall back to cuDNN fp16. If the module is not +built, `install_vae_optimizations()` raises a clear `ImportError` with +the rebuild command. #### Channels-last 3D pipeline @@ -94,6 +100,43 @@ plus a ~1.3× per-conv speedup from cuDNN's preferred CL algorithm. Zero precision loss (PSNR 40.8 dB vs fp16 reference, identical to the NCDHW path within fp16 rounding). +#### FP8 implicit-GEMM conv3d pipeline + +The dominant VAE cost is cuDNN's fp16 conv3d (~1549 ms / decode, 64% of +wall time). The `fp8_conv3d_mm_ndhwc_fp16out` kernel replaces cuDNN for +applicable 3×3×3 causal conv3d layers (Ci % 32 == 0, Co >= 8) with a +**hand-rolled FP8 e4m3 implicit-GEMM** that runs entirely on tensor cores. + +Key design: + +- **No im2col materialization.** The kernel computes im2col indices + on-the-fly inside the MMA loop, reading activations directly from the + original NDHWC tensor via `cp.async`. This avoids the ~268 MB + intermediate matrix that made the naive FP8 im2col + `_scaled_mm` + approach 3.5× slower than cuDNN. +- **Virtual cache concat.** Two input pointers (`cache_x_fp8` + + `new_x_fp8`) replace `torch.cat`, saving the concat kernel. The + temporal addressing `d_in = t_out + kt` reads from cache for + `d_in < T_cache`, else from new — zero-copy causal sliding window. +- **Direct causal output.** Output is `T_new` frames (not + `T_cache + T_new`), avoiding the slice + wasted output write. +- **Per-output-channel weight quantization.** Weights are quantized + once at install time with per-Co amax scales, maximizing FP8 dynamic + range utilization. The dequant alpha = `act_scale × w_scale[co]` is + applied in the bias-fused epilogue. +- **Fused activation quantization.** `amax_fp16` + atomicMax computes + a shared per-tensor scale over cache+new (2-pass, no host sync), then + `quantize_fp16_fp8_with_amax` scales and casts to FP8 e4m3. +- **Tile geometry.** BLOCK_M=128, BLOCK_N=128, BLOCK_K=32, 8 warps, + 2-stage cp.async pipeline, persistent Y-major CTA raster. The MMA + instruction is + `mma.sync.aligned.kind::f8f6f4.m16n8k32.row.col.f32.e4m3.e4m3.f32`. + +Result: **12.6% decode speedup** over channels-last-only cuDNN +(10.01 s → 8.75 s), **1.98× vs baseline**. PSNR 39.3 dB (median) — +a ~1.5 dB trade for the FP8 quantization, well within the "excellent" +threshold. Enable/disable via `--fp8-conv` / `--no-fp8-conv`. + Importing `flash_rt.models.minimax_remover` always succeeds — it needs **none** of `diffusers` / `einops` / `scipy` / `triton` / `sageattention`. The kernel surface is validated lazily in each pipeline's `__init__` via @@ -181,10 +224,11 @@ MiniMax-Remover `pipe` and consumes it in place: Both paths run the VAE encode / decode from the loaded diffusers model (one-shot per segment, outside the graph). With `--vae-opt` (default), the VAE's `WanRMS_norm` / `WanResidualBlock` norm+silu sites are replaced by the -FlashRT fp16 fused kernels, all 61 `WanCausalConv3d` weights are converted -to channels-last 3D, and the norm kernels are swapped to NDHWC variants so -the entire norm→conv pipeline stays in channels-last (see [VAE fused -kernels](#vae-fused-kernels-standalone-module-opt-in)). +FlashRT fp16 fused kernels, all `WanCausalConv3d` weights are converted +to channels-last 3D, the norm kernels are swapped to NDHWC variants so +the entire norm→conv pipeline stays in channels-last, and applicable 3×3×3 +causal conv3d layers are replaced by the FP8 implicit-GEMM kernel +(see [VAE fused kernels](#vae-fused-kernels-standalone-module-opt-in)). No MiniMax-Remover source is imported; the `pipe` is duck-typed through `.transformer` / `.vae` / `.scheduler` / `.video_processor` and the `expand_masks` / `resize` helpers. @@ -216,27 +260,30 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the | Clip (frames, resolution) | Stack | Wall time | Speedup vs fp16 ref | PSNR mean / worst vs fp16 ref | cosine mean | |--------------------------|-------|-----------|---------------------|-------------------------------|-------------| -| tennis (70 frames, 432x240) | fp16 reference (`--no-flashrt`) | 15.60 s | 1.0x | — | — | -| tennis (70 frames, 432x240) | FlashRT FP8 (no VAE opt) | 11.78 s | 1.32x | 40.9 / 37.4 dB | 0.99981 | -| tennis (70 frames, 432x240) | **FlashRT FP8 + VAE opt + CL (default)** | **10.03 s** | **1.55x** | **40.8 / 37.1 dB** | 0.99981 | -| tennis (70 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 9.52 s | 1.64x | 7.0 / 6.2 dB | 0.00000 (broken) | +| tennis (70 frames, 432x240) | fp16 reference (`--no-flashrt`) | 17.33 s | 1.0x | — | — | +| tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL (`--no-fp8-conv`) | 10.01 s | 1.73x | 40.8 / 37.0 dB | 0.99981 | +| tennis (70 frames, 432x240) | **FlashRT FP8 + VAE opt + CL + FP8 conv3d (default)** | **8.75 s** | **1.98x** | **39.3 / 36.5 dB** | 0.99981 | +| tennis (70 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 9.52 s | 1.82x | 7.0 / 6.2 dB | 0.00000 (broken) | | bmx-trees (80 frames, 432x240) | fp16 reference (`--no-flashrt`) | 19.76 s | 1.0x | — | — | | bmx-trees (80 frames, 432x240) | FlashRT FP8 (default) | 13.24 s | **1.49x** | 35.1 / 32.0 dB | 0.99912 | | bmx-trees (80 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 10.72 s | 1.84x | 7.3 / 7.0 dB | 0.00000 (broken) | -> Tennis numbers are from a same-session A/B (`--no-flashrt` vs `--vae-opt`) -> measured on RTX 5060 Ti, CUDA 13.0, cuDNN 9.2, PyTorch 2.12. Peak VRAM: -> fp16 ref 3.52 GB, FlashRT default 2.49 GB. +> Tennis numbers are from a same-session A/B (`--no-flashrt` vs `--vae-opt` +> with/without `--fp8-conv`) measured on RTX 5060 Ti, CUDA 13.0, cuDNN 9.2, +> PyTorch 2.12. Peak VRAM: fp16 ref 3.67 GB, FlashRT FP8 conv3d 2.51 GB. Takeaways: -- **FP8 + VAE opt + channels-last is the recommended default**: 1.55x - faster than the fp16 reference with PSNR 40.8 dB (median) on full-frame - tennis clip, peak VRAM reduced from 3.52 GB to 2.49 GB. The fused VAE - kernels + channels-last pipeline together contribute ~17% speedup on - top of FP8 alone (11.78 s -> 10.03 s) at no precision cost. -- **FP8 alone** (without VAE kernels) is already 1.32x faster with - cosine >= 0.999 and PSNR 35-41 dB. +- **FP8 + VAE opt + CL + FP8 conv3d is the recommended default**: 1.98x + faster than the fp16 reference with PSNR 39.3 dB (median) on full-frame + tennis clip, peak VRAM reduced from 3.67 GB to 2.51 GB. The FP8 + implicit-GEMM conv3d contributes an additional 12.6% speedup on top of + the channels-last pipeline (10.01 s → 8.75 s) at a ~1.5 dB PSNR cost. +- **FP8 conv3d vs channels-last-only cuDNN**: the hand-rolled implicit-GEMM + kernel (no im2col materialization, virtual cache concat, per-channel + weight dequant) beats cuDNN's fp16 conv3d while staying in fp16 at the + interface. Disable with `--no-fp8-conv` to recover the 1.5 dB PSNR if + absolute precision is preferred over speed. - **NVFP4 is faster but unusable on full-frame latents**: cosine collapses to ~0.0 and PSNR to ~7 dB (median per-pixel deviation ~85/255). The FP4 quantisation error accumulates over the large full-frame activations and the @@ -269,7 +316,9 @@ to the quantised GEMMs and the attention backend. | VAE `fp16_rms_silu_ncdhw` kernel | cosine vs fp32 reference | >= 0.9999999 | | VAE `fp16_rms_norm_ndhwc` (CL) kernel | cosine vs fp32 reference | >= 0.9999999 | | VAE `fp16_rms_silu_ndhwc` (CL) kernel | cosine vs fp32 reference | >= 0.9999999 | -| End-to-end FP8 + VAE opt + CL (full-frame) | PSNR vs fp16 ref | 40.8 dB (median) / >= 37 dB (worst frame) | +| VAE `fp8_conv3d_mm` kernel | cosine vs fp16 F.conv3d | >= 0.9993 (per-layer) | +| End-to-end FP8 + VAE opt + CL + FP8 conv3d (full-frame) | PSNR vs fp16 ref | 39.3 dB (median) / >= 36.5 dB (worst frame) | +| End-to-end FP8 + VAE opt + CL only (no FP8 conv3d) | PSNR vs fp16 ref | 40.8 dB (median) / >= 37.0 dB (worst frame) | | Attention — SageAttention QK-int8 PV-fp8 (`sage_fp8`, default) | cosine vs SDPA | 0.9993 | | Attention — SageAttention QK-int8 PV-fp16 (`sage_fp16`) | cosine vs SDPA | 0.9999 | | NVFP4 W4A4 GEMM | cosine vs fp16 matmul | >= 0.999 | @@ -288,7 +337,8 @@ the steady state; the FP8 path calibrates on the first call then freezes. | Flag | Default | Effect | |------|---------|--------| -| `--vae-opt` / `--no-vae-opt` | **enabled** | Install FlashRT fp16 fused VAE kernels (`fp16_rms_norm_ncdhw`, `fp16_rms_silu_ncdhw`, NDHWC variants) + channels-last 3D pipeline (Conv3d weights → CL, eliminates nchw↔nhwc conversion) + `WanUpsample` cast elimination. Requires the `flash_rt_minimax_remover` module. | +| `--vae-opt` / `--no-vae-opt` | **enabled** | Install FlashRT fp16 fused VAE kernels (`fp16_rms_norm_ncdhw`, `fp16_rms_silu_ncdhw`, NDHWC variants) + channels-last 3D pipeline (Conv3d weights → CL, eliminates nchw↔nhwc conversion) + FP8 implicit-GEMM conv3d + `WanUpsample` cast elimination. Requires the `flash_rt_minimax_remover` module. | +| `--fp8-conv` / `--no-fp8-conv` | **enabled** | Use FP8 implicit-GEMM conv3d kernel for applicable 3×3×3 causal convs (requires `--vae-opt`). Trades ~1.5 dB PSNR for ~13% decode speedup. | | `--use-fp4` | off | Use NVFP4 (W4A4) instead of the default FP8 (W8A8). Small-region only. | | `--no-flashrt` | off | Run the reference diffusers fp16 path (no FlashRT). | diff --git a/examples/minimax_remover_quickstart.py b/examples/minimax_remover_quickstart.py index ebb9ffa8..4eb3adb9 100644 --- a/examples/minimax_remover_quickstart.py +++ b/examples/minimax_remover_quickstart.py @@ -597,9 +597,14 @@ def parse_args() -> argparse.Namespace: help="Apply FlashRT VAE optimisations: fused fp16 RMS_norm " "+ RMS_SiLU CUDA kernels and WanUpsample cast " "elimination. The VAE is ~60%% of wall time on the " - "FP8 path; this cuts it significantly. PSNR >= 40 dB " + "FP8 path; this cuts it significantly. PSNR >= 39 dB " "vs the fp16 VAE reference. (default: enabled; use " "--no-vae-opt to disable)") + p.add_argument("--fp8-conv", action=argparse.BooleanOptionalAction, + default=True, + help="Use FP8 implicit-GEMM conv3d kernel for applicable " + "3x3x3 causal convs (requires --vae-opt). Trades ~1.5 " + "dB PSNR for ~13%% decode speedup. (default: enabled)") return p.parse_args() @@ -656,7 +661,8 @@ def main() -> None: if args.vae_opt: from flash_rt.models.minimax_remover._vae_opt import install_vae_optimizations - stats = install_vae_optimizations(pipe.vae) + stats = install_vae_optimizations(pipe.vae, + use_fp8_conv=args.fp8_conv) print(f" VAE optimised: {stats}") if args.no_flashrt: diff --git a/flash_rt/models/minimax_remover/_vae_opt.py b/flash_rt/models/minimax_remover/_vae_opt.py index 0871f9ee..82868c5f 100644 --- a/flash_rt/models/minimax_remover/_vae_opt.py +++ b/flash_rt/models/minimax_remover/_vae_opt.py @@ -199,12 +199,15 @@ def _no_cast_forward(self, x): return sum(1 for m in vae.modules() if isinstance(m, WanUpsample)) -def install_vae_optimizations(vae, dtype=None) -> Dict: +def install_vae_optimizations(vae, dtype=None, use_fp8_conv: bool = True) -> Dict: """Apply VAE optimizations: fp16-native fused RMS_norm + RMS_SiLU kernels - + WanUpsample cast elimination. + + WanUpsample cast elimination + channels-last 3D pipeline + FP8 + implicit-GEMM conv3d. No dtype cast is applied -- the VAE stays fp16. Only norm/activation - ops are replaced with FlashRT fp16 CUDA kernels. + ops are replaced with FlashRT fp16 CUDA kernels, and applicable 3x3x3 + conv3d layers are replaced with FP8 implicit-GEMM kernels (fp16 in/out, + FP8 e4m3 internal compute). Requires the standalone ``flash_rt_minimax_remover`` module, built with: cmake -DFLASHRT_ENABLE_MINIMAX_REMOVER=ON -DGPU_ARCH=120 ... @@ -212,6 +215,8 @@ def install_vae_optimizations(vae, dtype=None) -> Dict: Args: vae: loaded ``diffusers.AutoencoderKLWan`` instance. dtype: ignored (kept for API compat with the old bf16 interface). + use_fp8_conv: if True, replace applicable 3x3x3 conv3d with the + FP8 implicit-GEMM kernel (default True). Returns: stats dict. @@ -226,9 +231,11 @@ def install_vae_optimizations(vae, dtype=None) -> Dict: n_fused_blocks = install_flashrt_fp16_rms_norm(vae) n_upsample = _install_wan_upsample_no_cast(vae) _install_channels_last_pipeline(vae) + n_fp8_conv = _install_fp8_conv3d_pipeline(vae, enabled=use_fp8_conv) return { "n_fused_res_blocks": n_fused_blocks, "n_upsample_nocast": n_upsample, + "n_fp8_conv3d": n_fp8_conv, "vae_dtype": str(next(vae.parameters()).dtype), } @@ -347,6 +354,183 @@ def forward(self, x): return n +# ================================================================ +# FP8 implicit-GEMM conv3d pipeline +# ================================================================ + +_FP8_E4M3_MAX = 448.0 + + +def _prequantize_conv3d_weight(conv): + """Pre-quantize a WanCausalConv3d 3x3x3 weight to FP8 e4m3. + + Returns (w_fp8 [Co,3,3,3,Ci] fp8, w_scale [Co] float32) or + (None, None) if the conv is not applicable. + """ + kt, kh, kw = conv.kernel_size + Ci, Co = conv.in_channels, conv.out_channels + if ((kt, kh, kw) != (3, 3, 3) or Ci % 32 != 0 or Co < 8 or + conv.groups != 1): + return None, None + + w = conv.weight.data + if not w.is_contiguous(memory_format=torch.channels_last_3d): + w = w.to(memory_format=torch.channels_last_3d) + w_perm = w.permute(0, 2, 3, 4, 1).contiguous().float() + + w_scale = w_perm.reshape(Co, -1).abs().amax(dim=1) / _FP8_E4M3_MAX + w_scale = w_scale.clamp(min=1e-6) + + w_scaled = w_perm / w_scale.reshape(-1, 1, 1, 1, 1) + w_fp8 = w_scaled.clamp(-_FP8_E4M3_MAX, _FP8_E4M3_MAX).to( + torch.float8_e4m3fn) + return w_fp8.contiguous(), w_scale + + +def _fp8_conv3d_forward(self, x, cache_x=None): + """FP8 implicit-GEMM conv3d forward for 3x3x3 causal convs. + + Quantizes fp16 activations to FP8 e4m3 with a shared per-tensor + scale (computed over cache+new), runs the fused implicit-GEMM + MMA kernel with per-output-channel dequant, and returns fp16 + output in channels-last 3D format. + """ + B, Ci, T_new, H, W = x.shape + Co = self._fp8_w.shape[0] + stream = torch.cuda.current_stream().cuda_stream + + if not x.is_contiguous(memory_format=torch.channels_last_3d): + x = x.to(memory_format=torch.channels_last_3d) + + if cache_x is not None and cache_x.shape[2] >= 2: + cache_2 = cache_x[:, :, -2:] + if not cache_2.is_contiguous(memory_format=torch.channels_last_3d): + cache_2 = cache_2.to(memory_format=torch.channels_last_3d) + elif cache_x is not None and cache_x.shape[2] >= 1: + cache_2 = torch.empty( + B, Ci, 2, H, W, dtype=x.dtype, device=x.device, + memory_format=torch.channels_last_3d).zero_() + cache_2[:, :, 1:2] = cache_x[:, :, -1:] + else: + cache_2 = torch.empty( + B, Ci, 2, H, W, dtype=x.dtype, device=x.device, + memory_format=torch.channels_last_3d).zero_() + + n_cache = cache_2.numel() + n_new = x.numel() + + self._fp8_amax.zero_() + _fvk.amax_fp16(cache_2.data_ptr(), self._fp8_amax.data_ptr(), + n_cache, stream) + _fvk.amax_fp16(x.data_ptr(), self._fp8_amax.data_ptr(), + n_new, stream) + + cache_fp8 = torch.empty( + n_cache, dtype=torch.float8_e4m3fn, device=x.device) + new_fp8 = torch.empty( + n_new, dtype=torch.float8_e4m3fn, device=x.device) + _fvk.quantize_fp16_fp8_with_amax( + cache_2.data_ptr(), cache_fp8.data_ptr(), + self._fp8_amax.data_ptr(), self._fp8_scale.data_ptr(), + n_cache, stream) + _fvk.quantize_fp16_fp8_with_amax( + x.data_ptr(), new_fp8.data_ptr(), + self._fp8_amax.data_ptr(), self._fp8_scale.data_ptr(), + n_new, stream) + + alpha_vec = self._fp8_scale * self._w_scale + + out = torch.empty( + B, Co, T_new, H, W, dtype=torch.float16, device=x.device, + memory_format=torch.channels_last_3d) + + rc = _fvk.fp8_conv3d_mm_ndhwc_fp16out( + cache_fp8.data_ptr(), new_fp8.data_ptr(), + self._fp8_w.data_ptr(), out.data_ptr(), + self.bias.data_ptr() if self.bias is not None else 0, + alpha_vec.data_ptr(), + B, 2, T_new, H, W, Ci, Co, stream) + + if rc != 0: + logger.warning("[fp8_conv3d_mm] kernel rc=%d, falling back to " + "cuDNN for [%d,%d,%d,%d,%d]", rc, B, Ci, T_new, H, W) + padding = list(self._padding) + if cache_x is not None and self._padding[4] > 0: + cx = cache_x.to(x.device) + if not cx.is_contiguous(memory_format=torch.channels_last_3d): + cx = cx.to(memory_format=torch.channels_last_3d) + x_cat = torch.cat([cx, x], dim=2) + padding[4] -= cx.shape[2] + else: + x_cat = x + x_pad = F.pad(x_cat, padding) + return F.conv3d(x_pad, self.weight, self.bias, self.stride, + self.padding, self.dilation, self.groups) + return out + + +def _install_fp8_conv3d_pipeline(vae, enabled: bool = True) -> int: + """Pre-quantize applicable Conv3d weights to FP8 e4m3 and patch + WanCausalConv3d.forward to dispatch to the FP8 implicit-GEMM kernel. + + For each 3x3x3 WanCausalConv3d with Ci % 32 == 0 and Co >= 8: + - Pre-quantize weight to FP8 e4m3 with per-output-channel scale. + - Store fp8 weight, scale, and scratch buffers as non-persistent + buffers on the module. + + Non-applicable layers (1x1x1, 3x1x1, Ci%32!=0, Co<8) fall back to + the channels-last cuDNN path. + """ + from diffusers.models.autoencoders.autoencoder_kl_wan import ( + WanCausalConv3d) + + if not enabled: + return 0 + + n_fp8 = 0 + n_skip = 0 + for m in vae.modules(): + if isinstance(m, WanCausalConv3d): + w_fp8, w_scale = _prequantize_conv3d_weight(m) + if w_fp8 is not None: + device = m.weight.device + m.register_buffer('_fp8_w', w_fp8.to(device), + persistent=False) + m.register_buffer('_w_scale', w_scale.to(device), + persistent=False) + m.register_buffer('_fp8_amax', + torch.empty(1, dtype=torch.float32, + device=device), + persistent=False) + m.register_buffer('_fp8_scale', + torch.empty(1, dtype=torch.float32, + device=device), + persistent=False) + n_fp8 += 1 + else: + m._fp8_w = None + n_skip += 1 + + if n_fp8 == 0: + logger.info("[minimax-vae] FP8 conv3d: 0 layers applicable") + return 0 + + _saved_forward = WanCausalConv3d.forward + + def _fp8_dispatch_forward(self, x, cache_x=None): + if getattr(self, '_fp8_w', None) is not None: + return _fp8_conv3d_forward(self, x, cache_x) + return _saved_forward(self, x, cache_x) + + WanCausalConv3d.forward = _fp8_dispatch_forward + WanCausalConv3d._flashrt_fp8_patched = True + + logger.info("[minimax-vae] FP8 implicit-GEMM conv3d: %d layers " + "quantized, %d skipped (non-3x3x3 or Ci%%32!=0)", + n_fp8, n_skip) + return n_fp8 + + @torch.no_grad() def profile_vae(pipe, images_tensor, masks_infer, height, width, num_frames, iterations=6, num_inference_steps=12, seed=42, From 13517b0751145db1f4dc494f4a4f8655e9264f59 Mon Sep 17 00:00:00 2001 From: chenping Date: Tue, 7 Jul 2026 16:07:13 +0800 Subject: [PATCH 13/23] feat(minimax-remover): add fused norm+silu+amax kernel and running-max amax pipeline Add three new fused NDHWC kernels and a dual-quantize launcher to further accelerate the FP8 conv3d VAE pipeline: New kernels (flash_rt_minimax_remover module): - fp16_rms_silu_amax_ndhwc: fuses amax computation into the norm+silu pass via warp-level reduction + atomicMax. Eliminates a separate full-tensor read for amax per conv layer. - fp16_rms_silu_quant_fp8_ndhwc: fuses norm+silu with FP8 e4m3 quantization (reads pre-computed amax from device, no fp16 write). - fp16_rms_silu_amax_quant_fp8_ndhwc: 2-pass combined launcher that produces only fp8 output + scale (no fp16 intermediate). - quantize_fp16_fp8_with_amax_dual: quantizes two fp16 buffers (cache + new) with a shared amax in a single kernel launch. Pipeline optimizations (_vae_opt.py): - Running-max amax: the norm module accumulates amax into a persistent device-side buffer (atomicMax) shared with the sister conv module. Since cache_x was a previous output of the same norm, its amax is already covered -- the conv skips the cache amax pass entirely, saving one full read of the 2-frame cache tensor per layer. - Sister-norm linkage: each FP8-enabled conv gets a reference to its norm module to access the pre-computed amax (set up during _install_fp8_conv3d_pipeline). - Dual-quantize: cache + new quantized in a single launch. Result (RTX 5060 Ti, tennis 70 frames 432x240): Previous: 8.75s, 39.3 dB PSNR median, 1.98x vs baseline Current: 8.58s, 39.9 dB PSNR median, 2.02x vs baseline Both speed and precision improved simultaneously -- the running-max scale is temporally consistent across frames, reducing quantization noise compared to per-frame dynamic amax. --- CMakeLists.txt | 3 +- .../fp16_quant_fp8_per_tensor.cu | 57 +++++ .../fp16_quant_fp8_per_tensor.cuh | 9 + .../fp16_rms_silu_fp8_ndhwc.cu | 230 ++++++++++++++++++ .../fp16_rms_silu_fp8_ndhwc.cuh | 77 ++++++ csrc/minimax_remover_extra_bindings.cpp | 85 +++++++ docs/minimax_remover_usage.md | 51 ++-- flash_rt/models/minimax_remover/_vae_opt.py | 138 +++++++++-- 8 files changed, 612 insertions(+), 38 deletions(-) create mode 100644 csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cu create mode 100644 csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cuh diff --git a/CMakeLists.txt b/CMakeLists.txt index cc0c8e43..ec89277a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -968,7 +968,8 @@ if(FLASHRT_ENABLE_MINIMAX_REMOVER) csrc/kernels/minimax_remover/fp16_rms_silu_ncdhw.cu csrc/kernels/minimax_remover/fp16_rms_norm_ndhwc.cu csrc/kernels/minimax_remover/fp8_conv3d_mm_ndhwc_fp16out.cu - csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cu) + csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cu + csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cu) set_target_properties(flash_rt_minimax_remover PROPERTIES CUDA_STANDARD 17 LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cu b/csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cu index 3498d90d..505bc8e0 100644 --- a/csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cu +++ b/csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cu @@ -141,6 +141,63 @@ int quantize_fp16_fp8_with_amax( return (e == cudaSuccess) ? 0 : -2; } +// ── Dual quantize: two buffers, one shared amax, one launch ── +__global__ void quantize_fp16_to_fp8_dual_kernel( + const __half* __restrict__ x1, + __nv_fp8_e4m3* __restrict__ y1, int n1, + const __half* __restrict__ x2, + __nv_fp8_e4m3* __restrict__ y2, int n2, + const float* amax_in, float* scale_out) +{ + __shared__ float s_inv_scale; + if (threadIdx.x == 0) { + float amax = fmaxf(*amax_in, 0.0f); + float scale = amax * (1.0f / 448.0f); + if (scale < 1e-6f) scale = 1e-6f; + if (scale_out != nullptr) *scale_out = scale; + s_inv_scale = 1.0f / scale; + } + __syncthreads(); + float inv_scale = s_inv_scale; + + // Grid-stride over both buffers using a unified index space + int total = n1 + n2; + int idx = blockIdx.x * Q_THREADS + threadIdx.x; + int stride = Q_THREADS * gridDim.x; + for (int i = idx; i < total; i += stride) { + if (i < n1) { + float val = __half2float(x1[i]) * inv_scale; + val = fminf(fmaxf(val, -448.0f), 448.0f); + y1[i] = __nv_fp8_e4m3(val); + } else { + int j = i - n1; + float val = __half2float(x2[j]) * inv_scale; + val = fminf(fmaxf(val, -448.0f), 448.0f); + y2[j] = __nv_fp8_e4m3(val); + } + } +} + +int quantize_fp16_fp8_with_amax_dual( + const void* x1_fp16, void* y1_fp8, int n1, + const void* x2_fp16, void* y2_fp8, int n2, + const void* amax_buf, void* scale_out, + cudaStream_t stream) +{ + if (n1 <= 0 && n2 <= 0) return -1; + int total = n1 + n2; + int blocks = (total + Q_THREADS - 1) / Q_THREADS; + quantize_fp16_to_fp8_dual_kernel<<>>( + reinterpret_cast(x1_fp16), + reinterpret_cast<__nv_fp8_e4m3*>(y1_fp8), n1, + reinterpret_cast(x2_fp16), + reinterpret_cast<__nv_fp8_e4m3*>(y2_fp8), n2, + reinterpret_cast(amax_buf), + reinterpret_cast(scale_out)); + cudaError_t e = cudaGetLastError(); + return (e == cudaSuccess) ? 0 : -2; +} + } // namespace minimax_remover } // namespace kernels } // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cuh b/csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cuh index a5dc6b8d..970d5f20 100644 --- a/csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cuh +++ b/csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cuh @@ -40,6 +40,15 @@ int quantize_fp16_fp8_with_amax( const void* amax_buf, void* scale_out, int n, cudaStream_t stream); +// Dual quantize: quantizes TWO fp16 buffers with the SAME shared +// amax in a single kernel launch. Saves one launch vs calling +// quantize_fp16_fp8_with_amax twice. Writes one shared scale. +int quantize_fp16_fp8_with_amax_dual( + const void* x1_fp16, void* y1_fp8, int n1, + const void* x2_fp16, void* y2_fp8, int n2, + const void* amax_buf, void* scale_out, + cudaStream_t stream); + } // namespace minimax_remover } // namespace kernels } // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cu b/csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cu new file mode 100644 index 00000000..df45577b --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cu @@ -0,0 +1,230 @@ +// ================================================================ +// flash_rt — MiniMax-Remover fused RMSNorm+SiLU with amax / FP8 +// quantize (channels-last NDHWC). +// +// Built on the same warp-per-spatial-position pattern as +// fp16_rms_norm_ndhwc, with three entry points: +// +// 1. fp16_rms_silu_amax_ndhwc — norm+silu+amax → fp16 + amax +// 2. fp16_rms_silu_quant_fp8_ndhwc — norm+silu+quant → fp8 (pre-comp amax) +// 3. fp16_rms_silu_amax_quant_fp8_ndhwc — 2-pass: → fp8 + scale +// +// Template kernel controls: +// kWriteFp16 : write the fp16 normed+silu output +// kComputeAmax : accumulate |output| via atomicMax +// kQuantizeFp8 : read device amax and write fp8 output +// ================================================================ + +#include "fp16_rms_silu_fp8_ndhwc.cuh" + +#include +#include +#include +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +namespace { + +constexpr int kWarpsPerBlock = 4; +constexpr int kThreads = kWarpsPerBlock * 32; + +__device__ __forceinline__ float silu_f32(float x) { + return x * (1.0f / (1.0f + __expf(-x))); +} + +//atomicMax on reinterpreted int preserves ordering for non-negative floats. +__device__ __forceinline__ void atomic_max_f32(float* addr, float val) { + if (val > 0.0f) { + atomicMax(reinterpret_cast(addr), __float_as_int(val)); + } +} + +template +__global__ void fused_rms_silu_kernel( + const __half* __restrict__ x, + const __half* __restrict__ gamma, + const __half* __restrict__ bias, + __half* __restrict__ y_fp16, + __nv_fp8_e4m3* __restrict__ y_fp8, + float* __restrict__ amax_buf, + const float* __restrict__ amax_in, + float* __restrict__ scale_out, + int B, int C, int T, int H, int W, + int total_spatial, + float eps) +{ + const int warp_id = threadIdx.x >> 5; + const int lane = threadIdx.x & 31; + + const int spatial_idx = blockIdx.x * kWarpsPerBlock + warp_id; + if (spatial_idx >= total_spatial) return; + + const long long base = (long long)spatial_idx * C; + const __half* x_row = x + base; + + // ── Phase 1: read x, compute sum-of-squares ────────────────── + float sum_sq = 0.0f; + for (int c = lane; c < C; c += 32) { + float v = __half2float(x_row[c]); + sum_sq = fmaf(v, v, sum_sq); + } + #pragma unroll + for (int off = 16; off > 0; off >>= 1) + sum_sq += __shfl_xor_sync(0xFFFFFFFF, sum_sq, off); + + const float inv_rms = + rsqrtf(sum_sq * (1.0f / static_cast(C)) + eps); + + // ── Optional: broadcast amax → inv_scale for FP8 quantize ──── + float inv_scale = 0.0f; + if constexpr (kQuantizeFp8) { + __shared__ float s_inv_scale; + if (threadIdx.x == 0) { + float amax = fmaxf(*amax_in, 0.0f); + float scale = amax * (1.0f / 448.0f); + if (scale < 1e-6f) scale = 1e-6f; + if (scale_out) *scale_out = scale; + s_inv_scale = 1.0f / scale; + } + __syncthreads(); + inv_scale = s_inv_scale; + } + + // ── Phase 2: normalise, apply gamma+bias, silu, write ──────── + float local_amax = 0.0f; + for (int c = lane; c < C; c += 32) { + float v = __half2float(x_row[c]) * inv_rms + * __half2float(gamma[c]); + if (bias != nullptr) + v += __half2float(bias[c]); + v = silu_f32(v); + + if constexpr (kWriteFp16) { + y_fp16[base + c] = __float2half_rn(v); + } + if constexpr (kComputeAmax) { + local_amax = fmaxf(local_amax, fabsf(v)); + } + if constexpr (kQuantizeFp8) { + float q = v * inv_scale; + q = fminf(fmaxf(q, -448.0f), 448.0f); + y_fp8[base + c] = __nv_fp8_e4m3(q); + } + } + + // ── Warp-level amax reduction + atomicMax ──────────────────── + if constexpr (kComputeAmax) { + #pragma unroll + for (int off = 16; off > 0; off >>= 1) + local_amax = fmaxf(local_amax, __shfl_xor_sync(0xFFFFFFFF, local_amax, off)); + // lane 0 has the warp-wide amax + if (lane == 0 && local_amax > 0.0f) { + atomic_max_f32(amax_buf, local_amax); + } + } +} + +} // namespace + +// ── (1) Fused norm+silu+amax → fp16 + amax ────────────────────── +int fp16_rms_silu_amax_ndhwc( + const void* x_fp16, const void* gamma_fp16, const void* bias_fp16, + void* y_fp16, void* amax_buf, + int B, int C, int T, int H, int W, + float eps, cudaStream_t stream) +{ + if (B <= 0 || C <= 0 || T <= 0 || H <= 0 || W <= 0) return -1; + const long long total_spatial = (long long)B * T * H * W; + if (total_spatial <= 0 || total_spatial > (long long)INT32_MAX) return -4; + + const unsigned n_blocks = (unsigned)((total_spatial + kWarpsPerBlock - 1) + / kWarpsPerBlock); + fused_rms_silu_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast(gamma_fp16), + bias_fp16 ? reinterpret_cast(bias_fp16) : nullptr, + reinterpret_cast<__half*>(y_fp16), + nullptr, // y_fp8 + reinterpret_cast(amax_buf), + nullptr, nullptr, // amax_in, scale_out + B, C, T, H, W, (int)total_spatial, eps); + return static_cast(cudaGetLastError()); +} + +// ── (2) Fused norm+silu+quant → fp8 (pre-computed amax) ───────── +int fp16_rms_silu_quant_fp8_ndhwc( + const void* x_fp16, const void* gamma_fp16, const void* bias_fp16, + void* y_fp8, const void* amax_buf, void* scale_out, + int B, int C, int T, int H, int W, + float eps, cudaStream_t stream) +{ + if (B <= 0 || C <= 0 || T <= 0 || H <= 0 || W <= 0) return -1; + const long long total_spatial = (long long)B * T * H * W; + if (total_spatial <= 0 || total_spatial > (long long)INT32_MAX) return -4; + + const unsigned n_blocks = (unsigned)((total_spatial + kWarpsPerBlock - 1) + / kWarpsPerBlock); + fused_rms_silu_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast(gamma_fp16), + bias_fp16 ? reinterpret_cast(bias_fp16) : nullptr, + nullptr, // y_fp16 + reinterpret_cast<__nv_fp8_e4m3*>(y_fp8), + nullptr, // amax_buf (not computing) + reinterpret_cast(amax_buf), + reinterpret_cast(scale_out), + B, C, T, H, W, (int)total_spatial, eps); + return static_cast(cudaGetLastError()); +} + +// ── (3) 2-pass: norm+silu+amax → fp8+scale (no fp16 write) ────── +int fp16_rms_silu_amax_quant_fp8_ndhwc( + const void* x_fp16, const void* gamma_fp16, const void* bias_fp16, + void* y_fp8, void* scale_out, void* amax_buf, + int B, int C, int T, int H, int W, + float eps, cudaStream_t stream) +{ + if (B <= 0 || C <= 0 || T <= 0 || H <= 0 || W <= 0) return -1; + const long long total_spatial = (long long)B * T * H * W; + if (total_spatial <= 0 || total_spatial > (long long)INT32_MAX) return -4; + + cudaError_t e; + e = cudaMemsetAsync(amax_buf, 0, sizeof(float), stream); + if (e != cudaSuccess) return -2; + + const unsigned n_blocks = (unsigned)((total_spatial + kWarpsPerBlock - 1) + / kWarpsPerBlock); + + // Pass 1: norm+silu+amax (no write). + fused_rms_silu_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast(gamma_fp16), + bias_fp16 ? reinterpret_cast(bias_fp16) : nullptr, + nullptr, + nullptr, + reinterpret_cast(amax_buf), + nullptr, nullptr, + B, C, T, H, W, (int)total_spatial, eps); + + // Pass 2: norm+silu+quant (reads amax from pass 1). + fused_rms_silu_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast(gamma_fp16), + bias_fp16 ? reinterpret_cast(bias_fp16) : nullptr, + nullptr, + reinterpret_cast<__nv_fp8_e4m3*>(y_fp8), + nullptr, + reinterpret_cast(amax_buf), + reinterpret_cast(scale_out), + B, C, T, H, W, (int)total_spatial, eps); + + e = cudaGetLastError(); + return (e == cudaSuccess) ? 0 : -3; +} + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cuh b/csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cuh new file mode 100644 index 00000000..0a3d1597 --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cuh @@ -0,0 +1,77 @@ +// ================================================================ +// flash_rt — MiniMax-Remover fused RMSNorm+SiLU with FP8 quantize +// (channels-last NDHWC). +// +// Three entry points, all built on the same warp-per-spatial template: +// +// 1. fp16_rms_silu_amax_ndhwc +// Fused norm+silu+amax. Reads fp16 x, writes fp16 y AND +// accumulates |y| into a device-side amax buffer via atomicMax. +// Saves one full read of y compared to (norm+silu) → separate amax. +// +// 2. fp16_rms_silu_quant_fp8_ndhwc +// Fused norm+silu+quantize-to-FP8. Reads fp16 x, reads a +// pre-computed amax from device memory, quantizes and writes +// fp8 y. Does NOT write fp16 output — eliminates the fp16 +// intermediate entirely when the consumer only needs FP8. +// +// 3. fp16_rms_silu_amax_quant_fp8_ndhwc +// 2-pass launcher combining (1) and (2): pass 1 computes +// norm+silu+amax (no fp16 write); pass 2 re-reads x, computes +// norm+silu+quant. Produces ONLY fp8 output + scale. +// +// Use case: in WanResidualBlock the pattern is +// x → norm → silu → conv1(quant→FP8→MMA) +// Fusing norm+silu+amax+quant eliminates the fp16 intermediate +// between norm and conv, saving one full read+write per layer. +// ================================================================ +#pragma once +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +// (1) Fused norm+silu+amax → fp16 output + device amax. +// amax_buf must be zeroed by the caller before the first call. +// Multiple calls accumulate (atomicMax). +int fp16_rms_silu_amax_ndhwc( + const void* x_fp16, + const void* gamma_fp16, + const void* bias_fp16, + void* y_fp16, + void* amax_buf, // device float *, caller-zeroed + int B, int C, int T, int H, int W, + float eps, + cudaStream_t stream); + +// (2) Fused norm+silu+quant → fp8 output (reads pre-computed amax). +// No fp16 output is written. +int fp16_rms_silu_quant_fp8_ndhwc( + const void* x_fp16, + const void* gamma_fp16, + const void* bias_fp16, + void* y_fp8, // __nv_fp8_e4m3 * + const void* amax_buf, // device float *, pre-computed + void* scale_out, // device float *, may be nullptr + int B, int C, int T, int H, int W, + float eps, + cudaStream_t stream); + +// (3) 2-pass: norm+silu+amax+quant → fp8 output + scale. +// amax_buf is a caller-provided scratch (1 float). +// No fp16 output. +int fp16_rms_silu_amax_quant_fp8_ndhwc( + const void* x_fp16, + const void* gamma_fp16, + const void* bias_fp16, + void* y_fp8, + void* scale_out, + void* amax_buf, + int B, int C, int T, int H, int W, + float eps, + cudaStream_t stream); + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/minimax_remover_extra_bindings.cpp b/csrc/minimax_remover_extra_bindings.cpp index 33981a1b..a35b1c1e 100644 --- a/csrc/minimax_remover_extra_bindings.cpp +++ b/csrc/minimax_remover_extra_bindings.cpp @@ -18,6 +18,7 @@ #include "kernels/minimax_remover/fp16_rms_norm_ndhwc.cuh" #include "kernels/minimax_remover/fp8_conv3d_mm_ndhwc_fp16out.cuh" #include "kernels/minimax_remover/fp16_quant_fp8_per_tensor.cuh" +#include "kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cuh" namespace py = pybind11; @@ -163,4 +164,88 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { py::arg("n"), py::arg("stream") = 0, "Quantize fp16→fp8 using pre-computed amax in amax_buf. " "Writes float scale to scale_out."); + + // ── Dual quantize: two buffers, one shared amax, one launch ── + m.def("quantize_fp16_fp8_with_amax_dual", + [](uintptr_t x1_fp16, uintptr_t y1_fp8, int n1, + uintptr_t x2_fp16, uintptr_t y2_fp8, int n2, + uintptr_t amax_buf, uintptr_t scale_out, + uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + quantize_fp16_fp8_with_amax_dual( + to_ptr(x1_fp16), to_ptr(y1_fp8), n1, + to_ptr(x2_fp16), to_ptr(y2_fp8), n2, + to_ptr(amax_buf), + scale_out ? to_ptr(scale_out) : nullptr, + to_stream(stream)); + }, + py::arg("x1_fp16"), py::arg("y1_fp8"), py::arg("n1"), + py::arg("x2_fp16"), py::arg("y2_fp8"), py::arg("n2"), + py::arg("amax_buf"), py::arg("scale_out") = 0, + py::arg("stream") = 0, + "Dual quantize: two fp16 buffers → fp8 with shared amax in " + "one kernel launch. Saves one launch vs two separate calls."); + + // ── Fused norm+silu+amax / norm+silu+quant_fp8 (NDHWC) ── + m.def("fp16_rms_silu_amax_ndhwc", + [](uintptr_t x_fp16, uintptr_t gamma_fp16, uintptr_t bias_fp16, + uintptr_t y_fp16, uintptr_t amax_buf, + int B, int C, int T, int H, int W, + float eps, uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + fp16_rms_silu_amax_ndhwc( + to_ptr(x_fp16), to_ptr(gamma_fp16), + bias_fp16 ? to_ptr(bias_fp16) : nullptr, + to_ptr(y_fp16), to_ptr(amax_buf), + B, C, T, H, W, eps, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("gamma_fp16"), py::arg("bias_fp16"), + py::arg("y_fp16"), py::arg("amax_buf"), + py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), + py::arg("eps") = 1e-6f, py::arg("stream") = 0, + "Fused FP16 NDHWC RMSNorm+SiLU+amax. Writes fp16 output and " + "accumulates |output| into amax_buf via atomicMax (caller must " + "zero amax_buf before first call). Saves one full read of y " + "vs separate norm+silu then amax."); + + m.def("fp16_rms_silu_quant_fp8_ndhwc", + [](uintptr_t x_fp16, uintptr_t gamma_fp16, uintptr_t bias_fp16, + uintptr_t y_fp8, uintptr_t amax_buf, uintptr_t scale_out, + int B, int C, int T, int H, int W, + float eps, uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + fp16_rms_silu_quant_fp8_ndhwc( + to_ptr(x_fp16), to_ptr(gamma_fp16), + bias_fp16 ? to_ptr(bias_fp16) : nullptr, + to_ptr(y_fp8), to_ptr(amax_buf), + scale_out ? to_ptr(scale_out) : nullptr, + B, C, T, H, W, eps, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("gamma_fp16"), py::arg("bias_fp16"), + py::arg("y_fp8"), py::arg("amax_buf"), py::arg("scale_out") = 0, + py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), + py::arg("eps") = 1e-6f, py::arg("stream") = 0, + "Fused FP16 NDHWC RMSNorm+SiLU → FP8 e4m3 quantize. Reads " + "pre-computed amax from device. Does NOT write fp16 output — " + "eliminates the fp16 intermediate between norm and conv."); + + m.def("fp16_rms_silu_amax_quant_fp8_ndhwc", + [](uintptr_t x_fp16, uintptr_t gamma_fp16, uintptr_t bias_fp16, + uintptr_t y_fp8, uintptr_t scale_out, uintptr_t amax_buf, + int B, int C, int T, int H, int W, + float eps, uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + fp16_rms_silu_amax_quant_fp8_ndhwc( + to_ptr(x_fp16), to_ptr(gamma_fp16), + bias_fp16 ? to_ptr(bias_fp16) : nullptr, + to_ptr(y_fp8), to_ptr(scale_out), to_ptr(amax_buf), + B, C, T, H, W, eps, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("gamma_fp16"), py::arg("bias_fp16"), + py::arg("y_fp8"), py::arg("scale_out"), py::arg("amax_buf"), + py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), + py::arg("eps") = 1e-6f, py::arg("stream") = 0, + "2-pass fused norm+silu+amax+quant → FP8. Pass 1 computes amax " + "(no write); pass 2 re-reads x and quantizes. Produces ONLY fp8 " + "output + scale, no fp16 intermediate."); } diff --git a/docs/minimax_remover_usage.md b/docs/minimax_remover_usage.md index 9fe02c96..fcd8c499 100644 --- a/docs/minimax_remover_usage.md +++ b/docs/minimax_remover_usage.md @@ -61,9 +61,14 @@ cmake --build build -j --target flash_rt_minimax_remover | `fp16_rms_silu_ncdhw` | `flash_rt_minimax_remover` | `WanRMS_norm` + `F.silu` two-pass in every `WanResidualBlock` | fused single-pass; eliminates intermediate tensor R/W | | `fp16_rms_norm_ndhwc` | `flash_rt_minimax_remover` | channels-last (NDHWC) variant of the above for attention-block norm | keeps pipeline in CL → eliminates nchw↔nhwc conversion | | `fp16_rms_silu_ndhwc` | `flash_rt_minimax_remover` | channels-last fused norm+silu for residual blocks | CL-native, contiguous C reads → faster than NCDHW variant | +| `fp16_rms_silu_amax_ndhwc` | `flash_rt_minimax_remover` | channels-last fused norm+silu+**amax** | fuses amax into the norm+silu pass; saves 1 full read of the activation tensor per conv layer | +| `fp16_rms_silu_quant_fp8_ndhwc` | `flash_rt_minimax_remover` | channels-last fused norm+silu→**FP8 e4m3** quantize | eliminates the fp16 intermediate between norm and conv (reads pre-computed amax) | +| `fp16_rms_silu_amax_quant_fp8_ndhwc` | `flash_rt_minimax_remover` | 2-pass norm+silu+amax+quant→FP8 | combined launcher: pass 1 computes amax (no write), pass 2 quantizes; produces ONLY fp8 + scale | | `fp8_conv3d_mm_ndhwc_fp16out` | `flash_rt_minimax_remover` | cuDNN fp16 conv3d for applicable 3×3×3 causal convs | FP8 e4m3 implicit-GEMM (no im2col materialization); per-channel weight dequant; fp16 in/out | | `amax_fp16` + `quantize_fp16_fp8_with_amax` | `flash_rt_minimax_remover` | PyTorch multi-pass `abs().max()` + scale + cast for activation quantization | fused 2-pass device-side amax + quantize; no host sync; shared scale across cache+new frames | +| `quantize_fp16_fp8_with_amax_dual` | `flash_rt_minimax_remover` | two separate `quantize_fp16_fp8_with_amax` calls (cache + new) | single kernel launch quantizes two buffers with shared amax; saves 1 launch per conv layer | | channels-last pipeline | Python (`_vae_opt.py`) | Conv3d weight → CL, WanCausalConv3d → CL-preserving forward | eliminates ~97% of nchw↔nhwc conversion kernels (~280 ms / decode) | +| Running-max amax | Python (`_vae_opt.py`) | separate `amax_fp16` calls over cache + new each iteration | norm fuses amax via atomicMax into a persistent buffer shared with the sister conv; cache amax is skipped entirely (covered by running max) | | `WanUpsample` patch | Python (no kernel) | `x.float().type_as(x)` for nearest-exact upsample | eliminates redundant fp32 cast (index-only op, fp16 == fp32) | The VAE stays fp16 at the interface. Norm/activation ops use fp16-native @@ -127,15 +132,25 @@ Key design: - **Fused activation quantization.** `amax_fp16` + atomicMax computes a shared per-tensor scale over cache+new (2-pass, no host sync), then `quantize_fp16_fp8_with_amax` scales and casts to FP8 e4m3. +- **Running-max amax.** The norm module fuses amax into its + norm+silu pass via `fp16_rms_silu_amax_ndhwc` and accumulates it + into a persistent device-side buffer (atomicMax). Because cache_x + was a previous output of the same norm, its amax is already + covered — so the conv **skips the cache amax pass entirely** + (saves one full read of the 2-frame cache tensor per layer). +- **Dual-quantize launch.** `quantize_fp16_fp8_with_amax_dual` + quantizes the cache and new tensors in a single kernel launch, + reducing launch overhead by ~49 calls per decode. - **Tile geometry.** BLOCK_M=128, BLOCK_N=128, BLOCK_K=32, 8 warps, 2-stage cp.async pipeline, persistent Y-major CTA raster. The MMA instruction is `mma.sync.aligned.kind::f8f6f4.m16n8k32.row.col.f32.e4m3.e4m3.f32`. -Result: **12.6% decode speedup** over channels-last-only cuDNN -(10.01 s → 8.75 s), **1.98× vs baseline**. PSNR 39.3 dB (median) — -a ~1.5 dB trade for the FP8 quantization, well within the "excellent" -threshold. Enable/disable via `--fp8-conv` / `--no-fp8-conv`. +Result: **1.7% additional decode speedup** over the previous FP8 +conv3d path (8.75 s → 8.58 s) and **0.7 dB PSNR improvement** +(39.3 → 39.9 dB median) thanks to the temporally consistent +running-max scaling. Combined with the channels-last pipeline: +**2.02× vs baseline**. PSNR 39.9 dB (median). Importing `flash_rt.models.minimax_remover` always succeeds — it needs **none** of `diffusers` / `einops` / `scipy` / `triton` / `sageattention`. The kernel @@ -262,7 +277,7 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the |--------------------------|-------|-----------|---------------------|-------------------------------|-------------| | tennis (70 frames, 432x240) | fp16 reference (`--no-flashrt`) | 17.33 s | 1.0x | — | — | | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL (`--no-fp8-conv`) | 10.01 s | 1.73x | 40.8 / 37.0 dB | 0.99981 | -| tennis (70 frames, 432x240) | **FlashRT FP8 + VAE opt + CL + FP8 conv3d (default)** | **8.75 s** | **1.98x** | **39.3 / 36.5 dB** | 0.99981 | +| tennis (70 frames, 432x240) | **FlashRT FP8 + VAE opt + CL + FP8 conv3d (default)** | **8.58 s** | **2.02x** | **39.9 / 36.4 dB** | 0.99981 | | tennis (70 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 9.52 s | 1.82x | 7.0 / 6.2 dB | 0.00000 (broken) | | bmx-trees (80 frames, 432x240) | fp16 reference (`--no-flashrt`) | 19.76 s | 1.0x | — | — | | bmx-trees (80 frames, 432x240) | FlashRT FP8 (default) | 13.24 s | **1.49x** | 35.1 / 32.0 dB | 0.99912 | @@ -274,16 +289,19 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the Takeaways: -- **FP8 + VAE opt + CL + FP8 conv3d is the recommended default**: 1.98x - faster than the fp16 reference with PSNR 39.3 dB (median) on full-frame - tennis clip, peak VRAM reduced from 3.67 GB to 2.51 GB. The FP8 - implicit-GEMM conv3d contributes an additional 12.6% speedup on top of - the channels-last pipeline (10.01 s → 8.75 s) at a ~1.5 dB PSNR cost. +- **FP8 + VAE opt + CL + FP8 conv3d is the recommended default**: 2.02x + faster than the fp16 reference with PSNR 39.9 dB (median) on full-frame + tennis clip, peak VRAM reduced from 3.67 GB to 2.59 GB. The FP8 + implicit-GEMM conv3d contributes an additional 14.3% speedup on top of + the channels-last pipeline (10.01 s → 8.58 s). The fused norm+silu+amax + + running-max + dual-quantize optimizations further improve both speed + (8.75 → 8.58 s) and precision (39.3 → 39.9 dB) over the original + FP8 conv3d path. - **FP8 conv3d vs channels-last-only cuDNN**: the hand-rolled implicit-GEMM kernel (no im2col materialization, virtual cache concat, per-channel - weight dequant) beats cuDNN's fp16 conv3d while staying in fp16 at the - interface. Disable with `--no-fp8-conv` to recover the 1.5 dB PSNR if - absolute precision is preferred over speed. + weight dequant, fused amax, running-max scale) beats cuDNN's fp16 conv3d + while staying in fp16 at the interface. Disable with `--no-fp8-conv` to + recover ~1 dB PSNR if absolute precision is preferred over speed. - **NVFP4 is faster but unusable on full-frame latents**: cosine collapses to ~0.0 and PSNR to ~7 dB (median per-pixel deviation ~85/255). The FP4 quantisation error accumulates over the large full-frame activations and the @@ -316,8 +334,9 @@ to the quantised GEMMs and the attention backend. | VAE `fp16_rms_silu_ncdhw` kernel | cosine vs fp32 reference | >= 0.9999999 | | VAE `fp16_rms_norm_ndhwc` (CL) kernel | cosine vs fp32 reference | >= 0.9999999 | | VAE `fp16_rms_silu_ndhwc` (CL) kernel | cosine vs fp32 reference | >= 0.9999999 | +| VAE `fp16_rms_silu_amax_ndhwc` kernel | amax vs fp32 reference | exact (atomicMax on non-negative floats) | | VAE `fp8_conv3d_mm` kernel | cosine vs fp16 F.conv3d | >= 0.9993 (per-layer) | -| End-to-end FP8 + VAE opt + CL + FP8 conv3d (full-frame) | PSNR vs fp16 ref | 39.3 dB (median) / >= 36.5 dB (worst frame) | +| End-to-end FP8 + VAE opt + CL + FP8 conv3d (full-frame) | PSNR vs fp16 ref | 39.9 dB (median) / >= 36.4 dB (worst frame) | | End-to-end FP8 + VAE opt + CL only (no FP8 conv3d) | PSNR vs fp16 ref | 40.8 dB (median) / >= 37.0 dB (worst frame) | | Attention — SageAttention QK-int8 PV-fp8 (`sage_fp8`, default) | cosine vs SDPA | 0.9993 | | Attention — SageAttention QK-int8 PV-fp16 (`sage_fp16`) | cosine vs SDPA | 0.9999 | @@ -337,8 +356,8 @@ the steady state; the FP8 path calibrates on the first call then freezes. | Flag | Default | Effect | |------|---------|--------| -| `--vae-opt` / `--no-vae-opt` | **enabled** | Install FlashRT fp16 fused VAE kernels (`fp16_rms_norm_ncdhw`, `fp16_rms_silu_ncdhw`, NDHWC variants) + channels-last 3D pipeline (Conv3d weights → CL, eliminates nchw↔nhwc conversion) + FP8 implicit-GEMM conv3d + `WanUpsample` cast elimination. Requires the `flash_rt_minimax_remover` module. | -| `--fp8-conv` / `--no-fp8-conv` | **enabled** | Use FP8 implicit-GEMM conv3d kernel for applicable 3×3×3 causal convs (requires `--vae-opt`). Trades ~1.5 dB PSNR for ~13% decode speedup. | +| `--vae-opt` / `--no-vae-opt` | **enabled** | Install FlashRT fp16 fused VAE kernels (`fp16_rms_norm_ncdhw`, `fp16_rms_silu_ncdhw`, NDHWC variants, fused norm+silu+amax) + channels-last 3D pipeline + running-max amax sharing between norm and conv + FP8 implicit-GEMM conv3d + `WanUpsample` cast elimination. Requires the `flash_rt_minimax_remover` module. | +| `--fp8-conv` / `--no-fp8-conv` | **enabled** | Use FP8 implicit-GEMM conv3d kernel for applicable 3×3×3 causal convs (requires `--vae-opt`). Trades ~1 dB PSNR for ~14% decode speedup over channels-last-only cuDNN. | | `--use-fp4` | off | Use NVFP4 (W4A4) instead of the default FP8 (W8A8). Small-region only. | | `--no-flashrt` | off | Run the reference diffusers fp16 path (no FlashRT). | diff --git a/flash_rt/models/minimax_remover/_vae_opt.py b/flash_rt/models/minimax_remover/_vae_opt.py index 82868c5f..425053ba 100644 --- a/flash_rt/models/minimax_remover/_vae_opt.py +++ b/flash_rt/models/minimax_remover/_vae_opt.py @@ -309,10 +309,30 @@ def _cl_rms_norm_forward(self, x): return out class _FusedRmsSiluCL(nn.Module): + """Fused RMSNorm+SiLU with optional amax computation for FP8 conv. + + When _fp8_sister_conv is set (by the FP8 conv pipeline), the + forward computes the output amax via the fused + fp16_rms_silu_amax_ndhwc kernel and accumulates it into a + shared running-amax buffer via atomicMax. + + The running-max buffer is shared with the sister conv so the + conv can skip its own amax pass over both x AND cache_x (the + cache was a previous output of this same norm, so its amax + was already accumulated in a prior iteration). + """ def __init__(self, gamma, bias): super().__init__() self.gamma = gamma self.bias = bias + self._fp8_sister_conv = None + self._amax_buf = None + self._running_mode = False + + def _ensure_amax_buf(self, device): + if self._amax_buf is None or self._amax_buf.device != device: + self._amax_buf = torch.zeros( + 1, dtype=torch.float32, device=device) def forward(self, x): if x.dim() == 4: @@ -328,9 +348,20 @@ def forward(self, x): gamma_flat, bias_ptr = _prep_gamma_bias(self.gamma, self.bias) out = torch.empty_like(x) stream = torch.cuda.current_stream().cuda_stream - rc = _fvk.fp16_rms_silu_ndhwc( - x.data_ptr(), gamma_flat.data_ptr(), bias_ptr, - out.data_ptr(), B, C, T, H, W, _EPS, stream) + + if self._fp8_sister_conv is not None: + self._ensure_amax_buf(x.device) + if not self._running_mode: + self._amax_buf.zero_() + rc = _fvk.fp16_rms_silu_amax_ndhwc( + x.data_ptr(), gamma_flat.data_ptr(), bias_ptr, + out.data_ptr(), self._amax_buf.data_ptr(), + B, C, T, H, W, _EPS, stream) + else: + rc = _fvk.fp16_rms_silu_ndhwc( + x.data_ptr(), gamma_flat.data_ptr(), bias_ptr, + out.data_ptr(), B, C, T, H, W, _EPS, stream) + if rc != 0: return F.silu(_ref_rms_norm(self.gamma, self.bias, x)) return out @@ -390,10 +421,14 @@ def _prequantize_conv3d_weight(conv): def _fp8_conv3d_forward(self, x, cache_x=None): """FP8 implicit-GEMM conv3d forward for 3x3x3 causal convs. - Quantizes fp16 activations to FP8 e4m3 with a shared per-tensor - scale (computed over cache+new), runs the fused implicit-GEMM - MMA kernel with per-output-channel dequant, and returns fp16 - output in channels-last 3D format. + Uses a running-max amax strategy: the sister-norm module + accumulates the output amax of x via atomicMax into a shared + buffer across iterations. Since cache_x was a previous output + of the SAME norm module, its amax is already covered by the + running max — so we skip the cache amax pass entirely. + + This saves one full read of the cache tensor per layer (~40 MB + for the largest layers). """ B, Ci, T_new, H, W = x.shape Co = self._fp8_w.shape[0] @@ -402,6 +437,7 @@ def _fp8_conv3d_forward(self, x, cache_x=None): if not x.is_contiguous(memory_format=torch.channels_last_3d): x = x.to(memory_format=torch.channels_last_3d) + # ── Resolve cache ──────────────────────────────────────────── if cache_x is not None and cache_x.shape[2] >= 2: cache_2 = cache_x[:, :, -2:] if not cache_2.is_contiguous(memory_format=torch.channels_last_3d): @@ -419,24 +455,50 @@ def _fp8_conv3d_forward(self, x, cache_x=None): n_cache = cache_2.numel() n_new = x.numel() - self._fp8_amax.zero_() - _fvk.amax_fp16(cache_2.data_ptr(), self._fp8_amax.data_ptr(), - n_cache, stream) - _fvk.amax_fp16(x.data_ptr(), self._fp8_amax.data_ptr(), - n_new, stream) - + # ── Determine amax ─────────────────────────────────────────── + sister_norm = getattr(self, '_sister_norm', None) + sister_amax = getattr(sister_norm, '_amax_buf', None) if sister_norm else None + running_mode = getattr(sister_norm, '_running_mode', False) if sister_norm else False + + if sister_amax is not None and sister_amax.is_cuda and running_mode: + # Running-max path: amax of x already accumulated by norm. + # cache_x amax was accumulated in a previous iteration (same norm). + # No cache amax call needed! + shared_amax = sister_amax + elif sister_amax is not None and sister_amax.is_cuda: + # First-iteration path: norm computed x's amax, accumulate cache amax. + shared_amax = sister_amax + _fvk.amax_fp16(cache_2.data_ptr(), shared_amax.data_ptr(), + n_cache, stream) + else: + # Fallback: compute amax from scratch. + shared_amax = self._fp8_amax + shared_amax.zero_() + _fvk.amax_fp16(cache_2.data_ptr(), shared_amax.data_ptr(), + n_cache, stream) + _fvk.amax_fp16(x.data_ptr(), shared_amax.data_ptr(), + n_new, stream) + + # ── Quantize cache + new with shared amax (single launch) ── cache_fp8 = torch.empty( n_cache, dtype=torch.float8_e4m3fn, device=x.device) new_fp8 = torch.empty( n_new, dtype=torch.float8_e4m3fn, device=x.device) - _fvk.quantize_fp16_fp8_with_amax( - cache_2.data_ptr(), cache_fp8.data_ptr(), - self._fp8_amax.data_ptr(), self._fp8_scale.data_ptr(), - n_cache, stream) - _fvk.quantize_fp16_fp8_with_amax( - x.data_ptr(), new_fp8.data_ptr(), - self._fp8_amax.data_ptr(), self._fp8_scale.data_ptr(), - n_new, stream) + if hasattr(_fvk, 'quantize_fp16_fp8_with_amax_dual'): + _fvk.quantize_fp16_fp8_with_amax_dual( + cache_2.data_ptr(), cache_fp8.data_ptr(), n_cache, + x.data_ptr(), new_fp8.data_ptr(), n_new, + shared_amax.data_ptr(), self._fp8_scale.data_ptr(), + stream) + else: + _fvk.quantize_fp16_fp8_with_amax( + cache_2.data_ptr(), cache_fp8.data_ptr(), + shared_amax.data_ptr(), self._fp8_scale.data_ptr(), + n_cache, stream) + _fvk.quantize_fp16_fp8_with_amax( + x.data_ptr(), new_fp8.data_ptr(), + shared_amax.data_ptr(), self._fp8_scale.data_ptr(), + n_new, stream) alpha_vec = self._fp8_scale * self._w_scale @@ -525,12 +587,46 @@ def _fp8_dispatch_forward(self, x, cache_x=None): WanCausalConv3d.forward = _fp8_dispatch_forward WanCausalConv3d._flashrt_fp8_patched = True + # ── Wire up sister-norm references for fused amax ─────────── + # Each FP8-enabled conv gets a reference to its sister norm module + # so it can reuse the amax computed by fp16_rms_silu_amax_ndhwc. + # The norm accumulates amax into a running-max buffer (shared with + # the conv) so the conv can skip amax over both x and cache_x. + try: + from diffusers.models.autoencoders.autoencoder_kl_wan import ( + WanResidualBlock) + n_sister = 0 + for blk in vae.modules(): + if isinstance(blk, WanResidualBlock): + if getattr(blk.conv1, '_fp8_w', None) is not None: + norm1 = blk.norm1 + if hasattr(norm1, '_fp8_sister_conv'): + norm1._fp8_sister_conv = blk.conv1 + blk.conv1._sister_norm = norm1 + norm1._running_mode = True + n_sister += 1 + if getattr(blk.conv2, '_fp8_w', None) is not None: + norm2 = blk.norm2 + if hasattr(norm2, '_fp8_sister_conv'): + norm2._fp8_sister_conv = blk.conv2 + blk.conv2._sister_norm = norm2 + norm2._running_mode = True + n_sister += 1 + if n_sister > 0: + logger.info("[minimax-vae] fused norm+silu+amax + running-max: " + "%d norm→conv links (skips amax over cache_x entirely)", + n_sister) + except Exception as e: + logger.debug("[minimax-vae] sister-norm link skipped: %s", e) + logger.info("[minimax-vae] FP8 implicit-GEMM conv3d: %d layers " "quantized, %d skipped (non-3x3x3 or Ci%%32!=0)", n_fp8, n_skip) return n_fp8 + + @torch.no_grad() def profile_vae(pipe, images_tensor, masks_infer, height, width, num_frames, iterations=6, num_inference_steps=12, seed=42, From 67da1a2589da8b53b5369e0515a7f5a06b97e650 Mon Sep 17 00:00:00 2001 From: chenping Date: Tue, 7 Jul 2026 17:08:08 +0800 Subject: [PATCH 14/23] feat(minimax-remover): add fused FFN bias+gelu+quant epilogue kernel for transformer denoise Collapse the 3-kernel FFN glue sequence (add_bias_fp16 + gelu_inplace_fp16 + quantize_fp8_static_fp16) into a single-pass fp16->fp8 epilogue kernel (bias_gelu_quant_fp16_fp8). The fp8 output is the pre-quantised input of the next FP8 Linear, which skips its own activation quantise. All bias+gelu arithmetic is done in fp32 before the fp8 cast, so the result is slightly more accurate than the original path (which rounds to fp16 twice). End-to-end PSNR improves 39.9 -> 40.0 dB vs fp16 reference. Add mid-inference calibration freeze: a one-shot transformer forward hook freezes FP8 scales after the first denoise step, so steps 2..N run with static scales and the fused epilogue active -- a single-call invocation benefits without a separate warm-up pass. Denoise GPU time: 3.73s -> 3.16s (-15%). End-to-end: 8.58s -> 7.56s (2.29x vs fp16 baseline). Peak VRAM unchanged at 2.51 GB. Files: - fp16_bias_gelu_quant_fp8.cu/.cuh: fused kernel (gelu + identity variants) - _fp8_linear.py: FlashRTFp8Linear.gemm_no_bias / forward_from_fp8 helpers - _kern_block.py: FFN path dispatches fused epilogue (falls back during calibration) - _fp8_pipeline.py: mid-inference freeze via one-shot forward hook - minimax_remover_extra_bindings.cpp + CMakeLists.txt: pybind + build --- CMakeLists.txt | 5 +- .../fp16_bias_gelu_quant_fp8.cu | 170 ++++++++++++++++++ .../fp16_bias_gelu_quant_fp8.cuh | 40 +++++ csrc/minimax_remover_extra_bindings.cpp | 34 ++++ docs/minimax_remover_usage.md | 59 ++++-- .../models/minimax_remover/_fp8_linear.py | 58 ++++++ .../models/minimax_remover/_fp8_pipeline.py | 44 +++-- .../models/minimax_remover/_kern_block.py | 46 ++++- 8 files changed, 424 insertions(+), 32 deletions(-) create mode 100644 csrc/kernels/minimax_remover/fp16_bias_gelu_quant_fp8.cu create mode 100644 csrc/kernels/minimax_remover/fp16_bias_gelu_quant_fp8.cuh diff --git a/CMakeLists.txt b/CMakeLists.txt index ec89277a..7cee57df 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -968,8 +968,9 @@ if(FLASHRT_ENABLE_MINIMAX_REMOVER) csrc/kernels/minimax_remover/fp16_rms_silu_ncdhw.cu csrc/kernels/minimax_remover/fp16_rms_norm_ndhwc.cu csrc/kernels/minimax_remover/fp8_conv3d_mm_ndhwc_fp16out.cu - csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cu - csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cu) + csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cu + csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cu + csrc/kernels/minimax_remover/fp16_bias_gelu_quant_fp8.cu) set_target_properties(flash_rt_minimax_remover PROPERTIES CUDA_STANDARD 17 LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_bias_gelu_quant_fp8.cu b/csrc/kernels/minimax_remover/fp16_bias_gelu_quant_fp8.cu new file mode 100644 index 00000000..fd771992 --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_bias_gelu_quant_fp8.cu @@ -0,0 +1,170 @@ +// ================================================================ +// flash_rt_minimax_remover — fused FFN epilogue kernels. +// +// Replaces the 3-kernel sequence in the MiniMax-Remover transformer +// FFN path: +// add_bias_fp16 (read fp16 [M,N] + write fp16 [M,N]) +// gelu_inplace (read fp16 [M,N] + write fp16 [M,N]) +// quantize_fp8 (read fp16 [M,N] + write fp8 [M,N]) +// with a single pass that reads the GEMM's raw fp16 output ONCE and +// writes fp8 ONCE — eliminating ~3 full-tensor memory round-trips. +// +// The output fp8 tensor is the pre-quantised input of the NEXT FP8 +// Linear (net.2), which skips its own activation quantise step. +// +// Precision: all arithmetic (bias add + tanh-gelu) is done in fp32 +// before the fp8 cast, so the result is actually more accurate than +// the original path (which rounds to fp16 twice along the way). +// ================================================================ +#include "fp16_bias_gelu_quant_fp8.cuh" + +#include +#include +#include +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +namespace { + +__device__ __forceinline__ float gelu_tanh(float x) { + // Matches gelu_inplace_fp16 / gelu_tanh_nvfp4 exactly: + // 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3))) + constexpr float kSqrt2Pi = 0.7978845608f; + float t = tanhf(kSqrt2Pi * (x + 0.044715f * x * x * x)); + return x * 0.5f * (1.0f + t); +} + +// ── Fused: bias + tanh-gelu + quantise → fp8 e4m3 ── +// 4 elements / thread, vectorised fp16 reads, packed fp8 writes. +// Requires N % 4 == 0 (all MiniMax-Remover Linears satisfy this: +// inner_dim=13824, dim=5120, etc.). +__global__ void bias_gelu_quant_fp16_fp8_kernel( + const __half* __restrict__ gemm_out, // [M*N] raw GEMM output (no bias) + const __half* __restrict__ bias, // [N] + __nv_fp8_e4m3* __restrict__ out, // [M*N] + const float* __restrict__ d_scale, // act_scale of the NEXT linear + int M, int N) +{ + const int n_total = M * N; + int i = (blockIdx.x * blockDim.x + threadIdx.x) * 4; + if (i >= n_total) return; + + const float inv_scale = 1.0f / fmaxf(*d_scale, 1e-12f); + const int col = i % N; // N%4==0 ⇒ col is a multiple of 4, no row-cross + + // Load 4 fp16 GEMM-output values. + const __half2* in2 = reinterpret_cast(gemm_out + i); + __half2 vA = in2[0]; + __half2 vB = in2[1]; + float fv[4] = { + __half2float(vA.x), __half2float(vA.y), + __half2float(vB.x), __half2float(vB.y) + }; + + // Load 4 fp16 bias values (sequential within the row). + const __half2* bias2 = reinterpret_cast(bias + col); + __half2 bA = bias2[0]; + __half2 bB = bias2[1]; + float bv[4] = { + __half2float(bA.x), __half2float(bA.y), + __half2float(bB.x), __half2float(bB.y) + }; + + // bias + gelu(tanh) + quantise, all in fp32. + __nv_fp8_e4m3 fp8_pack[4]; + #pragma unroll + for (int j = 0; j < 4; j++) { + float v = fv[j] + bv[j]; + float g = gelu_tanh(v); + g = fminf(fmaxf(g * inv_scale, -448.0f), 448.0f); + fp8_pack[j] = __nv_fp8_e4m3(g); + } + *reinterpret_cast(out + i) = + *reinterpret_cast(fp8_pack); +} + +// ── Fused: bias + identity + quantise → fp8 e4m3 ── +// Used for Linear→Linear chains with NO activation in between +// (kept for completeness / future attention QKV fusion). +__global__ void bias_quant_fp16_fp8_kernel( + const __half* __restrict__ gemm_out, + const __half* __restrict__ bias, + __nv_fp8_e4m3* __restrict__ out, + const float* __restrict__ d_scale, + int M, int N) +{ + const int n_total = M * N; + int i = (blockIdx.x * blockDim.x + threadIdx.x) * 4; + if (i >= n_total) return; + + const float inv_scale = 1.0f / fmaxf(*d_scale, 1e-12f); + const int col = i % N; + + const __half2* in2 = reinterpret_cast(gemm_out + i); + __half2 vA = in2[0]; + __half2 vB = in2[1]; + + const __half2* bias2 = reinterpret_cast(bias + col); + __half2 bA = bias2[0]; + __half2 bB = bias2[1]; + + __nv_fp8_e4m3 fp8_pack[4]; + #pragma unroll + for (int j = 0; j < 4; j++) { + float v = (j == 0 ? __half2float(vA.x) : + j == 1 ? __half2float(vA.y) : + j == 2 ? __half2float(vB.x) : + __half2float(vB.y)) + + (j == 0 ? __half2float(bA.x) : + j == 1 ? __half2float(bA.y) : + j == 2 ? __half2float(bB.x) : + __half2float(bB.y)); + v = fminf(fmaxf(v * inv_scale, -448.0f), 448.0f); + fp8_pack[j] = __nv_fp8_e4m3(v); + } + *reinterpret_cast(out + i) = + *reinterpret_cast(fp8_pack); +} + +} // anonymous namespace + +int bias_gelu_quant_fp16_fp8( + const void* gemm_out, const void* bias, + void* out, const float* d_scale, + int M, int N, cudaStream_t stream) +{ + if (M <= 0 || N <= 0 || (N & 3) != 0) return -1; + const int n_total = M * N; + const int threads = 256; + const int blocks = (n_total / 4 + threads - 1) / threads; + bias_gelu_quant_fp16_fp8_kernel<<>>( + reinterpret_cast(gemm_out), + reinterpret_cast(bias), + reinterpret_cast<__nv_fp8_e4m3*>(out), + d_scale, M, N); + return 0; +} + +int bias_quant_fp16_fp8( + const void* gemm_out, const void* bias, + void* out, const float* d_scale, + int M, int N, cudaStream_t stream) +{ + if (M <= 0 || N <= 0 || (N & 3) != 0) return -1; + const int n_total = M * N; + const int threads = 256; + const int blocks = (n_total / 4 + threads - 1) / threads; + bias_quant_fp16_fp8_kernel<<>>( + reinterpret_cast(gemm_out), + reinterpret_cast(bias), + reinterpret_cast<__nv_fp8_e4m3*>(out), + d_scale, M, N); + return 0; +} + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_bias_gelu_quant_fp8.cuh b/csrc/kernels/minimax_remover/fp16_bias_gelu_quant_fp8.cuh new file mode 100644 index 00000000..3f2eebf5 --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_bias_gelu_quant_fp8.cuh @@ -0,0 +1,40 @@ +// ================================================================ +// flash_rt_minimax_remover — fused FFN epilogue kernel declarations. +// +// bias_gelu_quant_fp16_fp8 : fp16 GEMM-out + bias → tanh-gelu → fp8 +// bias_quant_fp16_fp8 : fp16 GEMM-out + bias → fp8 (identity) +// +// Both eliminate the intermediate fp16 round-trips of the separate +// add_bias_fp16 + (gelu_inplace) + quantize_fp8 sequence. +// ================================================================ +#ifndef FLASHRT_KERNELS_MINIMAX_REMOVER_FP16_BIAS_GELU_QUANT_FP8_CUH +#define FLASHRT_KERNELS_MINIMAX_REMOVER_FP16_BIAS_GELU_QUANT_FP8_CUH + +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +// Fused: bias-add + tanh-gelu + quantise → fp8 e4m3. +// gemm_out : [M*N] fp16, raw FP8-GEMM output (no bias yet) +// bias : [N] fp16 +// out : [M*N] fp8 e4m3 (the pre-quantised input for the next Linear) +// d_scale : float, the NEXT linear's act_scale (quantise divides by it) +// Returns 0 on success, <0 on invalid args. +int bias_gelu_quant_fp16_fp8( + const void* gemm_out, const void* bias, + void* out, const float* d_scale, + int M, int N, cudaStream_t stream); + +// Fused: bias-add + quantise → fp8 e4m3 (identity activation). +int bias_quant_fp16_fp8( + const void* gemm_out, const void* bias, + void* out, const float* d_scale, + int M, int N, cudaStream_t stream); + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt + +#endif // FLASHRT_KERNELS_MINIMAX_REMOVER_FP16_BIAS_GELU_QUANT_FP8_CUH diff --git a/csrc/minimax_remover_extra_bindings.cpp b/csrc/minimax_remover_extra_bindings.cpp index a35b1c1e..d88a9136 100644 --- a/csrc/minimax_remover_extra_bindings.cpp +++ b/csrc/minimax_remover_extra_bindings.cpp @@ -19,6 +19,7 @@ #include "kernels/minimax_remover/fp8_conv3d_mm_ndhwc_fp16out.cuh" #include "kernels/minimax_remover/fp16_quant_fp8_per_tensor.cuh" #include "kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cuh" +#include "kernels/minimax_remover/fp16_bias_gelu_quant_fp8.cuh" namespace py = pybind11; @@ -248,4 +249,37 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { "2-pass fused norm+silu+amax+quant → FP8. Pass 1 computes amax " "(no write); pass 2 re-reads x and quantizes. Produces ONLY fp8 " "output + scale, no fp16 intermediate."); + + // ── Fused FFN epilogue: bias + gelu + quant → fp8 (transformer) ── + m.def("bias_gelu_quant_fp16_fp8", + [](uintptr_t gemm_out, uintptr_t bias, uintptr_t out, + uintptr_t d_scale, int M, int N, uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + bias_gelu_quant_fp16_fp8( + to_ptr(gemm_out), to_ptr(bias), to_ptr(out), + reinterpret_cast(to_ptr(d_scale)), + M, N, to_stream(stream)); + }, + py::arg("gemm_out"), py::arg("bias"), py::arg("out"), + py::arg("d_scale"), py::arg("M"), py::arg("N"), + py::arg("stream") = 0, + "Fused FFN epilogue: fp16 GEMM-out + bias → tanh-gelu → fp8 e4m3. " + "Replaces add_bias_fp16 + gelu_inplace_fp16 + quantize_fp8 (3 " + "kernels → 1). Output is the pre-quantised input of the next FP8 " + "Linear, which skips its own activation quantise."); + + m.def("bias_quant_fp16_fp8", + [](uintptr_t gemm_out, uintptr_t bias, uintptr_t out, + uintptr_t d_scale, int M, int N, uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + bias_quant_fp16_fp8( + to_ptr(gemm_out), to_ptr(bias), to_ptr(out), + reinterpret_cast(to_ptr(d_scale)), + M, N, to_stream(stream)); + }, + py::arg("gemm_out"), py::arg("bias"), py::arg("out"), + py::arg("d_scale"), py::arg("M"), py::arg("N"), + py::arg("stream") = 0, + "Fused: fp16 GEMM-out + bias → fp8 e4m3 (identity activation). " + "For Linear→Linear chains with no activation in between."); } diff --git a/docs/minimax_remover_usage.md b/docs/minimax_remover_usage.md index fcd8c499..07ef9353 100644 --- a/docs/minimax_remover_usage.md +++ b/docs/minimax_remover_usage.md @@ -67,6 +67,8 @@ cmake --build build -j --target flash_rt_minimax_remover | `fp8_conv3d_mm_ndhwc_fp16out` | `flash_rt_minimax_remover` | cuDNN fp16 conv3d for applicable 3×3×3 causal convs | FP8 e4m3 implicit-GEMM (no im2col materialization); per-channel weight dequant; fp16 in/out | | `amax_fp16` + `quantize_fp16_fp8_with_amax` | `flash_rt_minimax_remover` | PyTorch multi-pass `abs().max()` + scale + cast for activation quantization | fused 2-pass device-side amax + quantize; no host sync; shared scale across cache+new frames | | `quantize_fp16_fp8_with_amax_dual` | `flash_rt_minimax_remover` | two separate `quantize_fp16_fp8_with_amax` calls (cache + new) | single kernel launch quantizes two buffers with shared amax; saves 1 launch per conv layer | +| `bias_gelu_quant_fp16_fp8` | `flash_rt_minimax_remover` | transformer FFN `add_bias_fp16` + `gelu_inplace_fp16` + `quantize_fp8_static_fp16` (3 kernels) | fused single-pass: fp16 GEMM-out + bias → tanh-gelu → fp8 e4m3; output is the pre-quantised input of the next FP8 Linear (skips its activation quantise); eliminates 3 full-tensor fp16 round-trips per FFN block | +| `bias_quant_fp16_fp8` | `flash_rt_minimax_remover` | `add_bias_fp16` + `quantize_fp8_static_fp16` (identity activation variant) | fused bias + quant for Linear→Linear chains with no activation | | channels-last pipeline | Python (`_vae_opt.py`) | Conv3d weight → CL, WanCausalConv3d → CL-preserving forward | eliminates ~97% of nchw↔nhwc conversion kernels (~280 ms / decode) | | Running-max amax | Python (`_vae_opt.py`) | separate `amax_fp16` calls over cache + new each iteration | norm fuses amax via atomicMax into a persistent buffer shared with the sister conv; cache amax is skipped entirely (covered by running max) | | `WanUpsample` patch | Python (no kernel) | `x.float().type_as(x)` for nearest-exact upsample | eliminates redundant fp32 cast (index-only op, fp16 == fp32) | @@ -152,6 +154,34 @@ conv3d path (8.75 s → 8.58 s) and **0.7 dB PSNR improvement** running-max scaling. Combined with the channels-last pipeline: **2.02× vs baseline**. PSNR 39.9 dB (median). +#### Fused transformer FFN epilogue + +The transformer denoise loop spends ~877 ms / 12-step run on three +elementwise "glue" kernels sandwiched between the FP8 GEMMs of each +FeedForward block: + + `add_bias_fp16` (607 ms) + `gelu_inplace_fp16` (233 ms) + `quantize_fp8` (270 ms) + +Each is a full read+write of the `[S, inner_dim]` fp16 FFN-up output +(inner_dim = 13824). The `bias_gelu_quant_fp16_fp8` kernel collapses +all three into a **single pass** that reads the GEMM's raw fp16 output +once, applies bias + tanh-GELU in fp32, and writes fp8 e4m3 directly. +The fp8 tensor becomes the pre-quantised input of the FFN-down Linear, +which skips its own activation quantise step. + +All arithmetic (bias + GELU) is done in fp32 before the fp8 cast, so +the result is actually **more accurate** than the original path (which +rounds to fp16 twice along the way) — end-to-end PSNR improves from +39.9 → 40.0 dB. + +The FP8 pipeline freezes calibration after the **first denoise step** +(via a one-shot transformer forward hook), so steps 2..N run with +static scales and the fused epilogue active — a single-call invocation +benefits without needing a separate warm-up pass. + +Result: **denoise GPU time 3.73 s → 3.16 s** (-15%), end-to-end +**8.58 s → 7.56 s** (**2.29× vs baseline**), PSNR **40.0 dB** (median). + Importing `flash_rt.models.minimax_remover` always succeeds — it needs **none** of `diffusers` / `einops` / `scipy` / `triton` / `sageattention`. The kernel surface is validated lazily in each pipeline's `__init__` via @@ -201,11 +231,12 @@ kernels are self-contained Triton JIT kernels shipped in the package `flash_rt/models/minimax_remover/_fp8_pipeline.py`. Uses static calibration: the first inference call runs in dynamic-FP8 calibration mode (accumulating -activation amax on GPU), then freezes to a static `act_scale` for all -subsequent calls (zero CPU sync overhead in the steady state, suitable for -CUDA Graph capture). **The frozen scale is calibrated to the first call's -input; if the input resolution/shape changes, construct a new pipeline so -the scale is re-calibrated.** +activation amax on GPU). A one-shot transformer forward hook **freezes the +calibration after the first denoise step**, so steps 2..N (and the fused FFN +epilogue kernel) run with static scales — a single-call invocation benefits +without needing a separate warm-up pass. **The frozen scale is calibrated to +the first call's input; if the input resolution/shape changes, construct a +new pipeline so the scale is re-calibrated.** - every eligible transformer Linear -> FP8 W8A8 GEMM (weight quantised once at load time; activation quantised with a calibrated static scale); @@ -277,7 +308,8 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the |--------------------------|-------|-----------|---------------------|-------------------------------|-------------| | tennis (70 frames, 432x240) | fp16 reference (`--no-flashrt`) | 17.33 s | 1.0x | — | — | | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL (`--no-fp8-conv`) | 10.01 s | 1.73x | 40.8 / 37.0 dB | 0.99981 | -| tennis (70 frames, 432x240) | **FlashRT FP8 + VAE opt + CL + FP8 conv3d (default)** | **8.58 s** | **2.02x** | **39.9 / 36.4 dB** | 0.99981 | +| tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d | 8.58 s | 2.02x | 39.9 / 36.4 dB | 0.99981 | +| tennis (70 frames, 432x240) | **FlashRT FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue (default)** | **7.56 s** | **2.29x** | **40.0 / 36.4 dB** | 0.99981 | | tennis (70 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 9.52 s | 1.82x | 7.0 / 6.2 dB | 0.00000 (broken) | | bmx-trees (80 frames, 432x240) | fp16 reference (`--no-flashrt`) | 19.76 s | 1.0x | — | — | | bmx-trees (80 frames, 432x240) | FlashRT FP8 (default) | 13.24 s | **1.49x** | 35.1 / 32.0 dB | 0.99912 | @@ -289,14 +321,12 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the Takeaways: -- **FP8 + VAE opt + CL + FP8 conv3d is the recommended default**: 2.02x - faster than the fp16 reference with PSNR 39.9 dB (median) on full-frame - tennis clip, peak VRAM reduced from 3.67 GB to 2.59 GB. The FP8 - implicit-GEMM conv3d contributes an additional 14.3% speedup on top of - the channels-last pipeline (10.01 s → 8.58 s). The fused norm+silu+amax - + running-max + dual-quantize optimizations further improve both speed - (8.75 → 8.58 s) and precision (39.3 → 39.9 dB) over the original - FP8 conv3d path. +- **FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue is the recommended default**: 2.29x + faster than the fp16 reference with PSNR 40.0 dB (median) on full-frame + tennis clip, peak VRAM 2.51 GB. The fused FFN epilogue kernel + (`bias_gelu_quant_fp16_fp8`) collapses bias-add + GELU + activation + quantise into one pass, cutting denoise GPU time by 15% (3.73 → 3.16 s) + and end-to-end from 8.58 → 7.56 s. - **FP8 conv3d vs channels-last-only cuDNN**: the hand-rolled implicit-GEMM kernel (no im2col materialization, virtual cache concat, per-channel weight dequant, fused amax, running-max scale) beats cuDNN's fp16 conv3d @@ -336,6 +366,7 @@ to the quantised GEMMs and the attention backend. | VAE `fp16_rms_silu_ndhwc` (CL) kernel | cosine vs fp32 reference | >= 0.9999999 | | VAE `fp16_rms_silu_amax_ndhwc` kernel | amax vs fp32 reference | exact (atomicMax on non-negative floats) | | VAE `fp8_conv3d_mm` kernel | cosine vs fp16 F.conv3d | >= 0.9993 (per-layer) | +| End-to-end FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue (full-frame) | PSNR vs fp16 ref | 40.0 dB (median) / >= 36.4 dB (worst frame) | | End-to-end FP8 + VAE opt + CL + FP8 conv3d (full-frame) | PSNR vs fp16 ref | 39.9 dB (median) / >= 36.4 dB (worst frame) | | End-to-end FP8 + VAE opt + CL only (no FP8 conv3d) | PSNR vs fp16 ref | 40.8 dB (median) / >= 37.0 dB (worst frame) | | Attention — SageAttention QK-int8 PV-fp8 (`sage_fp8`, default) | cosine vs SDPA | 0.9993 | diff --git a/flash_rt/models/minimax_remover/_fp8_linear.py b/flash_rt/models/minimax_remover/_fp8_linear.py index 713ebdf3..6ef03bd1 100644 --- a/flash_rt/models/minimax_remover/_fp8_linear.py +++ b/flash_rt/models/minimax_remover/_fp8_linear.py @@ -175,6 +175,64 @@ def freeze_act_scale(self, margin: float = 1.0): self.act_scale.data = torch.tensor([scale], dtype=torch.float32, device=self.weight_fp8.device) self.calibrating = False + # ── Fused-FFN helpers (used by _kern_block block_forward) ── + # These let the FFN path skip 3 full-tensor round-trips between the + # proj0 (up) and proj1 (down) Linears by fusing bias+gelu+quant into + # one kernel (bias_gelu_quant_fp16_fp8 in flash_rt_minimax_remover). + + def gemm_no_bias(self, x: torch.Tensor) -> torch.Tensor: + """Quantise + FP8 GEMM, return raw fp16 output WITHOUT bias. + + The caller applies bias later (fused with gelu+quant). Only valid + when not calibrating (the fused FFN path is disabled during the + calibration warm-up pass). + """ + if self.calibrating or self.bias is None: + return self.forward(x) + if x.dtype != torch.float16: + x = x.to(torch.float16) + orig_shape = x.shape + x2d = x.reshape(-1, self.in_features) + if x2d.stride(0) != self.in_features or x2d.stride(1) != 1: + x2d = x2d.contiguous() + m = x2d.shape[0] + k, n = self.in_features, self.out_features + scale = self.act_scale.data + stream = torch.cuda.current_stream().cuda_stream + x_fp8 = torch.empty(m, k, dtype=_FP8, device=x2d.device) + out = torch.empty(m, n, dtype=torch.float16, device=x2d.device) + kern.quantize_fp8_static_fp16( + x2d.data_ptr(), x_fp8.data_ptr(), scale.data_ptr(), m * k, stream) + kern.fp8_gemm_descale_fp16( + x_fp8.data_ptr(), self.weight_fp8.data_ptr(), out.data_ptr(), + m, n, k, scale.data_ptr(), self.weight_scale.data_ptr(), stream) + return out.view(*orig_shape[:-1], n) + + def forward_from_fp8(self, x_fp8: torch.Tensor) -> torch.Tensor: + """FP8 GEMM from a pre-quantised fp8 input + bias (skip quantise). + + Counterpart to ``gemm_no_bias`` on the previous Linear: the input + was already quantised (by the fused bias+gelu+quant kernel), so we + only run the GEMM and the (non-fused) bias-add. + """ + if self.calibrating: + return self.forward(x_fp8) + orig_shape = x_fp8.shape + x2d = x_fp8.reshape(-1, self.in_features) + if x2d.stride(0) != self.in_features or x2d.stride(1) != 1: + x2d = x2d.contiguous() + m = x2d.shape[0] + k, n = self.in_features, self.out_features + scale = self.act_scale.data + stream = torch.cuda.current_stream().cuda_stream + out = torch.empty(m, n, dtype=torch.float16, device=x2d.device) + kern.fp8_gemm_descale_fp16( + x2d.data_ptr(), self.weight_fp8.data_ptr(), out.data_ptr(), + m, n, k, scale.data_ptr(), self.weight_scale.data_ptr(), stream) + if self.bias is not None: + kern.add_bias_fp16(out.data_ptr(), self.bias.data_ptr(), m, n, stream) + return out.view(*orig_shape[:-1], n) + def _is_fp8_target(module: nn.Module) -> bool: """Determine whether a Linear is suitable for FP8 replacement. diff --git a/flash_rt/models/minimax_remover/_fp8_pipeline.py b/flash_rt/models/minimax_remover/_fp8_pipeline.py index f246b352..93e9e452 100644 --- a/flash_rt/models/minimax_remover/_fp8_pipeline.py +++ b/flash_rt/models/minimax_remover/_fp8_pipeline.py @@ -98,18 +98,40 @@ def __init__(self, pipe, num_inference_steps=12, fp8_target="all", @torch.no_grad() def __call__(self, *args, **kwargs): - """Run the wrapped pipe, calibrating FP8 scales on the first call.""" + """Run the wrapped pipe, calibrating FP8 scales on the first call. + + On the first call, a one-shot forward hook on the transformer + freezes the FP8 act_scales immediately after the FIRST denoise + step completes. This lets steps 2..N (and the fused FFN epilogue + kernel) run with static scales, so a single-call invocation + benefits from the fused path instead of only multi-call ones. + The cost is a single CPU sync (~1 ms) after step 1. + """ if not self._calibrated: logger.info("MiniMax-Remover FP8: calibration mode " - "(first call, dynamic FP8 + amax accumulation)") + "(first call, dynamic FP8 + amax accumulation; " + "freezes after step 1)") self._set_calibration(True) - - result = self._orig_pipe_call(*args, **kwargs) - - if not self._calibrated: - n = self._freeze_calibration() - self._calibrated = True - logger.info("MiniMax-Remover FP8: calibration done, " - "froze %d static act_scales (margin=%.2f)", - n, self.calib_margin) + # One-shot hook: freeze after the first transformer forward. + fired = [False] + + def _freeze_after_step1(_module, _inp, _out): + if fired[0]: + return + fired[0] = True + n = self._freeze_calibration() + self._calibrated = True + logger.info("MiniMax-Remover FP8: mid-inference freeze " + "after step 1 — %d act_scales frozen " + "(margin=%.2f); steps 2+ now use static FP8 " + "+ fused FFN epilogue", n, self.calib_margin) + + handle = self.transformer.register_forward_hook( + _freeze_after_step1) + try: + result = self._orig_pipe_call(*args, **kwargs) + finally: + handle.remove() + else: + result = self._orig_pipe_call(*args, **kwargs) return result diff --git a/flash_rt/models/minimax_remover/_kern_block.py b/flash_rt/models/minimax_remover/_kern_block.py index cd5afe8f..e6c258ad 100644 --- a/flash_rt/models/minimax_remover/_kern_block.py +++ b/flash_rt/models/minimax_remover/_kern_block.py @@ -41,6 +41,18 @@ from ._kernels import ada_layernorm_fp16_io, rms_norm_fp32stat, gate_mul_residual_bcast from ._attention import install_attention +# MiniMax-Remover fused FFN epilogue kernel (bias+gelu+quant → fp8). +# Opt-in module; if absent the FFN path falls back to the 3-kernel +# sequence (bias-add + gelu-inplace + quantise). +_fvk = None +try: + from flash_rt import flash_rt_minimax_remover as _fvk +except ImportError: + try: + import flash_rt_minimax_remover as _fvk + except ImportError: + pass + # Backward-compat alias: the FP8 pipeline (``_fp8_pipeline``) installs the # kernel attention processor via this name. The single source of truth for # attention dispatch lives in ``_attention`` and is shared by both paths. @@ -115,11 +127,35 @@ def block_forward(self, hidden_states, temb, rotary_emb): if gelu_mode == "torch": ff_out = self.ffn(n2_3d).view(S, D) else: - up = self.ffn.net[0].proj(n2_3d) - inner = up.shape[-1] - _gelu_fn = kern.gelu_inplace if up.dtype == torch.bfloat16 else kern.gelu_inplace_fp16 - _gelu_fn(up.data_ptr(), S * inner, stream_of()) - ff_out = self.ffn.net[2](up).view(S, D) + proj0 = self.ffn.net[0].proj + proj1 = self.ffn.net[2] + # Fused FFN epilogue: bias + gelu + quant → fp8 in one kernel. + # Only when both Linears are FlashRT FP8, the kernel module is + # loaded, scales are frozen (not calibrating), and proj0 has a + # bias. Saves 3 full-tensor fp16 round-trips per block. + _can_fuse = ( + _fvk is not None + and hasattr(proj0, "gemm_no_bias") + and hasattr(proj1, "forward_from_fp8") + and not getattr(proj1, "calibrating", False) + and getattr(proj0, "bias", None) is not None + and (proj0.out_features & 3) == 0) + if _can_fuse: + raw = proj0.gemm_no_bias(n2_3d) # quant+GEMM, no bias → fp16 [1,S,inner] + inner = raw.shape[-1] + up_fp8 = torch.empty( + S, inner, dtype=torch.float8_e4m3fn, device=raw.device) + _fvk.bias_gelu_quant_fp16_fp8( + raw.data_ptr(), proj0.bias.data_ptr(), + up_fp8.data_ptr(), proj1.act_scale.data_ptr(), + S, inner, stream_of()) + ff_out = proj1.forward_from_fp8(up_fp8).view(S, D) + else: + up = proj0(n2_3d) + inner = up.shape[-1] + _gelu_fn = kern.gelu_inplace if up.dtype == torch.bfloat16 else kern.gelu_inplace_fp16 + _gelu_fn(up.data_ptr(), S * inner, stream_of()) + ff_out = proj1(up).view(S, D) gate_mul_residual_bcast(hs, ff_out, c_gate_msa.view(D)) return hs.view(1, S, D) From 2a848131c2afe42a1b5dfa3a59fa836f601da4f6 Mon Sep 17 00:00:00 2001 From: chenping Date: Wed, 8 Jul 2026 10:22:55 +0800 Subject: [PATCH 15/23] feat(minimax-remover): add FP8 CUDA-graph capturable denoise path Add a manual graph-capturable denoise loop for the FP8 (W8A8) path that mirrors the NVFP4 _manual_denoise design but calls the installed FP8 block forwards. The diffusers denoise loop cannot be captured directly because condition_embedder uses torch.arange (CPU op) and FlowMatchEulerDiscreteScheduler.step mutates CPU state. The manual loop pre-computes time embeddings + norm_out modulation + RoPE + dt outside the graph, then runs pre-allocated buffer copies + transformer forward + in-place Triton euler step -- all kernel launches, fully capturable. Gated behind FLASHRT_FP8_GRAPH=1 (default off). The first call uses the diffusers path for calibration; the graph is captured on the second call and replayed thereafter. Requires a graph-safe attention backend (triton_fp8/triton_fp16); the default sage_fp8 is not graph-safe. Also fixes a transformer dtype resolution bug (next(parameters()) hits an fp32 scale_shift_table) and uses per-channel latent normalize/denormalize in the manual path (the shared Triton helper collapses latents_std to a scalar via .max(), which is wrong for per-channel stats). Performance note: graph capture is technically correct but not a net win here -- the denoise loop is GPU-bound (graph saves only ~20ms), and the graph-safe attention backends are ~1.1s slower than sage_fp8. Retained for when a fast graph-safe attention backend (e.g. flash_rt_fa2) is built. --- .../minimax_remover/_fp8_manual_denoise.py | 267 ++++++++++++++++++ .../models/minimax_remover/_fp8_pipeline.py | 79 ++++++ 2 files changed, 346 insertions(+) create mode 100644 flash_rt/models/minimax_remover/_fp8_manual_denoise.py diff --git a/flash_rt/models/minimax_remover/_fp8_manual_denoise.py b/flash_rt/models/minimax_remover/_fp8_manual_denoise.py new file mode 100644 index 00000000..02823157 --- /dev/null +++ b/flash_rt/models/minimax_remover/_fp8_manual_denoise.py @@ -0,0 +1,267 @@ +"""FlashRT -- MiniMax-Remover FP8 manual graph-capturable denoise pipeline. + +Replaces the diffusers ``MinimaxRemoverPipeline.__call__`` denoise loop +with a pointer-based implementation that mirrors ``_manual_denoise.py`` +(NVFP4) but calls the **installed FP8 block forwards** from +``_kern_block.install_fused_blocks``. + +Why a manual loop is needed for CUDA Graph capture: + * ``condition_embedder`` calls ``Timesteps`` which uses ``torch.arange`` + (a CPU op) -- breaks capture. + * ``FlowMatchEulerDiscreteScheduler.step`` mutates ``self.step_index`` + (a Python int) and does CPU indexing into ``sigmas`` -- breaks capture. + +This module: + 1. Pre-computes ALL per-step time embeddings + norm_out modulation + BEFORE capture (condition_embedder runs once per step, outside the + graph). + 2. Pre-computes RoPE freqs (fixed for a given latent shape). + 3. Pre-computes the Euler flow-matching ``dt`` values. + 4. Runs a manual denoise loop of pre-allocated buffer copies + the + installed FP8 transformer forward + an in-place Triton euler step -- + every operation is a kernel launch, fully CUDA-graph capturable. + 5. Captures the entire N-step loop as ONE graph and replays it on + subsequent calls with the same latent shape. + +The FP8 act_scales MUST be frozen (static calibration) before capture -- +the parent ``MiniMaxRemoverPipelineFP8`` guarantees this (mid-inference +freeze after step 1 on the first call). Capture therefore happens on the +second call; the first call runs the diffusers (calibration) path. + +-------------------------------------------------------------------- +Performance finding (measured on RTX 5060 Ti, 70-frame tennis clip) +-------------------------------------------------------------------- +Graph capture is **technically feasible and bit-correct** (with a +graph-safe attention backend, the captured graph reproduces the eager +result exactly). However it is **not a net win** for this workload: + + * The denoise loop is GPU-bound -- the kernels are large and the GPU + stays saturated, so Python/launch overhead is negligible + (graph replay saves only ~20 ms vs eager, both using triton_fp8). + * The fast default attention backend (``sage_fp8`` = SageAttention + QK-int8/PV-fp8) is **not** CUDA-graph safe -- it produces garbage + inside a captured graph (likely a CPU-syncing absmax reduction). + Switching to a graph-safe backend (``triton_fp8``/``triton_fp16``) + costs ~1.1 s, far more than the graph saves. + +Result: graph replay (triton_fp8) = 7.87 s vs the default sage_fp8 +eager path = 6.76 s (2nd call). The graph loses by ~1.1 s. + +The code is retained (gated behind ``FLASHRT_FP8_GRAPH=1``, default off) +because it is correct and would become worthwhile if a fast graph-safe +attention backend is added (e.g. FlashRT's vendored ``flash_rt_fa2``, +which is pointer-based and graph-safe but not built in this tree). + +No MiniMax-Remover imports: tensors + the loaded diffusers ``pipe`` only. +""" + +import logging +import os + +import torch +from einops import rearrange +from diffusers.utils.torch_utils import randn_tensor + +from ._kernels import (ada_layernorm_fp16_io, euler_step_inplace, mask_mul, + latent_normalize, latent_denormalize) + +logger = logging.getLogger(__name__) + + +def transformer_forward_fp8(transformer, hidden_states, tproj_step, + mod_out_step, rotary_emb, eps): + """FP8 transformer forward with pre-computed time projection + norm_out mod. + + Calls the installed FP8 block forwards (``block(hs, tproj, rope)``). + Avoids ``condition_embedder`` (torch.arange) and uses ``ada_layernorm`` + for norm_out, so the whole forward is CUDA-graph capturable. + + Args: + transformer: Transformer3DModel with FP8-patched blocks + attention. + hidden_states: [B, 3*C, T, H, W] concat latent (fp16). + tproj_step: [1, 6, D] pre-computed time projection (unflattened). + mod_out_step: [2, D] fp32 (shift, scale) for norm_out. + rotary_emb: pre-computed RoPE freqs. + eps: layer-norm epsilon. + Returns: + [B, C_out, T, H, W] noise prediction (fp16). + """ + B, C_in, T, H, W = hidden_states.shape + p_t, p_h, p_w = transformer.config.patch_size + post_t, post_h, post_w = T // p_t, H // p_h, W // p_w + + hs = transformer.patch_embedding(hidden_states) + hs = hs.flatten(2).transpose(1, 2) + + for block in transformer.blocks: + hs = block(hs, tproj_step, rotary_emb) + + S, D = hs.shape[1], hs.shape[2] + hs_2d = hs.contiguous().view(S, D) + # norm_out: original is (FP32LayerNorm(hs) * (1+scale) + shift).type_as; + # ada_layernorm_fp16_io is the fp32-stat reference-equivalent single kernel + # (same kernel used by every block's norm1/norm2). mod_out_step[0]=shift, + # mod_out_step[1]=scale. + hs_2d = ada_layernorm_fp16_io(hs_2d, mod_out_step[1], mod_out_step[0], eps) + hs = transformer.proj_out(hs_2d.view(1, S, D)) + + hs = hs.reshape(B, post_t, post_h, post_w, p_t, p_h, p_w, -1) + hs = hs.permute(0, 7, 1, 4, 2, 5, 3, 6) + return hs.flatten(6, 7).flatten(4, 5).flatten(2, 3) + + +class FP8ManualDenoise: + """Manual graph-capturable denoise for the FP8 (W8A8) path. + + Owned by ``MiniMaxRemoverPipelineFP8``. ``denoise(...)`` runs the full + N-step loop, capturing a CUDA Graph on the first invocation for a given + latent shape and replaying it thereafter. FP8 scales must be frozen + before the first ``denoise`` call. + """ + + def __init__(self, pipe, transformer): + self.pipe = pipe + self.transformer = transformer + self.eps = float(transformer.config.eps) + self._dtype = next(transformer.parameters()).dtype + self._vae_dtype = next(pipe.vae.parameters()).dtype + # Per-shape cache: (graph, lat_buf, masked_buf, masks_buf, + # tproj_all, mod_out, dt_all, rotary_emb) + self._graphs = {} + + # ------------------------------------------------------------------ # + # Static (per-shape, per-step) pre-computation -- runs OUTSIDE graph. # + # ------------------------------------------------------------------ # + def _precompute_static(self, latents, num_steps): + """Pre-compute time embeddings, norm_out modulation, RoPE, dt. + + These are identical for every call with the same (latent shape, + num_steps), so they are cached per shape key. + """ + device = latents.device + scheduler = self.pipe.scheduler + scheduler.set_timesteps(num_steps, device=device) + timesteps = scheduler.timesteps + sigmas = scheduler.sigmas + dt_all = [float(sigmas[i + 1] - sigmas[i]) for i in range(num_steps)] + + tr = self.transformer + D = tr.scale_shift_table.shape[-1] + temb_all, tproj_all = [], [] + mod_out = torch.empty(num_steps, 2, D, dtype=torch.float32, + device=device) + with torch.no_grad(): + for i in range(num_steps): + t_step = timesteps[i:i + 1] + temb_i, tproj_i = tr.condition_embedder(t_step) + tproj_i = tproj_i.unflatten(1, (6, -1)) + temb_all.append(temb_i) + tproj_all.append(tproj_i) + temb_f = temb_i.float().unsqueeze(1) + mod_out[i] = (tr.scale_shift_table + temb_f).squeeze(0) + + rotary_emb = tr.rope(latents) + return tproj_all, mod_out, dt_all, rotary_emb + + # ------------------------------------------------------------------ # + # The graph-capturable N-step loop. # + # ------------------------------------------------------------------ # + def _denoise_loop_body(self, lat_buf, masked_buf, masks_buf, concat_buf, + tproj_all, mod_out, dt_all, rotary_emb, num_steps): + C = lat_buf.shape[1] + tr = self.transformer + eps = self.eps + for step in range(num_steps): + concat_buf[:, :C].copy_(lat_buf) + concat_buf[:, C:2 * C].copy_(masked_buf) + concat_buf[:, 2 * C:3 * C].copy_(masks_buf) + noise_pred = transformer_forward_fp8( + tr, concat_buf, tproj_all[step], mod_out[step], + rotary_emb, eps) + euler_step_inplace(lat_buf, noise_pred, dt_all[step]) + + def _capture_graph(self, latents, masked_latents, masks_latents, + tproj_all, mod_out, dt_all, rotary_emb, num_steps): + """Capture the full N-step denoise loop as one CUDA Graph.""" + device = latents.device + C = latents.shape[1] + B, _, T, H, W = latents.shape + dtype = latents.dtype + + lat_buf = latents.clone() + masked_buf = masked_latents.clone() + masks_buf = masks_latents.clone() + concat_buf = torch.empty(B, 3 * C, T, H, W, dtype=dtype, device=device) + + def denoise(): + self._denoise_loop_body( + lat_buf, masked_buf, masks_buf, concat_buf, + tproj_all, mod_out, dt_all, rotary_emb, num_steps) + + # Warmup on a side stream to compile all kernels / init cuBLASLt + # workspaces before capture. The FP8 scales are already frozen, so + # the warmup runs with the exact same kernels as capture/replay. + s = torch.cuda.Stream() + n_warmup = int(os.environ.get("FLASHRT_FP8_GRAPH_WARMUP", "1")) + with torch.cuda.stream(s): + for _ in range(n_warmup): + denoise() + torch.cuda.current_stream().wait_stream(s) + torch.cuda.synchronize() + + # Reset the latent buffer to the caller's input before capture (the + # warmup mutated it); the capture pass will produce the final result. + lat_buf.copy_(latents) + + graph = torch.cuda.CUDAGraph() + with torch.cuda.graph(graph, stream=s): + denoise() + + logger.info("MiniMax-Remover FP8: CUDA Graph captured for shape %s " + "(%d steps, warmup=%d)", tuple(latents.shape), + num_steps, n_warmup) + return (graph, lat_buf, masked_buf, masks_buf, + tproj_all, mod_out, dt_all, rotary_emb) + + # ------------------------------------------------------------------ # + # Public entry: run the denoise, capture-or-replay per shape. # + # ------------------------------------------------------------------ # + def denoise(self, latents, masked_latents, masks_latents, num_steps, + use_graph=True): + """Run the N-step denoise; capture+replay a graph when use_graph. + + Returns the final latents tensor (same shape/dtype as ``latents``). + """ + shape_key = tuple(latents.shape) + (num_steps,) + entry = self._graphs.get(shape_key) if use_graph else None + + if entry is None: + tproj_all, mod_out, dt_all, rotary_emb = self._precompute_static( + latents, num_steps) + if use_graph: + entry = self._capture_graph( + latents, masked_latents, masks_latents, + tproj_all, mod_out, dt_all, rotary_emb, num_steps) + self._graphs[shape_key] = entry + else: + # Eager manual path (no graph) -- still avoids condition_embedder + # / scheduler CPU ops; useful for PSNR validation vs graph. + C = latents.shape[1] + concat_buf = torch.empty( + latents.shape[0], 3 * C, *latents.shape[2:], + dtype=latents.dtype, device=latents.device) + lat_buf = latents.clone() + self._denoise_loop_body( + lat_buf, masked_latents, masks_latents, concat_buf, + tproj_all, mod_out, dt_all, rotary_emb, num_steps) + return lat_buf + + (graph, lat_buf, masked_buf, masks_buf, + tproj_all, mod_out, dt_all, rotary_emb) = entry + # Copy this call's inputs into the captured buffers, then replay. + lat_buf.copy_(latents) + masked_buf.copy_(masked_latents) + masks_buf.copy_(masks_latents) + graph.replay() + torch.cuda.synchronize() + return lat_buf.clone() diff --git a/flash_rt/models/minimax_remover/_fp8_pipeline.py b/flash_rt/models/minimax_remover/_fp8_pipeline.py index 93e9e452..ebcd7199 100644 --- a/flash_rt/models/minimax_remover/_fp8_pipeline.py +++ b/flash_rt/models/minimax_remover/_fp8_pipeline.py @@ -15,10 +15,14 @@ import os import torch +from einops import rearrange +from diffusers.utils.torch_utils import randn_tensor logger = logging.getLogger(__name__) from flash_rt.models.minimax_remover._utils import load_fp8_kernels +from flash_rt.models.minimax_remover._kernels import mask_mul +from flash_rt.models.minimax_remover._fp8_manual_denoise import FP8ManualDenoise def _import_runtime_fp8(): @@ -96,6 +100,16 @@ def __init__(self, pipe, num_inference_steps=12, fp8_target="all", self._orig_pipe_call = self.pipe.__call__ + # Manual graph-capturable denoise (used once calibrated + when + # FLASHRT_FP8_GRAPH=1). Lazily captures a CUDA Graph per latent shape. + self._graph_denoise = FP8ManualDenoise(self.pipe, self.transformer) + # Transformer compute dtype. ``next(transformer.parameters())`` is + # unreliable here because scale_shift_table / time_embedder are kept + # in fp32 (via _keep_in_fp32_modules). The diffusers reference path + # hardcodes fp16 (bf16 only when use_bf16). + self._dtype = torch.bfloat16 if use_bf16 else torch.float16 + self._vae_dtype = next(self.pipe.vae.parameters()).dtype + @torch.no_grad() def __call__(self, *args, **kwargs): """Run the wrapped pipe, calibrating FP8 scales on the first call. @@ -106,7 +120,14 @@ def __call__(self, *args, **kwargs): kernel) run with static scales, so a single-call invocation benefits from the fused path instead of only multi-call ones. The cost is a single CPU sync (~1 ms) after step 1. + + When ``FLASHRT_FP8_GRAPH=1`` and scales are frozen (call 2+), the + denoise loop runs via the manual graph-capturable path + (``_manual_call`` -> ``FP8ManualDenoise``). The first call always + uses the diffusers path (calibration); the graph is captured on + the second call and replayed thereafter. """ + use_graph = os.environ.get("FLASHRT_FP8_GRAPH", "0") == "1" if not self._calibrated: logger.info("MiniMax-Remover FP8: calibration mode " "(first call, dynamic FP8 + amax accumulation; " @@ -132,6 +153,64 @@ def _freeze_after_step1(_module, _inp, _out): result = self._orig_pipe_call(*args, **kwargs) finally: handle.remove() + elif use_graph: + # Frozen scales + graph requested: manual graph-capturable path. + result = self._manual_call(*args, **kwargs) else: result = self._orig_pipe_call(*args, **kwargs) return result + + @torch.no_grad() + def _manual_call(self, images, masks, num_frames, height, width, + num_inference_steps=12, generator=None, iterations=16, + output_type="np"): + """Manual encode + graph-denoise + decode (mirrors the diffusers + ``MinimaxRemoverPipeline.__call__`` but replaces the denoise loop + with the CUDA-graph-capturable ``FP8ManualDenoise``). Requires + frozen FP8 scales (caller guarantees calibration is done). + """ + pipe = self.pipe + device = self.transformer.device + + pipe.scheduler.set_timesteps(num_inference_steps, device=device) + num_channels_latents = 16 + vsft = pipe.vae_scale_factor_temporal + vsfs = pipe.vae_scale_factor_spatial + num_latent_frames = (num_frames - 1) // vsft + 1 + shape = (1, num_channels_latents, num_latent_frames, + height // vsfs, width // vsfs) + latents = randn_tensor(shape, generator=generator, device=device, + dtype=self._dtype) + + masks_t = pipe.expand_masks(masks, iterations) + masks_t = pipe.resize(masks_t, height, width).to(device).to(self._vae_dtype) + masks_t[masks_t > 0] = 1 + images_t = rearrange(images, "f h w c -> c f h w") + images_t = pipe.resize(images_t[None, ...], height, width).to(device).to(self._vae_dtype) + masked_images = mask_mul(images_t, masks_t) + + latents_mean = (torch.tensor(pipe.vae.config.latents_mean) + .view(1, pipe.vae.config.z_dim, 1, 1, 1) + .to(device, self._vae_dtype)) + latents_std = 1.0 / torch.tensor(pipe.vae.config.latents_std).view( + 1, pipe.vae.config.z_dim, 1, 1, 1).to(device, self._vae_dtype) + + masked_latents = pipe.vae.encode(masked_images.to(self._vae_dtype)).latent_dist.mode() + masks_latents = pipe.vae.encode((2 * masks_t - 1.0).to(self._vae_dtype)).latent_dist.mode() + # Per-channel normalize (matches diffusers exactly). Done outside the + # graph; the latent_normalize() Triton helper collapses latents_std to + # a scalar via .max() which is wrong for per-channel stats. + masked_latents = ((masked_latents - latents_mean) * latents_std).to(self._dtype) + masks_latents = ((masks_latents - latents_mean) * latents_std).to(self._dtype) + + result_latents = self._graph_denoise.denoise( + latents, masked_latents, masks_latents, num_inference_steps, + use_graph=True) + + result_latents = (result_latents.to(self._vae_dtype) / latents_std + + latents_mean) + video = pipe.vae.decode(result_latents, return_dict=False)[0] + video = pipe.video_processor.postprocess_video(video, output_type=output_type) + + from diffusers.pipelines.wan.pipeline_output import WanPipelineOutput + return WanPipelineOutput(frames=video) From 4f08c9f5dd15e73a37f2f1353c607d152e908063 Mon Sep 17 00:00:00 2001 From: chenping Date: Wed, 8 Jul 2026 11:10:04 +0800 Subject: [PATCH 16/23] feat(minimax-remover): add fused bias+gate+residual and fp16x8 bias-add kernels Collapse the O-projection / FFN-down block tails (add_bias_fp16 + gate_mul_residual_bcast) into a single fp16x8 (uint4) kernel that reads the GEMM output once and writes straight into the residual, eliminating the intermediate fp16 RMW pass across 720 slots per denoise. Also add a vectorised fp16_add_bias_vec8 for the Q/K/V projections (8x fewer memory transactions than the scalar path). Denoise GPU kernel time 3.10 s -> 3.03 s; end-to-end 7.57 s at 2.30x vs the fp16 reference (17.42 s). PSNR 40.0 dB median, 36.2 dB worst. Wall time is launch-bound at 12 steps x 30 blocks. Toggle via FLASHRT_DISABLE_BIAS_GATE=1 for A/B without a rebuild. --- CMakeLists.txt | 5 +- .../fp16_bias_gate_residual.cu | 146 ++++++++++++++++++ .../fp16_bias_gate_residual.cuh | 67 ++++++++ csrc/minimax_remover_extra_bindings.cpp | 33 ++++ docs/minimax_remover_usage.md | 39 ++++- flash_rt/models/minimax_remover/_attention.py | 9 +- .../models/minimax_remover/_fp8_linear.py | 54 ++++++- .../models/minimax_remover/_kern_block.py | 110 +++++++++---- 8 files changed, 421 insertions(+), 42 deletions(-) create mode 100644 csrc/kernels/minimax_remover/fp16_bias_gate_residual.cu create mode 100644 csrc/kernels/minimax_remover/fp16_bias_gate_residual.cuh diff --git a/CMakeLists.txt b/CMakeLists.txt index 7cee57df..47c6d4b4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -970,7 +970,8 @@ if(FLASHRT_ENABLE_MINIMAX_REMOVER) csrc/kernels/minimax_remover/fp8_conv3d_mm_ndhwc_fp16out.cu csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cu csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cu - csrc/kernels/minimax_remover/fp16_bias_gelu_quant_fp8.cu) + csrc/kernels/minimax_remover/fp16_bias_gelu_quant_fp8.cu + csrc/kernels/minimax_remover/fp16_bias_gate_residual.cu) set_target_properties(flash_rt_minimax_remover PROPERTIES CUDA_STANDARD 17 LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/flash_rt @@ -986,7 +987,7 @@ if(FLASHRT_ENABLE_MINIMAX_REMOVER) -U__CUDA_NO_HALF_CONVERSIONS__ ${GPU_GENCODE} >) - target_link_libraries(flash_rt_minimax_remover PRIVATE CUDA::cudart) + target_link_libraries(flash_rt_minimax_remover PRIVATE CUDA::cudart CUDA::cublas CUDA::cublasLt) install(TARGETS flash_rt_minimax_remover LIBRARY DESTINATION flash_rt) message(STATUS "MiniMax-Remover VAE fused kernels: ENABLED") else() diff --git a/csrc/kernels/minimax_remover/fp16_bias_gate_residual.cu b/csrc/kernels/minimax_remover/fp16_bias_gate_residual.cu new file mode 100644 index 00000000..0c849330 --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_bias_gate_residual.cu @@ -0,0 +1,146 @@ +// ================================================================ +// flash_rt_minimax_remover — fused bias + gate·residual kernel. +// See fp16_bias_gate_residual.cuh for the semantics. +// +// Optimisation summary vs the stock `add_bias_fp16` + Triton +// `gate_mul_residual_bcast` sequence: +// * Removes the intermediate fp16 read-modify-write on `out` +// (one full [M,D] pass eliminated per call). +// * fp16x8 (uint4) vector loads/stores → 1/8 the memory +// transactions of the scalar bias kernel. +// * Bias & gate rows are cooperatively staged to shared memory +// once per block (each block covers `THREADS * VEC` = 8×D0 +// columns of a single row cache-line width). +// +// Layout constraints: +// D % 8 == 0 (MiniMax-Remover Linears: 1536, 8960 — both /8). +// ================================================================ +#include "fp16_bias_gate_residual.cuh" + +#include +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +namespace { + +// One thread processes VEC=8 fp16 elements via one uint4 (=8×fp16) op. +constexpr int VEC = 8; + +__device__ __forceinline__ void load_vec8(const __half* p, __half2 out[4]) { + const uint4* pu = reinterpret_cast(p); + uint4 v = *pu; + out[0] = *reinterpret_cast<__half2*>(&v.x); + out[1] = *reinterpret_cast<__half2*>(&v.y); + out[2] = *reinterpret_cast<__half2*>(&v.z); + out[3] = *reinterpret_cast<__half2*>(&v.w); +} + +__device__ __forceinline__ void store_vec8(__half* p, const __half2 x[4]) { + uint4 v; + v.x = *reinterpret_cast(&x[0]); + v.y = *reinterpret_cast(&x[1]); + v.z = *reinterpret_cast(&x[2]); + v.w = *reinterpret_cast(&x[3]); + *reinterpret_cast(p) = v; +} + +// grid.x = M, grid.y = D_vec = D/8 +// each thread → one 8-element column tile of one row. +__global__ void bias_gate_residual_kernel( + const __half* __restrict__ out, + const __half* __restrict__ bias, + const __half* __restrict__ gate, + __half* __restrict__ residual, + int M, int D_vec) { + const int m = blockIdx.x; + const int dv = blockIdx.y * blockDim.x + threadIdx.x; + if (dv >= D_vec) return; + + const int d = dv * VEC; + const int base = m * (D_vec * VEC) + d; + + __half2 o[4], b[4], g[4], r[4]; + load_vec8(out + base, o); + load_vec8(bias + d, b); + load_vec8(gate + d, g); + load_vec8(residual + base, r); + + #pragma unroll + for (int i = 0; i < 4; i++) { + // (o + b) * g — in fp16 for speed. Bias values are small + // (≪ fp16 max) and the residual is already fp16, so no + // range issues; matches the numerical behaviour of the + // previous (scalar bias fp16 + Triton fp32-accum gate) + // pipeline within fp16 rounding tolerance. + __half2 sum = __hadd2(o[i], b[i]); + __half2 mul = __hmul2(sum, g[i]); + r[i] = __hadd2(r[i], mul); + } + store_vec8(residual + base, r); +} + +__global__ void add_bias_vec8_kernel( + __half* __restrict__ x, + const __half* __restrict__ bias, + int M, int D_vec) { + const int m = blockIdx.x; + const int dv = blockIdx.y * blockDim.x + threadIdx.x; + if (dv >= D_vec) return; + + const int d = dv * VEC; + const int base = m * (D_vec * VEC) + d; + + __half2 xv[4], bv[4]; + load_vec8(x + base, xv); + load_vec8(bias + d, bv); + #pragma unroll + for (int i = 0; i < 4; i++) xv[i] = __hadd2(xv[i], bv[i]); + store_vec8(x + base, xv); +} + +} // namespace + +int fp16_bias_gate_residual_bcast( + const void* out_fp16, + const void* bias_fp16, + const void* gate_fp16, + void* residual_fp16, + int M, int D, + cudaStream_t stream) { + if (!out_fp16 || !bias_fp16 || !gate_fp16 || !residual_fp16) return -1; + if (M <= 0 || D <= 0 || (D % VEC) != 0) return -2; + const int D_vec = D / VEC; + const int threads = 128; + dim3 grid(M, (D_vec + threads - 1) / threads); + bias_gate_residual_kernel<<>>( + reinterpret_cast(out_fp16), + reinterpret_cast(bias_fp16), + reinterpret_cast(gate_fp16), + reinterpret_cast<__half*>(residual_fp16), + M, D_vec); + return 0; +} + +int fp16_add_bias_vec8( + void* x_fp16, + const void* bias_fp16, + int M, int D, + cudaStream_t stream) { + if (!x_fp16 || !bias_fp16) return -1; + if (M <= 0 || D <= 0 || (D % VEC) != 0) return -2; + const int D_vec = D / VEC; + const int threads = 128; + dim3 grid(M, (D_vec + threads - 1) / threads); + add_bias_vec8_kernel<<>>( + reinterpret_cast<__half*>(x_fp16), + reinterpret_cast(bias_fp16), + M, D_vec); + return 0; +} + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_bias_gate_residual.cuh b/csrc/kernels/minimax_remover/fp16_bias_gate_residual.cuh new file mode 100644 index 00000000..13e35c34 --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_bias_gate_residual.cuh @@ -0,0 +1,67 @@ +// ================================================================ +// flash_rt_minimax_remover — fused bias + gate·residual kernel. +// +// The MiniMax-Remover transformer block runs the following pattern +// after every attention output (O-proj) and every FFN down proj: +// +// out_fp16 = FP8_GEMM(...) // no bias yet +// add_bias_fp16(out, bias, S, D) // RMW on out +// gate_mul_residual_bcast(residual, out, gate) // residual += out*gate +// +// The middle step is a full fp16 read-modify-write pass over out that +// happens JUST BEFORE the residual add reads out again — cache-hostile +// (2 * S * D fp16 traffic per pass, ~100 MB per call at S=32256/D=1536). +// +// This kernel folds them into one: +// residual[i] += (out[i] + bias[i % D]) * gate[i % D] +// which drops the intermediate RMW on `out` entirely (one fewer full +// pass over ~50M fp16 elements per call). Node-level profiling put +// `add_bias_fp16` at 280 ms / 1812 calls; the O-proj + FFN-down slots +// account for 720 of those calls — this kernel eliminates them. +// +// Layout / semantics: +// out : [M, D] fp16, row-major (read-only) +// bias : [D] fp16 (broadcast along M) +// gate : [D] fp16 (broadcast along M) +// residual : [M, D] fp16 (accumulated in place) +// +// Uses fp16x8 vector loads/stores (uint4) so each thread processes 8 +// elements, cutting global memory transactions 8× versus the scalar +// `add_bias_fp16` kernel. D is assumed to be a multiple of 8 (true +// for all MiniMax-Remover Linears; D ∈ {1536, 8960}). +// ================================================================ +#ifndef FLASHRT_KERNELS_MINIMAX_REMOVER_FP16_BIAS_GATE_RES_CUH +#define FLASHRT_KERNELS_MINIMAX_REMOVER_FP16_BIAS_GATE_RES_CUH + +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +// Fused: residual[m,d] += (out[m,d] + bias[d]) * gate[d] +// +// All buffers are fp16. bias and gate are broadcast vectors of length D. +// Returns 0 on success, negative on invalid args. +int fp16_bias_gate_residual_bcast( + const void* out_fp16, + const void* bias_fp16, + const void* gate_fp16, + void* residual_fp16, + int M, int D, + cudaStream_t stream); + +// Vectorised replacement for the generic scalar add_bias_fp16: +// x[m,d] = x[m,d] + bias[d] (broadcast bias, in-place) +// Uses fp16x8 (uint4) accesses; D must be a multiple of 8. +int fp16_add_bias_vec8( + void* x_fp16, + const void* bias_fp16, + int M, int D, + cudaStream_t stream); + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt + +#endif // FLASHRT_KERNELS_MINIMAX_REMOVER_FP16_BIAS_GATE_RES_CUH diff --git a/csrc/minimax_remover_extra_bindings.cpp b/csrc/minimax_remover_extra_bindings.cpp index d88a9136..92ce8a3b 100644 --- a/csrc/minimax_remover_extra_bindings.cpp +++ b/csrc/minimax_remover_extra_bindings.cpp @@ -20,6 +20,7 @@ #include "kernels/minimax_remover/fp16_quant_fp8_per_tensor.cuh" #include "kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cuh" #include "kernels/minimax_remover/fp16_bias_gelu_quant_fp8.cuh" +#include "kernels/minimax_remover/fp16_bias_gate_residual.cuh" namespace py = pybind11; @@ -282,4 +283,36 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { py::arg("stream") = 0, "Fused: fp16 GEMM-out + bias → fp8 e4m3 (identity activation). " "For Linear→Linear chains with no activation in between."); + + // ── Fused bias + gate·residual (eliminates the mid-block bias-add RMW) ── + m.def("fp16_bias_gate_residual_bcast", + [](uintptr_t out_fp16, uintptr_t bias_fp16, uintptr_t gate_fp16, + uintptr_t residual_fp16, int M, int D, uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + fp16_bias_gate_residual_bcast( + to_ptr(out_fp16), to_ptr(bias_fp16), to_ptr(gate_fp16), + to_ptr(residual_fp16), M, D, to_stream(stream)); + }, + py::arg("out_fp16"), py::arg("bias_fp16"), + py::arg("gate_fp16"), py::arg("residual_fp16"), + py::arg("M"), py::arg("D"), + py::arg("stream") = 0, + "Fused: residual[m,d] += (out[m,d] + bias[d]) * gate[d]. " + "Replaces add_bias_fp16 + gate_mul_residual_bcast (2 kernels → 1) " + "for the O-proj and FFN-down slots — cuts one full [M,D] fp16 " + "read-modify-write per call. D must be a multiple of 8."); + + m.def("fp16_add_bias_vec8", + [](uintptr_t x_fp16, uintptr_t bias_fp16, + int M, int D, uintptr_t stream) { + return flash_rt::kernels::minimax_remover::fp16_add_bias_vec8( + to_ptr(x_fp16), to_ptr(bias_fp16), M, D, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("bias_fp16"), + py::arg("M"), py::arg("D"), + py::arg("stream") = 0, + "Vectorised (fp16x8) in-place add_bias: x[m,d] += bias[d]. " + "Replaces the scalar decoder_fused add_bias_fp16 kernel for the " + "Q/K/V projections; ~8× fewer memory transactions. D must be " + "a multiple of 8."); } diff --git a/docs/minimax_remover_usage.md b/docs/minimax_remover_usage.md index 07ef9353..2410bff2 100644 --- a/docs/minimax_remover_usage.md +++ b/docs/minimax_remover_usage.md @@ -69,6 +69,8 @@ cmake --build build -j --target flash_rt_minimax_remover | `quantize_fp16_fp8_with_amax_dual` | `flash_rt_minimax_remover` | two separate `quantize_fp16_fp8_with_amax` calls (cache + new) | single kernel launch quantizes two buffers with shared amax; saves 1 launch per conv layer | | `bias_gelu_quant_fp16_fp8` | `flash_rt_minimax_remover` | transformer FFN `add_bias_fp16` + `gelu_inplace_fp16` + `quantize_fp8_static_fp16` (3 kernels) | fused single-pass: fp16 GEMM-out + bias → tanh-gelu → fp8 e4m3; output is the pre-quantised input of the next FP8 Linear (skips its activation quantise); eliminates 3 full-tensor fp16 round-trips per FFN block | | `bias_quant_fp16_fp8` | `flash_rt_minimax_remover` | `add_bias_fp16` + `quantize_fp8_static_fp16` (identity activation variant) | fused bias + quant for Linear→Linear chains with no activation | +| `fp16_bias_gate_residual_bcast` | `flash_rt_minimax_remover` | transformer O-proj / FFN-down `add_bias_fp16` + `gate_mul_residual_bcast` (2 kernels) | fused single-pass fp16x8 (uint4) kernel: `residual[m,d] += (out[m,d] + bias[d]) * gate[d]`; eliminates one full [S,D] fp16 read-modify-write per call (~720 slots / denoise) | +| `fp16_add_bias_vec8` | `flash_rt_minimax_remover` | scalar `add_bias_fp16` (decoder_fused) | vectorised fp16x8 (uint4) in-place bias add; used for Q/K/V and any bias-only slot; ~8× fewer memory transactions | | channels-last pipeline | Python (`_vae_opt.py`) | Conv3d weight → CL, WanCausalConv3d → CL-preserving forward | eliminates ~97% of nchw↔nhwc conversion kernels (~280 ms / decode) | | Running-max amax | Python (`_vae_opt.py`) | separate `amax_fp16` calls over cache + new each iteration | norm fuses amax via atomicMax into a persistent buffer shared with the sister conv; cache amax is skipped entirely (covered by running max) | | `WanUpsample` patch | Python (no kernel) | `x.float().type_as(x)` for nearest-exact upsample | eliminates redundant fp32 cast (index-only op, fp16 == fp32) | @@ -182,6 +184,32 @@ benefits without needing a separate warm-up pass. Result: **denoise GPU time 3.73 s → 3.16 s** (-15%), end-to-end **8.58 s → 7.56 s** (**2.29× vs baseline**), PSNR **40.0 dB** (median). +#### Fused O-proj / FFN-down bias + gate + residual + +Each transformer block finishes both the attention O-projection and +the FFN-down projection with the same 3-op tail: + + `add_bias_fp16` + `gate_mul_residual_bcast` → `residual += (out + bias) * gate[D]` + +That's 2 kernel launches + one full-tensor fp16 read-modify-write on +`out` per slot, and there are 30 blocks × 2 slots × 12 steps = **720 +occurrences per denoise**. `fp16_bias_gate_residual_bcast` collapses +the pair into a single fp16x8 (uint4) kernel that reads `out` once, +folds in the broadcast bias & gate, and writes straight into the +residual — eliminating the intermediate RMW pass. `fp16_add_bias_vec8` +vectorises the remaining Q/K/V scalar bias adds to 8× fewer memory +transactions. + +Result: **denoise GPU kernel time 3.10 s → 3.03 s** (-70 ms), end-to-end +**7.56 s → 7.57 s** (wall time is CPU/launch-bound at 12 steps × 30 +blocks × 432×240 — further wins require kernel-count reduction, e.g. +graph capture, rather than per-kernel savings). PSNR **40.0 dB** (median), +worst-frame **36.2 dB**. Overall stack now runs at **2.30× vs the fp16 +reference** (17.42 s → 7.57 s). + +An env-var toggle `FLASHRT_DISABLE_BIAS_GATE=1` disables this fusion +for A/B verification without a rebuild. + Importing `flash_rt.models.minimax_remover` always succeeds — it needs **none** of `diffusers` / `einops` / `scipy` / `triton` / `sageattention`. The kernel surface is validated lazily in each pipeline's `__init__` via @@ -309,7 +337,8 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the | tennis (70 frames, 432x240) | fp16 reference (`--no-flashrt`) | 17.33 s | 1.0x | — | — | | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL (`--no-fp8-conv`) | 10.01 s | 1.73x | 40.8 / 37.0 dB | 0.99981 | | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d | 8.58 s | 2.02x | 39.9 / 36.4 dB | 0.99981 | -| tennis (70 frames, 432x240) | **FlashRT FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue (default)** | **7.56 s** | **2.29x** | **40.0 / 36.4 dB** | 0.99981 | +| tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue | 7.56 s | 2.29x | 40.0 / 36.4 dB | 0.99981 | +| tennis (70 frames, 432x240) | **FlashRT FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue + fused bias-gate residual (default)** | **7.57 s** | **2.30x** | **40.0 / 36.2 dB** | 0.99981 | | tennis (70 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 9.52 s | 1.82x | 7.0 / 6.2 dB | 0.00000 (broken) | | bmx-trees (80 frames, 432x240) | fp16 reference (`--no-flashrt`) | 19.76 s | 1.0x | — | — | | bmx-trees (80 frames, 432x240) | FlashRT FP8 (default) | 13.24 s | **1.49x** | 35.1 / 32.0 dB | 0.99912 | @@ -321,12 +350,16 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the Takeaways: -- **FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue is the recommended default**: 2.29x +- **FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue + fused bias-gate residual is the recommended default**: 2.30x faster than the fp16 reference with PSNR 40.0 dB (median) on full-frame tennis clip, peak VRAM 2.51 GB. The fused FFN epilogue kernel (`bias_gelu_quant_fp16_fp8`) collapses bias-add + GELU + activation quantise into one pass, cutting denoise GPU time by 15% (3.73 → 3.16 s) - and end-to-end from 8.58 → 7.56 s. + and end-to-end from 8.58 → 7.56 s. The fused bias + gate + residual + kernel (`fp16_bias_gate_residual_bcast`) further collapses the O-proj + and FFN-down block tails (720 slots / denoise) into single-pass + fp16x8 kernels, trimming denoise GPU kernel time another –70 ms; + wall time is now launch-bound and unchanged (7.56 → 7.57 s). - **FP8 conv3d vs channels-last-only cuDNN**: the hand-rolled implicit-GEMM kernel (no im2col materialization, virtual cache concat, per-channel weight dequant, fused amax, running-max scale) beats cuDNN's fp16 conv3d diff --git a/flash_rt/models/minimax_remover/_attention.py b/flash_rt/models/minimax_remover/_attention.py index 9718412a..51120407 100644 --- a/flash_rt/models/minimax_remover/_attention.py +++ b/flash_rt/models/minimax_remover/_attention.py @@ -147,7 +147,8 @@ def __init__(self): self._cos_sin = {} def __call__(self, attn, hidden_states, rotary_emb=None, - attention_mask=None, encoder_hidden_states=None): + attention_mask=None, encoder_hidden_states=None, + no_out_bias=False): mode = _attention_mode() B, S, _ = hidden_states.shape H = attn.heads @@ -185,7 +186,11 @@ def __call__(self, attn, hidden_states, rotary_emb=None, lse_cache=self._lse_bufs) hidden_states = out.view(B, S, H * Dd) - hidden_states = attn.to_out[0](hidden_states) + to_out0 = attn.to_out[0] + if no_out_bias and hasattr(to_out0, "gemm_no_bias"): + hidden_states = to_out0.gemm_no_bias(hidden_states) + else: + hidden_states = to_out0(hidden_states) return hidden_states diff --git a/flash_rt/models/minimax_remover/_fp8_linear.py b/flash_rt/models/minimax_remover/_fp8_linear.py index 6ef03bd1..3284082a 100644 --- a/flash_rt/models/minimax_remover/_fp8_linear.py +++ b/flash_rt/models/minimax_remover/_fp8_linear.py @@ -25,6 +25,20 @@ from flash_rt import flash_rt_kernels as kern +# Optional: FlashRT MiniMax-Remover extra kernel module. Used for the +# vectorised fp16 bias-add kernel (a drop-in replacement for the scalar +# `add_bias_fp16` in decoder_fused.cu) — cuts the Q/K/V bias cost. +_fvk_extra = None +try: + from flash_rt import flash_rt_minimax_remover as _fvk_extra +except ImportError: + try: + import flash_rt_minimax_remover as _fvk_extra # type: ignore + except ImportError: + _fvk_extra = None +_has_add_bias_vec8 = _fvk_extra is not None and hasattr( + _fvk_extra, "fp16_add_bias_vec8") + logger = logging.getLogger(__name__) _FP8 = torch.float8_e4m3fn @@ -158,8 +172,14 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: m, n, k, scale.data_ptr(), self.weight_scale.data_ptr(), stream, ) if self.bias is not None: - # FlashRT fused bias (in-place [m,n]+=bias[n]), no torch op - kern.add_bias_fp16(out.data_ptr(), self.bias.data_ptr(), m, n, stream) + # Prefer the vectorised fp16x8 bias-add kernel (8× fewer memory + # transactions than the scalar decoder_fused implementation). + if _has_add_bias_vec8 and (n & 7) == 0: + _fvk_extra.fp16_add_bias_vec8( + out.data_ptr(), self.bias.data_ptr(), m, n, stream) + else: + kern.add_bias_fp16(out.data_ptr(), self.bias.data_ptr(), + m, n, stream) out = out.view(*orig_shape[:-1], n) if in_dtype != torch.float16: @@ -208,6 +228,29 @@ def gemm_no_bias(self, x: torch.Tensor) -> torch.Tensor: m, n, k, scale.data_ptr(), self.weight_scale.data_ptr(), stream) return out.view(*orig_shape[:-1], n) + def gemm_no_bias_from_fp8(self, x_fp8: torch.Tensor) -> torch.Tensor: + """FP8 GEMM from pre-quantised fp8 input, WITHOUT bias. + + Counterpart to ``gemm_no_bias`` when the input is already fp8 + (produced by ``bias_gelu_quant_fp16_fp8``). Caller applies bias + later (e.g. fused with gate + residual). + """ + if self.calibrating: + return self.forward(x_fp8) + orig_shape = x_fp8.shape + x2d = x_fp8.reshape(-1, self.in_features) + if x2d.stride(0) != self.in_features or x2d.stride(1) != 1: + x2d = x2d.contiguous() + m = x2d.shape[0] + k, n = self.in_features, self.out_features + scale = self.act_scale.data + stream = torch.cuda.current_stream().cuda_stream + out = torch.empty(m, n, dtype=torch.float16, device=x2d.device) + kern.fp8_gemm_descale_fp16( + x2d.data_ptr(), self.weight_fp8.data_ptr(), out.data_ptr(), + m, n, k, scale.data_ptr(), self.weight_scale.data_ptr(), stream) + return out.view(*orig_shape[:-1], n) + def forward_from_fp8(self, x_fp8: torch.Tensor) -> torch.Tensor: """FP8 GEMM from a pre-quantised fp8 input + bias (skip quantise). @@ -230,7 +273,12 @@ def forward_from_fp8(self, x_fp8: torch.Tensor) -> torch.Tensor: x2d.data_ptr(), self.weight_fp8.data_ptr(), out.data_ptr(), m, n, k, scale.data_ptr(), self.weight_scale.data_ptr(), stream) if self.bias is not None: - kern.add_bias_fp16(out.data_ptr(), self.bias.data_ptr(), m, n, stream) + if _has_add_bias_vec8 and (n & 7) == 0: + _fvk_extra.fp16_add_bias_vec8( + out.data_ptr(), self.bias.data_ptr(), m, n, stream) + else: + kern.add_bias_fp16(out.data_ptr(), self.bias.data_ptr(), + m, n, stream) return out.view(*orig_shape[:-1], n) diff --git a/flash_rt/models/minimax_remover/_kern_block.py b/flash_rt/models/minimax_remover/_kern_block.py index e6c258ad..b1cbd4b3 100644 --- a/flash_rt/models/minimax_remover/_kern_block.py +++ b/flash_rt/models/minimax_remover/_kern_block.py @@ -103,6 +103,25 @@ def _ada_norm_flashrt_fp16(self_hs, scale_v, shift_v, S, D): out.data_ptr(), S, D, eps, stream_of()) return out + _has_bgr = (_fvk is not None + and hasattr(_fvk, "fp16_bias_gate_residual_bcast") + and os.environ.get("FLASHRT_DISABLE_BIAS_GATE", "0") != "1") + + def _gate_residual_apply(hs, x_no_bias, bias, gate, S, D): + """residual += (x + bias) * gate[D] — one fused kernel if available, + else falls back to add_bias_fp16 + gate_mul_residual_bcast.""" + if _has_bgr and bias is not None and (D & 7) == 0: + gate_fp16 = gate.to(_FP16).contiguous().view(D) + _fvk.fp16_bias_gate_residual_bcast( + x_no_bias.data_ptr(), bias.data_ptr(), + gate_fp16.data_ptr(), hs.data_ptr(), + S, D, stream_of()) + else: + if bias is not None: + kern.add_bias_fp16(x_no_bias.data_ptr(), bias.data_ptr(), + S, D, stream_of()) + gate_mul_residual_bcast(hs, x_no_bias, gate.view(D)) + def block_forward(self, hidden_states, temb, rotary_emb): B, S, D = hidden_states.shape # Ensure contiguity at entry (first block truly copies; subsequent blocks are no-ops) @@ -115,9 +134,24 @@ def block_forward(self, hidden_states, temb, rotary_emb): norm1_out = _ada_norm_flashrt_fp16(hs, scale_msa, shift_msa, S, D) else: norm1_out = _ada_norm(hs, scale_msa, shift_msa, S, D) - attn_out = self.attn1(hidden_states=norm1_out.view(1, S, D), rotary_emb=rotary_emb).view(S, D) - # Broadcast gate[D] (avoids [S,D] expand copy); fp16 in-place - gate_mul_residual_bcast(hs, attn_out, gate_msa.view(D)) + # Fused bias+gate+residual on O-proj: ask attention to skip the + # to_out[0] bias, then apply it fused with the gate/residual step. + to_out0 = self.attn1.to_out[0] + _fuse_attn = (_has_bgr + and hasattr(to_out0, "gemm_no_bias") + and getattr(to_out0, "bias", None) is not None + and not getattr(to_out0, "calibrating", False) + and (D & 7) == 0) + if _fuse_attn: + attn_out = self.attn1( + hidden_states=norm1_out.view(1, S, D), + rotary_emb=rotary_emb, no_out_bias=True).view(S, D) + _gate_residual_apply(hs, attn_out, to_out0.bias, gate_msa, S, D) + else: + attn_out = self.attn1( + hidden_states=norm1_out.view(1, S, D), + rotary_emb=rotary_emb).view(S, D) + gate_mul_residual_bcast(hs, attn_out, gate_msa.view(D)) if norm_mode == "fp16": norm2_out = _ada_norm_flashrt_fp16(hs, c_scale_msa, c_shift_msa, S, D) @@ -126,36 +160,48 @@ def block_forward(self, hidden_states, temb, rotary_emb): n2_3d = norm2_out.view(1, S, D) if gelu_mode == "torch": ff_out = self.ffn(n2_3d).view(S, D) + gate_mul_residual_bcast(hs, ff_out, c_gate_msa.view(D)) + return hs.view(1, S, D) + + proj0 = self.ffn.net[0].proj + proj1 = self.ffn.net[2] + # Fused FFN epilogue: bias + gelu + quant → fp8 in one kernel. + # Only when both Linears are FlashRT FP8, the kernel module is + # loaded, scales are frozen (not calibrating), and proj0 has a + # bias. Saves 3 full-tensor fp16 round-trips per block. + _can_fuse = ( + _fvk is not None + and hasattr(proj0, "gemm_no_bias") + and hasattr(proj1, "forward_from_fp8") + and not getattr(proj1, "calibrating", False) + and getattr(proj0, "bias", None) is not None + and (proj0.out_features & 3) == 0) + # Additional fusion of proj1 (FFN-down) bias into gate+residual: + # requires the pre-quantised fp8 input path plus gemm_no_bias_from_fp8. + _fuse_ffn_down = (_has_bgr and _can_fuse + and hasattr(proj1, "gemm_no_bias_from_fp8") + and getattr(proj1, "bias", None) is not None + and (D & 7) == 0) + if _can_fuse: + raw = proj0.gemm_no_bias(n2_3d) # quant+GEMM, no bias → fp16 [1,S,inner] + inner = raw.shape[-1] + up_fp8 = torch.empty( + S, inner, dtype=torch.float8_e4m3fn, device=raw.device) + _fvk.bias_gelu_quant_fp16_fp8( + raw.data_ptr(), proj0.bias.data_ptr(), + up_fp8.data_ptr(), proj1.act_scale.data_ptr(), + S, inner, stream_of()) + if _fuse_ffn_down: + ff_out = proj1.gemm_no_bias_from_fp8(up_fp8).view(S, D) + _gate_residual_apply(hs, ff_out, proj1.bias, c_gate_msa, S, D) + return hs.view(1, S, D) + ff_out = proj1.forward_from_fp8(up_fp8).view(S, D) else: - proj0 = self.ffn.net[0].proj - proj1 = self.ffn.net[2] - # Fused FFN epilogue: bias + gelu + quant → fp8 in one kernel. - # Only when both Linears are FlashRT FP8, the kernel module is - # loaded, scales are frozen (not calibrating), and proj0 has a - # bias. Saves 3 full-tensor fp16 round-trips per block. - _can_fuse = ( - _fvk is not None - and hasattr(proj0, "gemm_no_bias") - and hasattr(proj1, "forward_from_fp8") - and not getattr(proj1, "calibrating", False) - and getattr(proj0, "bias", None) is not None - and (proj0.out_features & 3) == 0) - if _can_fuse: - raw = proj0.gemm_no_bias(n2_3d) # quant+GEMM, no bias → fp16 [1,S,inner] - inner = raw.shape[-1] - up_fp8 = torch.empty( - S, inner, dtype=torch.float8_e4m3fn, device=raw.device) - _fvk.bias_gelu_quant_fp16_fp8( - raw.data_ptr(), proj0.bias.data_ptr(), - up_fp8.data_ptr(), proj1.act_scale.data_ptr(), - S, inner, stream_of()) - ff_out = proj1.forward_from_fp8(up_fp8).view(S, D) - else: - up = proj0(n2_3d) - inner = up.shape[-1] - _gelu_fn = kern.gelu_inplace if up.dtype == torch.bfloat16 else kern.gelu_inplace_fp16 - _gelu_fn(up.data_ptr(), S * inner, stream_of()) - ff_out = proj1(up).view(S, D) + up = proj0(n2_3d) + inner = up.shape[-1] + _gelu_fn = kern.gelu_inplace if up.dtype == torch.bfloat16 else kern.gelu_inplace_fp16 + _gelu_fn(up.data_ptr(), S * inner, stream_of()) + ff_out = proj1(up).view(S, D) gate_mul_residual_bcast(hs, ff_out, c_gate_msa.view(D)) return hs.view(1, S, D) From 603692ca0bf18c6e22b5c84daaba422dbbd40b88 Mon Sep 17 00:00:00 2001 From: chenping Date: Wed, 8 Jul 2026 11:41:35 +0800 Subject: [PATCH 17/23] feat(minimax-remover): add fused adaLN+quant (shared-scale QKV) and fused RMSNorm+RoPE kernels Two new CUDA kernels for the MiniMax-Remover transformer denoise: * fp16_ada_layernorm_quant_fp8: one-pass fp32-stat LayerNorm + adaLN modulation + per-tensor FP8 e4m3 quantise. Feeds a shared-scale FP8 tensor into Q/K/V (one quantise for three Linears, three descales) via FlashRTFp8Linear.gemm_from_fp8_ext, eliminating the three per-Linear activation-quant passes. Shared max scale also suppresses per-Linear outlier saturation (PSNR 40.05 -> 40.81 dB). * fp16_rmsnorm_rope_bshd: one-pass per-token RMSNorm (fp32 stats + fp16 affine) + interleaved RoPE on the native [B,S,H,Dd] fp16 layout. Replaces the Triton rms_norm_fp32stat + rope_apply_bshd pair and eliminates one full fp16 R/W of Q and K per attention block. End-to-end tennis clip (70 frames, 432x240, RTX 5060 Ti): 7.57 s -> 7.28 s (-4%, 2.30x -> 2.38x vs fp16 ref), PSNR mean 40.81 dB. Toggles: FLASHRT_DISABLE_ADA_QKV=1, FLASHRT_DISABLE_RMSNORM_ROPE=1. --- CMakeLists.txt | 4 +- .../fp16_ada_layernorm_quant_fp8.cu | 206 ++++++++++++++++++ .../fp16_ada_layernorm_quant_fp8.cuh | 55 +++++ .../minimax_remover/fp16_rmsnorm_rope.cu | 196 +++++++++++++++++ .../minimax_remover/fp16_rmsnorm_rope.cuh | 54 +++++ csrc/minimax_remover_extra_bindings.cpp | 46 ++++ docs/minimax_remover_usage.md | 22 +- flash_rt/models/minimax_remover/_attention.py | 82 +++++-- .../models/minimax_remover/_fp8_linear.py | 36 +++ .../models/minimax_remover/_kern_block.py | 71 ++++-- 10 files changed, 738 insertions(+), 34 deletions(-) create mode 100644 csrc/kernels/minimax_remover/fp16_ada_layernorm_quant_fp8.cu create mode 100644 csrc/kernels/minimax_remover/fp16_ada_layernorm_quant_fp8.cuh create mode 100644 csrc/kernels/minimax_remover/fp16_rmsnorm_rope.cu create mode 100644 csrc/kernels/minimax_remover/fp16_rmsnorm_rope.cuh diff --git a/CMakeLists.txt b/CMakeLists.txt index 47c6d4b4..506a8029 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -971,7 +971,9 @@ if(FLASHRT_ENABLE_MINIMAX_REMOVER) csrc/kernels/minimax_remover/fp16_quant_fp8_per_tensor.cu csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cu csrc/kernels/minimax_remover/fp16_bias_gelu_quant_fp8.cu - csrc/kernels/minimax_remover/fp16_bias_gate_residual.cu) + csrc/kernels/minimax_remover/fp16_bias_gate_residual.cu + csrc/kernels/minimax_remover/fp16_ada_layernorm_quant_fp8.cu + csrc/kernels/minimax_remover/fp16_rmsnorm_rope.cu) set_target_properties(flash_rt_minimax_remover PROPERTIES CUDA_STANDARD 17 LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_ada_layernorm_quant_fp8.cu b/csrc/kernels/minimax_remover/fp16_ada_layernorm_quant_fp8.cu new file mode 100644 index 00000000..08763319 --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_ada_layernorm_quant_fp8.cu @@ -0,0 +1,206 @@ +// ================================================================ +// flash_rt_minimax_remover — fused adaLN + FP8 quantise CUDA kernel. +// See fp16_ada_layernorm_quant_fp8.cuh for semantics. +// +// Implementation notes: +// * One CUDA block per row (S rows total). Each row's D elements +// are processed cooperatively via a fp32 reduction. +// * fp16x8 (uint4) vector loads for x; fp16x2 (__half2 loads); +// one warp shuffles + a single-warp reduction across warps via +// shared memory (no atomic in the hot path). +// * scale/shift/x_norm arithmetic done in fp32 to match the +// reference FP32LayerNorm path bit-for-bit within fp16 tolerance. +// * Output is packed as fp8x4 uint32 stores. +// +// D must be a multiple of 8 (all MiniMax-Remover Linears satisfy this: +// D ∈ {1536}). Kernel assumes D <= 4096 * threads * 8 which covers +// every practical value; caller sizes the block accordingly. +// ================================================================ +#include "fp16_ada_layernorm_quant_fp8.cuh" + +#include +#include +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +namespace { + +constexpr int VEC = 8; // 8 fp16 per uint4 load + +__device__ __forceinline__ float warp_reduce_sum(float v) { + #pragma unroll + for (int off = 16; off > 0; off >>= 1) + v += __shfl_xor_sync(0xffffffff, v, off); + return v; +} + +// One block per row; blockDim.x = THREADS (128 = 4 warps). Each thread +// walks the row in VEC-strided chunks: thread t handles elements +// t*VEC, (t + THREADS)*VEC, ... D is a multiple of VEC. +template +__global__ void ada_layernorm_quant_fp8_kernel( + const __half* __restrict__ x_in, + const float* __restrict__ scale_vec, + const float* __restrict__ shift_vec, + const float* __restrict__ act_scale_ptr, + __nv_fp8_e4m3* __restrict__ out, + int S, int D, float eps) +{ + const int s = blockIdx.x; + const int tid = threadIdx.x; + const int lane = tid & 31; + const int warp = tid >> 5; + constexpr int NWARPS = THREADS / 32; + + const __half* xp = x_in + s * D; + __nv_fp8_e4m3* op = out + s * D; + + const int D_vec = D / VEC; // number of uint4 chunks + // Pass 1 + 2 fused: single-load streaming stats (Welford-style, but + // we prefer a simple two-pass with L2 reuse since D is small enough + // that x is fully resident after first pass). Two passes are used + // here to keep the code short and match the reference fp32 + // LayerNorm bit-for-bit. + + // ── Pass 1: sum ────────────────────────────────────────── + float local_sum = 0.f; + for (int v = tid; v < D_vec; v += THREADS) { + const uint4* p = reinterpret_cast(xp + v * VEC); + uint4 raw = *p; + __half2 h0 = *reinterpret_cast<__half2*>(&raw.x); + __half2 h1 = *reinterpret_cast<__half2*>(&raw.y); + __half2 h2 = *reinterpret_cast<__half2*>(&raw.z); + __half2 h3 = *reinterpret_cast<__half2*>(&raw.w); + float2 f0 = __half22float2(h0); + float2 f1 = __half22float2(h1); + float2 f2 = __half22float2(h2); + float2 f3 = __half22float2(h3); + local_sum += f0.x + f0.y + f1.x + f1.y + + f2.x + f2.y + f3.x + f3.y; + } + // Warp reduce, then cross-warp via smem. + __shared__ float smem_sum[NWARPS]; + float wsum = warp_reduce_sum(local_sum); + if (lane == 0) smem_sum[warp] = wsum; + __syncthreads(); + float mean; + if (warp == 0) { + float v = (lane < NWARPS) ? smem_sum[lane] : 0.f; + v = warp_reduce_sum(v); + if (lane == 0) smem_sum[0] = v / (float)D; + } + __syncthreads(); + mean = smem_sum[0]; + + // ── Pass 2: sum of squared deviations ───────────────────── + float local_sq = 0.f; + for (int v = tid; v < D_vec; v += THREADS) { + const uint4* p = reinterpret_cast(xp + v * VEC); + uint4 raw = *p; + __half2 h0 = *reinterpret_cast<__half2*>(&raw.x); + __half2 h1 = *reinterpret_cast<__half2*>(&raw.y); + __half2 h2 = *reinterpret_cast<__half2*>(&raw.z); + __half2 h3 = *reinterpret_cast<__half2*>(&raw.w); + float2 f0 = __half22float2(h0); + float2 f1 = __half22float2(h1); + float2 f2 = __half22float2(h2); + float2 f3 = __half22float2(h3); + float d0 = f0.x - mean, d1 = f0.y - mean; + float d2 = f1.x - mean, d3 = f1.y - mean; + float d4 = f2.x - mean, d5 = f2.y - mean; + float d6 = f3.x - mean, d7 = f3.y - mean; + local_sq += d0*d0 + d1*d1 + d2*d2 + d3*d3 + + d4*d4 + d5*d5 + d6*d6 + d7*d7; + } + __shared__ float smem_sq[NWARPS]; + float wsq = warp_reduce_sum(local_sq); + if (lane == 0) smem_sq[warp] = wsq; + __syncthreads(); + float rstd; + if (warp == 0) { + float v = (lane < NWARPS) ? smem_sq[lane] : 0.f; + v = warp_reduce_sum(v); + if (lane == 0) smem_sq[0] = rsqrtf(v / (float)D + eps); + } + __syncthreads(); + rstd = smem_sq[0]; + + // ── Pass 3: normalise + adaLN modulate + fp8 quantise ──── + const float act_scale = *act_scale_ptr; + const float inv_a = 1.0f / fmaxf(act_scale, 1e-12f); + for (int v = tid; v < D_vec; v += THREADS) { + const int d0 = v * VEC; + const uint4* px = reinterpret_cast(xp + d0); + uint4 raw = *px; + __half2 h0 = *reinterpret_cast<__half2*>(&raw.x); + __half2 h1 = *reinterpret_cast<__half2*>(&raw.y); + __half2 h2 = *reinterpret_cast<__half2*>(&raw.z); + __half2 h3 = *reinterpret_cast<__half2*>(&raw.w); + float xv[VEC]; + { + float2 f0 = __half22float2(h0); + float2 f1 = __half22float2(h1); + float2 f2 = __half22float2(h2); + float2 f3 = __half22float2(h3); + xv[0]=f0.x; xv[1]=f0.y; xv[2]=f1.x; xv[3]=f1.y; + xv[4]=f2.x; xv[5]=f2.y; xv[6]=f3.x; xv[7]=f3.y; + } + // scale/shift are fp32, read sequentially. + float sv[VEC], bv[VEC]; + // 8 fp32 loads via 2×float4: + const float4* pS = reinterpret_cast(scale_vec + d0); + const float4* pB = reinterpret_cast(shift_vec + d0); + float4 s0 = pS[0], s1 = pS[1]; + float4 b0 = pB[0], b1 = pB[1]; + sv[0]=s0.x; sv[1]=s0.y; sv[2]=s0.z; sv[3]=s0.w; + sv[4]=s1.x; sv[5]=s1.y; sv[6]=s1.z; sv[7]=s1.w; + bv[0]=b0.x; bv[1]=b0.y; bv[2]=b0.z; bv[3]=b0.w; + bv[4]=b1.x; bv[5]=b1.y; bv[6]=b1.z; bv[7]=b1.w; + + __nv_fp8_e4m3 fp8_pack[VEC]; + #pragma unroll + for (int i = 0; i < VEC; i++) { + float xn = (xv[i] - mean) * rstd; + float y = xn * (1.0f + sv[i]) + bv[i]; + float yq = y * inv_a; + yq = fminf(fmaxf(yq, -448.0f), 448.0f); + fp8_pack[i] = __nv_fp8_e4m3(yq); + } + // Pack 8 fp8 into 2×uint32 stores. + uint32_t* out_u32 = reinterpret_cast(op + d0); + out_u32[0] = *reinterpret_cast(&fp8_pack[0]); + out_u32[1] = *reinterpret_cast(&fp8_pack[4]); + } +} + +} // anonymous namespace + +int fp16_ada_layernorm_quant_fp8( + const void* x_fp16, + const void* scale_fp32, + const void* shift_fp32, + const void* act_scale_fp32, + void* out_fp8, + int S, int D, float eps, + cudaStream_t stream) +{ + if (!x_fp16 || !scale_fp32 || !shift_fp32 || !act_scale_fp32 || !out_fp8) + return -1; + if (S <= 0 || D <= 0 || (D % VEC) != 0) return -2; + constexpr int THREADS = 128; + ada_layernorm_quant_fp8_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast(scale_fp32), + reinterpret_cast(shift_fp32), + reinterpret_cast(act_scale_fp32), + reinterpret_cast<__nv_fp8_e4m3*>(out_fp8), + S, D, eps); + return 0; +} + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_ada_layernorm_quant_fp8.cuh b/csrc/kernels/minimax_remover/fp16_ada_layernorm_quant_fp8.cuh new file mode 100644 index 00000000..e609de3c --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_ada_layernorm_quant_fp8.cuh @@ -0,0 +1,55 @@ +// ================================================================ +// flash_rt_minimax_remover — fused adaLN + FP8 quantise kernel. +// +// Single-kernel fusion of the FP8 attention/FFN entry path: +// (1) FP32-statistics LayerNorm across D +// mean = mean(x[s,:]) // fp32 reduce +// var = mean((x - mean)^2) // fp32 reduce +// rstd = 1 / sqrt(var + eps) +// (2) adaLN modulation (fp32 scale/shift from temb.float()): +// y = (x - mean) * rstd * (1 + scale[d]) + shift[d] +// (3) Per-tensor FP8 e4m3 quantise (static act_scale from the FP8 +// Linear that will consume this output): +// y_fp8 = clip(y / act_scale, ±448) cast to fp8_e4m3fn +// +// Replaces the 3-kernel path in the FP8 transformer block entry: +// ada_layernorm_fp16_io → quantize_fp8_static_fp16 → fp8_gemm +// Eliminates one full [S,D] fp16 read-modify-write on the LayerNorm +// output. The output tensor is the pre-quantised input of the next +// FP8 Linear, so its .forward_from_fp8() (or gemm_from_fp8_ext for +// Q/K/V shared-scale) can skip its own activation quantise entirely. +// +// Layout: +// x : [S, D] fp16, contiguous row-major +// scale : [D] fp32 (from temb.float().chunk(6)) +// shift : [D] fp32 +// act_scale: [1] fp32 device scalar (target Linear's descale factor) +// out : [S, D] fp8_e4m3fn contiguous row-major +// +// Grid: one CUDA block per row. Each block reduces over D in fp32. +// ================================================================ +#ifndef FLASHRT_KERNELS_MINIMAX_REMOVER_FP16_ADA_LN_QUANT_FP8_CUH +#define FLASHRT_KERNELS_MINIMAX_REMOVER_FP16_ADA_LN_QUANT_FP8_CUH + +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +// Fused fp32-stat LayerNorm + adaLN modulation + per-tensor fp8 quantise. +// Returns 0 on success, negative on invalid args. +int fp16_ada_layernorm_quant_fp8( + const void* x_fp16, + const void* scale_fp32, + const void* shift_fp32, + const void* act_scale_fp32, + void* out_fp8, + int S, int D, float eps, + cudaStream_t stream); + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt + +#endif // FLASHRT_KERNELS_MINIMAX_REMOVER_FP16_ADA_LN_QUANT_FP8_CUH diff --git a/csrc/kernels/minimax_remover/fp16_rmsnorm_rope.cu b/csrc/kernels/minimax_remover/fp16_rmsnorm_rope.cu new file mode 100644 index 00000000..f74cefe7 --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_rmsnorm_rope.cu @@ -0,0 +1,196 @@ +// ================================================================ +// flash_rt_minimax_remover — fused RMSNorm + interleaved RoPE. +// See fp16_rmsnorm_rope.cuh for semantics. +// +// Implementation: +// * One CUDA block per token (B*S tokens total). Each block +// reduces D in fp32 across THREADS threads, then applies affine +// weight + interleaved RoPE. +// * fp16x8 (uint4) vector loads/stores for x. +// * cos/sin tables indexed by (seq_idx, pair_idx) — same table +// across heads, so a per-token block re-uses the same base_cos_sin +// pointer for each head. +// ================================================================ +#include "fp16_rmsnorm_rope.cuh" + +#include +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +namespace { + +constexpr int VEC = 8; + +__device__ __forceinline__ float warp_reduce_sum(float v) { + #pragma unroll + for (int off = 16; off > 0; off >>= 1) + v += __shfl_xor_sync(0xffffffff, v, off); + return v; +} + +template +__global__ void rmsnorm_rope_kernel( + __half* __restrict__ x, + const __half* __restrict__ weight, + const float* __restrict__ cos_tab, + const float* __restrict__ sin_tab, + int B, int S, int H, int Dd, + float eps) +{ + const int tok = blockIdx.x; // 0 .. B*S - 1 + const int s = tok % S; + const int tid = threadIdx.x; + const int lane = tid & 31; + const int warp = tid >> 5; + constexpr int NWARPS = THREADS / 32; + + const int D = H * Dd; + __half* xrow = x + tok * D; + + // ── Pass 1: sum of squares over full D (across all heads) ──────── + const int D_vec = D / VEC; + float local_sq = 0.f; + for (int v = tid; v < D_vec; v += THREADS) { + const uint4* p = reinterpret_cast(xrow + v * VEC); + uint4 raw = *p; + __half2 h0 = *reinterpret_cast<__half2*>(&raw.x); + __half2 h1 = *reinterpret_cast<__half2*>(&raw.y); + __half2 h2 = *reinterpret_cast<__half2*>(&raw.z); + __half2 h3 = *reinterpret_cast<__half2*>(&raw.w); + float2 f0 = __half22float2(h0); + float2 f1 = __half22float2(h1); + float2 f2 = __half22float2(h2); + float2 f3 = __half22float2(h3); + local_sq += f0.x*f0.x + f0.y*f0.y + f1.x*f1.x + f1.y*f1.y + + f2.x*f2.x + f2.y*f2.y + f3.x*f3.x + f3.y*f3.y; + } + __shared__ float smem[NWARPS]; + float ws = warp_reduce_sum(local_sq); + if (lane == 0) smem[warp] = ws; + __syncthreads(); + float rstd; + if (warp == 0) { + float v = (lane < NWARPS) ? smem[lane] : 0.f; + v = warp_reduce_sum(v); + if (lane == 0) smem[0] = rsqrtf(v / (float)D + eps); + } + __syncthreads(); + rstd = smem[0]; + + // ── Pass 2: (x*rstd*w) then RoPE within each head ──────────────── + // + // Layout: xrow[h*Dd + i], for h in [0,H), i in [0,Dd). + // Weight[h*Dd + i] provides the affine multiplier. + // RoPE pairs (i, i+1) share cos[s, i/2], sin[s, i/2] across h. + // + // Process VEC=8 elements per thread, which spans (VEC/2)=4 RoPE + // pairs. Guarantee Dd is a multiple of VEC so within-head pair + // alignment holds. Since D = H*Dd is a multiple of VEC (H*Dd + // multiple of 8 required by caller — H%1 free, Dd%8 required), + // vector index v corresponds to head h = (v*VEC) / Dd and offset + // off_in_head = (v*VEC) % Dd. + const float* cos_row = cos_tab + s * (Dd / 2); + const float* sin_row = sin_tab + s * (Dd / 2); + for (int v = tid; v < D_vec; v += THREADS) { + const int base_d = v * VEC; + const int off = base_d % Dd; // 0 .. Dd-1 within head + // Load x, weight + const uint4* px = reinterpret_cast(xrow + base_d); + uint4 raw = *px; + __half2 h0 = *reinterpret_cast<__half2*>(&raw.x); + __half2 h1 = *reinterpret_cast<__half2*>(&raw.y); + __half2 h2 = *reinterpret_cast<__half2*>(&raw.z); + __half2 h3 = *reinterpret_cast<__half2*>(&raw.w); + + const uint4* pw = reinterpret_cast(weight + base_d); + uint4 wraw = *pw; + __half2 w0 = *reinterpret_cast<__half2*>(&wraw.x); + __half2 w1 = *reinterpret_cast<__half2*>(&wraw.y); + __half2 w2 = *reinterpret_cast<__half2*>(&wraw.z); + __half2 w3 = *reinterpret_cast<__half2*>(&wraw.w); + + // Normalise + affine (fp32) + float2 f0 = __half22float2(h0); + float2 f1 = __half22float2(h1); + float2 f2 = __half22float2(h2); + float2 f3 = __half22float2(h3); + float2 wf0 = __half22float2(w0); + float2 wf1 = __half22float2(w1); + float2 wf2 = __half22float2(w2); + float2 wf3 = __half22float2(w3); + float xv[VEC]; + xv[0] = f0.x * rstd * wf0.x; + xv[1] = f0.y * rstd * wf0.y; + xv[2] = f1.x * rstd * wf1.x; + xv[3] = f1.y * rstd * wf1.y; + xv[4] = f2.x * rstd * wf2.x; + xv[5] = f2.y * rstd * wf2.y; + xv[6] = f3.x * rstd * wf3.x; + xv[7] = f3.y * rstd * wf3.y; + + // Load cos/sin for the 4 pairs (off, off+2, off+4, off+6). + // These are within the same head (guaranteed since Dd % VEC == 0). + const int pair0 = off / 2; + float cs[4], sn[4]; + // 4 fp32 pairs; use float4 loads. + const float4* pc = reinterpret_cast(cos_row + pair0); + const float4* ps = reinterpret_cast(sin_row + pair0); + float4 c4 = *pc, s4 = *ps; + cs[0] = c4.x; cs[1] = c4.y; cs[2] = c4.z; cs[3] = c4.w; + sn[0] = s4.x; sn[1] = s4.y; sn[2] = s4.z; sn[3] = s4.w; + + // Apply RoPE (interleaved): (x0, x1) -> (x0*c - x1*s, x0*s + x1*c) + #pragma unroll + for (int j = 0; j < 4; j++) { + float x0 = xv[2*j], x1 = xv[2*j + 1]; + float c = cs[j], sn_j = sn[j]; + xv[2*j ] = x0 * c - x1 * sn_j; + xv[2*j + 1] = x0 * sn_j + x1 * c; + } + + // Pack back to fp16 and store. + __half2 o0 = __floats2half2_rn(xv[0], xv[1]); + __half2 o1 = __floats2half2_rn(xv[2], xv[3]); + __half2 o2 = __floats2half2_rn(xv[4], xv[5]); + __half2 o3 = __floats2half2_rn(xv[6], xv[7]); + uint4 out; + out.x = *reinterpret_cast(&o0); + out.y = *reinterpret_cast(&o1); + out.z = *reinterpret_cast(&o2); + out.w = *reinterpret_cast(&o3); + *reinterpret_cast(xrow + base_d) = out; + } +} + +} // anonymous namespace + +int fp16_rmsnorm_rope_bshd( + void* x_fp16, + const void* weight_fp16, + const void* cos_fp32, + const void* sin_fp32, + int B, int S, int H, int Dd, + float eps, + cudaStream_t stream) +{ + if (!x_fp16 || !weight_fp16 || !cos_fp32 || !sin_fp32) return -1; + if (B <= 0 || S <= 0 || H <= 0 || Dd <= 0) return -2; + const int D = H * Dd; + if ((D % VEC) != 0 || (Dd % VEC) != 0) return -3; + constexpr int THREADS = 128; + const int tokens = B * S; + rmsnorm_rope_kernel<<>>( + reinterpret_cast<__half*>(x_fp16), + reinterpret_cast(weight_fp16), + reinterpret_cast(cos_fp32), + reinterpret_cast(sin_fp32), + B, S, H, Dd, eps); + return 0; +} + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_rmsnorm_rope.cuh b/csrc/kernels/minimax_remover/fp16_rmsnorm_rope.cuh new file mode 100644 index 00000000..beba6a47 --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_rmsnorm_rope.cuh @@ -0,0 +1,54 @@ +// ================================================================ +// flash_rt_minimax_remover — fused RMSNorm + interleaved RoPE kernel. +// +// The MiniMax-Remover attention entry does three separate passes over +// each Q/K tensor between the QKV Linear and the attention kernel: +// +// 1. rms_norm_fp32stat(q, norm_q.weight, eps) # fp32-stat RMSNorm +// - reads q, computes per-token RMS across D, writes normalised +// 2. .view(B, S, H, Dd) # zero-cost reshape +// 3. rope_apply_bshd(q, cos, sin) # in-place interleaved RoPE +// +// This kernel fuses (1) and (3) into a single pass: +// * Per-token fp32 RMS reduction over D (D = H * Dd). +// * Per-element affine (weight[d] broadcast across [B*S] rows). +// * Interleaved RoPE on adjacent (2k, 2k+1) fp16 pairs, with +// per-head cos/sin tables shared across heads (cos/sin depend +// only on the sequence index and the pair index within Dd). +// +// Layout assumptions (matches _kernels.py): +// x : [B*S*H, Dd] fp16 contiguous (equivalent to [B,S,H,Dd]) +// weight : [D] fp16 (RMSNorm affine, D = H * Dd) +// cos, sin : [S, Dd/2] fp32 +// eps : LayerNorm epsilon +// +// The RMS is computed over the *full* D per token (i.e. jointly over +// all heads), matching qk_norm="rms_norm_across_heads". Grid: one +// block per token (B*S), each block reduces D in fp32 then applies +// RoPE across the H heads sequentially. +// ================================================================ +#ifndef FLASHRT_KERNELS_MINIMAX_REMOVER_FP16_RMSNORM_ROPE_CUH +#define FLASHRT_KERNELS_MINIMAX_REMOVER_FP16_RMSNORM_ROPE_CUH + +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +// Fused RMSNorm(fp32 stats + fp16 affine) + interleaved RoPE. +// x is treated as [B*S, H, Dd] fp16 contiguous. Returns 0 on success. +int fp16_rmsnorm_rope_bshd( + void* x_fp16, // in/out [B*S, D] D = H*Dd + const void* weight_fp16, // [D] + const void* cos_fp32, // [S, Dd/2] + const void* sin_fp32, // [S, Dd/2] + int B, int S, int H, int Dd, + float eps, + cudaStream_t stream); + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt + +#endif // FLASHRT_KERNELS_MINIMAX_REMOVER_FP16_RMSNORM_ROPE_CUH diff --git a/csrc/minimax_remover_extra_bindings.cpp b/csrc/minimax_remover_extra_bindings.cpp index 92ce8a3b..695a5267 100644 --- a/csrc/minimax_remover_extra_bindings.cpp +++ b/csrc/minimax_remover_extra_bindings.cpp @@ -21,6 +21,8 @@ #include "kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cuh" #include "kernels/minimax_remover/fp16_bias_gelu_quant_fp8.cuh" #include "kernels/minimax_remover/fp16_bias_gate_residual.cuh" +#include "kernels/minimax_remover/fp16_ada_layernorm_quant_fp8.cuh" +#include "kernels/minimax_remover/fp16_rmsnorm_rope.cuh" namespace py = pybind11; @@ -315,4 +317,48 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { "Replaces the scalar decoder_fused add_bias_fp16 kernel for the " "Q/K/V projections; ~8× fewer memory transactions. D must be " "a multiple of 8."); + + m.def("fp16_ada_layernorm_quant_fp8", + [](uintptr_t x_fp16, uintptr_t scale_fp32, uintptr_t shift_fp32, + uintptr_t act_scale_fp32, uintptr_t out_fp8, + int S, int D, float eps, uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + fp16_ada_layernorm_quant_fp8( + to_ptr(x_fp16), to_ptr(scale_fp32), to_ptr(shift_fp32), + to_ptr(act_scale_fp32), to_ptr(out_fp8), + S, D, eps, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("scale_fp32"), py::arg("shift_fp32"), + py::arg("act_scale_fp32"), py::arg("out_fp8"), + py::arg("S"), py::arg("D"), + py::arg("eps") = 1e-6f, py::arg("stream") = 0, + "Fused fp32-stat LayerNorm + adaLN modulation + per-tensor fp8 " + "e4m3 quantise. Reads x[S,D] fp16, applies " + "y = (x-mean)/std * (1+scale[d]) + shift[d], then divides by the " + "target Linear's static act_scale and casts to fp8_e4m3fn. " + "Replaces the 3-kernel ada_layernorm_fp16_io + quantize_fp8 + " + "gemm-descale sequence with a single pass — eliminates the " + "intermediate fp16 read of the LayerNorm output. D must be a " + "multiple of 8."); + + m.def("fp16_rmsnorm_rope_bshd", + [](uintptr_t x_fp16, uintptr_t weight_fp16, + uintptr_t cos_fp32, uintptr_t sin_fp32, + int B, int S, int H, int Dd, + float eps, uintptr_t stream) { + return flash_rt::kernels::minimax_remover::fp16_rmsnorm_rope_bshd( + to_ptr(x_fp16), to_ptr(weight_fp16), + to_ptr(cos_fp32), to_ptr(sin_fp32), + B, S, H, Dd, eps, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("weight_fp16"), + py::arg("cos_fp32"), py::arg("sin_fp32"), + py::arg("B"), py::arg("S"), py::arg("H"), py::arg("Dd"), + py::arg("eps") = 1e-6f, py::arg("stream") = 0, + "Fused per-token RMSNorm (fp32 stats, fp16 affine) + interleaved " + "RoPE on the native [B,S,H,Dd] fp16 layout. RMS reduction is " + "across the full D = H*Dd (matches qk_norm='rms_norm_across_heads'). " + "Replaces the 2-kernel rms_norm_fp32stat + rope_apply_bshd Triton " + "sequence with one pass — eliminates one full fp16 read+write of " + "the Q/K tensor. Dd must be a multiple of 8."); } diff --git a/docs/minimax_remover_usage.md b/docs/minimax_remover_usage.md index 2410bff2..b268071a 100644 --- a/docs/minimax_remover_usage.md +++ b/docs/minimax_remover_usage.md @@ -71,6 +71,8 @@ cmake --build build -j --target flash_rt_minimax_remover | `bias_quant_fp16_fp8` | `flash_rt_minimax_remover` | `add_bias_fp16` + `quantize_fp8_static_fp16` (identity activation variant) | fused bias + quant for Linear→Linear chains with no activation | | `fp16_bias_gate_residual_bcast` | `flash_rt_minimax_remover` | transformer O-proj / FFN-down `add_bias_fp16` + `gate_mul_residual_bcast` (2 kernels) | fused single-pass fp16x8 (uint4) kernel: `residual[m,d] += (out[m,d] + bias[d]) * gate[d]`; eliminates one full [S,D] fp16 read-modify-write per call (~720 slots / denoise) | | `fp16_add_bias_vec8` | `flash_rt_minimax_remover` | scalar `add_bias_fp16` (decoder_fused) | vectorised fp16x8 (uint4) in-place bias add; used for Q/K/V and any bias-only slot; ~8× fewer memory transactions | +| `fp16_ada_layernorm_quant_fp8` | `flash_rt_minimax_remover` | Triton `ada_layernorm_fp16_io` + `quantize_fp8_static_fp16` + 3× per-Linear activation quant (Q/K/V) | fused single-pass fp32-stat LayerNorm + adaLN modulation + per-tensor FP8 quantise; feeds a shared-scale FP8 tensor into Q/K/V (one quantise for three Linears, three descales) | +| `fp16_rmsnorm_rope_bshd` | `flash_rt_minimax_remover` | Triton `rms_norm_fp32stat` + `rope_apply_bshd` (2 kernels, 2 full R/W of Q or K) | fused per-token RMSNorm (fp32 stats + fp16 affine) + interleaved RoPE on native [B,S,H,Dd] fp16 layout; one full R/W per tensor; skips the intermediate fp16 write of the normalised Q/K | | channels-last pipeline | Python (`_vae_opt.py`) | Conv3d weight → CL, WanCausalConv3d → CL-preserving forward | eliminates ~97% of nchw↔nhwc conversion kernels (~280 ms / decode) | | Running-max amax | Python (`_vae_opt.py`) | separate `amax_fp16` calls over cache + new each iteration | norm fuses amax via atomicMax into a persistent buffer shared with the sister conv; cache amax is skipped entirely (covered by running max) | | `WanUpsample` patch | Python (no kernel) | `x.float().type_as(x)` for nearest-exact upsample | eliminates redundant fp32 cast (index-only op, fp16 == fp32) | @@ -338,7 +340,8 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL (`--no-fp8-conv`) | 10.01 s | 1.73x | 40.8 / 37.0 dB | 0.99981 | | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d | 8.58 s | 2.02x | 39.9 / 36.4 dB | 0.99981 | | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue | 7.56 s | 2.29x | 40.0 / 36.4 dB | 0.99981 | -| tennis (70 frames, 432x240) | **FlashRT FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue + fused bias-gate residual (default)** | **7.57 s** | **2.30x** | **40.0 / 36.2 dB** | 0.99981 | +| tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue + fused bias-gate residual | 7.57 s | 2.30x | 40.0 / 36.2 dB | 0.99981 | +| tennis (70 frames, 432x240) | **… + fused adaLN+quant (shared-scale QKV) + fused RMSNorm+RoPE (default)** | **7.28 s** | **2.38x** | **40.8 / 36.3 dB** | 0.99981 | | tennis (70 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 9.52 s | 1.82x | 7.0 / 6.2 dB | 0.00000 (broken) | | bmx-trees (80 frames, 432x240) | fp16 reference (`--no-flashrt`) | 19.76 s | 1.0x | — | — | | bmx-trees (80 frames, 432x240) | FlashRT FP8 (default) | 13.24 s | **1.49x** | 35.1 / 32.0 dB | 0.99912 | @@ -350,16 +353,25 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the Takeaways: -- **FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue + fused bias-gate residual is the recommended default**: 2.30x - faster than the fp16 reference with PSNR 40.0 dB (median) on full-frame +- **FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue + fused bias-gate residual + fused adaLN-quant (shared-scale QKV) + fused RMSNorm+RoPE is the recommended default**: 2.38x + faster than the fp16 reference with PSNR 40.8 dB (median) on full-frame tennis clip, peak VRAM 2.51 GB. The fused FFN epilogue kernel (`bias_gelu_quant_fp16_fp8`) collapses bias-add + GELU + activation quantise into one pass, cutting denoise GPU time by 15% (3.73 → 3.16 s) and end-to-end from 8.58 → 7.56 s. The fused bias + gate + residual kernel (`fp16_bias_gate_residual_bcast`) further collapses the O-proj and FFN-down block tails (720 slots / denoise) into single-pass - fp16x8 kernels, trimming denoise GPU kernel time another –70 ms; - wall time is now launch-bound and unchanged (7.56 → 7.57 s). + fp16x8 kernels, trimming denoise GPU kernel time another –70 ms + (wall 7.56 → 7.57 s, launch-bound). The fused adaLN+quant kernel + (`fp16_ada_layernorm_quant_fp8`) collapses the per-block LayerNorm + + adaLN modulation + 3× per-Linear activation quant into a single pass + and feeds a **shared-scale FP8 tensor** into Q/K/V (one quantise, + three descales), boosting PSNR to 40.8 dB because the shared max + scale suppresses per-Linear outlier saturation. The fused + `fp16_rmsnorm_rope_bshd` kernel then collapses the Q/K RMSNorm + + interleaved RoPE into a single per-token pass, eliminating one full + fp16 R/W of each Q/K tensor per attention block. Combined wall time + drops 7.57 → 7.28 s (–4%). - **FP8 conv3d vs channels-last-only cuDNN**: the hand-rolled implicit-GEMM kernel (no im2col materialization, virtual cache concat, per-channel weight dequant, fused amax, running-max scale) beats cuDNN's fp16 conv3d diff --git a/flash_rt/models/minimax_remover/_attention.py b/flash_rt/models/minimax_remover/_attention.py index 51120407..7b75560c 100644 --- a/flash_rt/models/minimax_remover/_attention.py +++ b/flash_rt/models/minimax_remover/_attention.py @@ -34,6 +34,19 @@ from ._kernels import rms_norm_fp32stat, rope_apply_bshd, freqs_to_cos_sin +# Optional MiniMax-Remover fused kernels — used when both are available. +_fvk = None +try: + from flash_rt import flash_rt_minimax_remover as _fvk +except ImportError: + try: + import flash_rt_minimax_remover as _fvk # type: ignore + except ImportError: + pass +_has_fused_rmsnorm_rope = ( + _fvk is not None and hasattr(_fvk, "fp16_rmsnorm_rope_bshd") + and os.environ.get("FLASHRT_DISABLE_RMSNORM_ROPE", "0") != "1") + try: _NUM_SMS = torch.cuda.get_device_properties(0).multi_processor_count except Exception: @@ -148,32 +161,71 @@ def __init__(self): def __call__(self, attn, hidden_states, rotary_emb=None, attention_mask=None, encoder_hidden_states=None, - no_out_bias=False): + no_out_bias=False, fp8_hidden=None, fp8_scale=None): mode = _attention_mode() B, S, _ = hidden_states.shape H = attn.heads Dd = attn.inner_dim // H scale = 1.0 / math.sqrt(float(Dd)) - q = attn.to_q(hidden_states) - k = attn.to_k(hidden_states) - v = attn.to_v(hidden_states) - if attn.norm_q is not None: - q = rms_norm_fp32stat(q, attn.norm_q.weight, attn.norm_q.eps) - if attn.norm_k is not None: - k = rms_norm_fp32stat(k, attn.norm_k.weight, attn.norm_k.eps) - - q = q.view(B, S, H, Dd) - k = k.view(B, S, H, Dd) - v = v.view(B, S, H, Dd) - + # ── Fused QKV entry from pre-quantised fp8 input ── + # When the caller provides the fp8 output of the fused adaLN+quant + # kernel plus the shared scale used to build it, drive Q/K/V from + # that fp8 tensor directly — saves 3 activation-quantise passes + # (one per Linear) and 1 fp16 read of norm1_out. Both Linears + # must expose gemm_from_fp8_ext (FlashRTFp8Linear post-calibration). + _use_fp8 = (fp8_hidden is not None and fp8_scale is not None + and hasattr(attn.to_q, "gemm_from_fp8_ext") + and hasattr(attn.to_k, "gemm_from_fp8_ext") + and hasattr(attn.to_v, "gemm_from_fp8_ext")) + if _use_fp8: + q = attn.to_q.gemm_from_fp8_ext(fp8_hidden, fp8_scale) + k = attn.to_k.gemm_from_fp8_ext(fp8_hidden, fp8_scale) + v = attn.to_v.gemm_from_fp8_ext(fp8_hidden, fp8_scale) + else: + q = attn.to_q(hidden_states) + k = attn.to_k(hidden_states) + v = attn.to_v(hidden_states) + cs = None if rotary_emb is not None: cs = self._cos_sin.get(S) if cs is None: cs = freqs_to_cos_sin(rotary_emb) self._cos_sin[S] = cs - rope_apply_bshd(q, cs[0], cs[1]) - rope_apply_bshd(k, cs[0], cs[1]) + + _fuse_qk = (_has_fused_rmsnorm_rope and cs is not None + and attn.norm_q is not None and attn.norm_k is not None + and (Dd & 7) == 0) + if _fuse_qk: + if not q.is_contiguous(): + q = q.contiguous() + if not k.is_contiguous(): + k = k.contiguous() + stream = torch.cuda.current_stream().cuda_stream + _fvk.fp16_rmsnorm_rope_bshd( + q.data_ptr(), attn.norm_q.weight.data_ptr(), + cs[0].data_ptr(), cs[1].data_ptr(), + B, S, H, Dd, float(attn.norm_q.eps), stream) + _fvk.fp16_rmsnorm_rope_bshd( + k.data_ptr(), attn.norm_k.weight.data_ptr(), + cs[0].data_ptr(), cs[1].data_ptr(), + B, S, H, Dd, float(attn.norm_k.eps), stream) + q = q.view(B, S, H, Dd) + k = k.view(B, S, H, Dd) + v = v.view(B, S, H, Dd) + else: + if attn.norm_q is not None: + q = rms_norm_fp32stat(q, attn.norm_q.weight, attn.norm_q.eps) + if attn.norm_k is not None: + k = rms_norm_fp32stat(k, attn.norm_k.weight, attn.norm_k.eps) + + q = q.view(B, S, H, Dd) + k = k.view(B, S, H, Dd) + v = v.view(B, S, H, Dd) + + if cs is not None: + rope_apply_bshd(q, cs[0], cs[1]) + rope_apply_bshd(k, cs[0], cs[1]) if not q.is_contiguous(): q = q.contiguous() diff --git a/flash_rt/models/minimax_remover/_fp8_linear.py b/flash_rt/models/minimax_remover/_fp8_linear.py index 3284082a..5836c4ae 100644 --- a/flash_rt/models/minimax_remover/_fp8_linear.py +++ b/flash_rt/models/minimax_remover/_fp8_linear.py @@ -281,6 +281,42 @@ def forward_from_fp8(self, x_fp8: torch.Tensor) -> torch.Tensor: m, n, stream) return out.view(*orig_shape[:-1], n) + def gemm_from_fp8_ext(self, x_fp8: torch.Tensor, + ext_act_scale: torch.Tensor) -> torch.Tensor: + """FP8 GEMM from a pre-quantised fp8 input using an externally + supplied activation scale (fp32 [1]). + + Used by the fused ``ada_layernorm_quant_fp8_shared`` path, where + several sibling Linears (Q/K/V) share a single fp8-quantised + input built with ``shared_scale = max(scale_q, scale_k, scale_v)``. + The FP8 GEMM descales with the actual scale used at quantise time, + which is arithmetically correct regardless of self.act_scale. + + Includes bias. + """ + if self.calibrating: + raise RuntimeError("gemm_from_fp8_ext is only valid post-calibration") + orig_shape = x_fp8.shape + x2d = x_fp8.reshape(-1, self.in_features) + if x2d.stride(0) != self.in_features or x2d.stride(1) != 1: + x2d = x2d.contiguous() + m = x2d.shape[0] + k, n = self.in_features, self.out_features + stream = torch.cuda.current_stream().cuda_stream + out = torch.empty(m, n, dtype=torch.float16, device=x2d.device) + kern.fp8_gemm_descale_fp16( + x2d.data_ptr(), self.weight_fp8.data_ptr(), out.data_ptr(), + m, n, k, ext_act_scale.data_ptr(), + self.weight_scale.data_ptr(), stream) + if self.bias is not None: + if _has_add_bias_vec8 and (n & 7) == 0: + _fvk_extra.fp16_add_bias_vec8( + out.data_ptr(), self.bias.data_ptr(), m, n, stream) + else: + kern.add_bias_fp16(out.data_ptr(), self.bias.data_ptr(), + m, n, stream) + return out.view(*orig_shape[:-1], n) + def _is_fp8_target(module: nn.Module) -> bool: """Determine whether a Linear is suitable for FP8 replacement. diff --git a/flash_rt/models/minimax_remover/_kern_block.py b/flash_rt/models/minimax_remover/_kern_block.py index b1cbd4b3..7a640540 100644 --- a/flash_rt/models/minimax_remover/_kern_block.py +++ b/flash_rt/models/minimax_remover/_kern_block.py @@ -107,6 +107,13 @@ def _ada_norm_flashrt_fp16(self_hs, scale_v, shift_v, S, D): and hasattr(_fvk, "fp16_bias_gate_residual_bcast") and os.environ.get("FLASHRT_DISABLE_BIAS_GATE", "0") != "1") + # Fused adaLN + fp8 quantise kernel — feeds Q/K/V from a shared fp8 + # tensor built with max(act_scale_q, act_scale_k, act_scale_v). Saves + # three activation-quantise passes and one full fp16 read of norm1. + _fuse_ada_qkv = (_fvk is not None + and hasattr(_fvk, "fp16_ada_layernorm_quant_fp8") + and os.environ.get("FLASHRT_DISABLE_ADA_QKV", "0") != "1") + def _gate_residual_apply(hs, x_no_bias, bias, gate, S, D): """residual += (x + bias) * gate[D] — one fused kernel if available, else falls back to add_bias_fp16 + gate_mul_residual_bcast.""" @@ -122,6 +129,21 @@ def _gate_residual_apply(hs, x_no_bias, bias, gate, S, D): S, D, stream_of()) gate_mul_residual_bcast(hs, x_no_bias, gate.view(D)) + def _get_qkv_shared_scale(block): + """Cache & return the fp32[1] max(to_q, to_k, to_v).act_scale for + the attn1 sub-module of `block`. Persists across denoise steps + (scales are frozen after calibration).""" + cached = getattr(block, "_flashrt_qkv_shared_scale", None) + if cached is not None: + return cached + attn = block.attn1 + s = torch.stack([attn.to_q.act_scale.view(1), + attn.to_k.act_scale.view(1), + attn.to_v.act_scale.view(1)]).max().view(1) + s = s.to(torch.float32).contiguous() + block._flashrt_qkv_shared_scale = s + return s + def block_forward(self, hidden_states, temb, rotary_emb): B, S, D = hidden_states.shape # Ensure contiguity at entry (first block truly copies; subsequent blocks are no-ops) @@ -130,27 +152,50 @@ def block_forward(self, hidden_states, temb, rotary_emb): (shift_msa, scale_msa, gate_msa, c_shift_msa, c_scale_msa, c_gate_msa) = (self.scale_shift_table + temb.float()).chunk(6, dim=1) - if norm_mode == "fp16": - norm1_out = _ada_norm_flashrt_fp16(hs, scale_msa, shift_msa, S, D) - else: - norm1_out = _ada_norm(hs, scale_msa, shift_msa, S, D) - # Fused bias+gate+residual on O-proj: ask attention to skip the - # to_out[0] bias, then apply it fused with the gate/residual step. - to_out0 = self.attn1.to_out[0] + # ── norm1 → attn ──────────────────────────────────────── + attn = self.attn1 + to_out0 = attn.to_out[0] + _fuse_qkv = (_fuse_ada_qkv + and norm_mode != "fp16" + and hasattr(attn.to_q, "gemm_from_fp8_ext") + and hasattr(attn.to_k, "gemm_from_fp8_ext") + and hasattr(attn.to_v, "gemm_from_fp8_ext") + and not getattr(attn.to_q, "calibrating", False) + and (D & 7) == 0) _fuse_attn = (_has_bgr and hasattr(to_out0, "gemm_no_bias") and getattr(to_out0, "bias", None) is not None and not getattr(to_out0, "calibrating", False) and (D & 7) == 0) + + if _fuse_qkv: + shared_scale = _get_qkv_shared_scale(self) + # Ensure fp32 contiguous scale/shift vectors for the kernel. + scale_f = scale_msa.contiguous().to(torch.float32).view(D) + shift_f = shift_msa.contiguous().to(torch.float32).view(D) + norm1_fp8 = torch.empty(S, D, dtype=torch.float8_e4m3fn, + device=hs.device) + _fvk.fp16_ada_layernorm_quant_fp8( + hs.data_ptr(), scale_f.data_ptr(), shift_f.data_ptr(), + shared_scale.data_ptr(), norm1_fp8.data_ptr(), + S, D, eps, stream_of()) + attn_out = attn(hidden_states=hs.view(1, S, D), + rotary_emb=rotary_emb, + no_out_bias=_fuse_attn, + fp8_hidden=norm1_fp8, + fp8_scale=shared_scale).view(S, D) + else: + if norm_mode == "fp16": + norm1_out = _ada_norm_flashrt_fp16(hs, scale_msa, shift_msa, S, D) + else: + norm1_out = _ada_norm(hs, scale_msa, shift_msa, S, D) + attn_out = attn(hidden_states=norm1_out.view(1, S, D), + rotary_emb=rotary_emb, + no_out_bias=_fuse_attn).view(S, D) + if _fuse_attn: - attn_out = self.attn1( - hidden_states=norm1_out.view(1, S, D), - rotary_emb=rotary_emb, no_out_bias=True).view(S, D) _gate_residual_apply(hs, attn_out, to_out0.bias, gate_msa, S, D) else: - attn_out = self.attn1( - hidden_states=norm1_out.view(1, S, D), - rotary_emb=rotary_emb).view(S, D) gate_mul_residual_bcast(hs, attn_out, gate_msa.view(D)) if norm_mode == "fp16": From adf58d75718cc4870008bda189a69a75d99671f5 Mon Sep 17 00:00:00 2001 From: chenping Date: Wed, 8 Jul 2026 14:00:51 +0800 Subject: [PATCH 18/23] feat(minimax-remover): fuse norm2+quant for FFN proj0, add VAE nozero quant variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transformer denoise: * norm2 now uses fp16_ada_layernorm_quant_fp8 to produce fp8 directly for FFN proj0, eliminating the intermediate fp16 [S,D] write+read+ quantize (4×S×D bytes/call × 360 calls). Toggle: FLASHRT_DISABLE_NORM2_FFN=1. VAE kernels: * Add fp16_rms_silu_amax_quant_fp8_ndhwc_nozero variant that skips zeroing amax_buf for running-max mode (retained for future use; direct VAE fusion blocked by WanResidualBlock cache slice needing fp16). End-to-end tennis clip (70 frames, 432×240, RTX 5060 Ti): 7.28 s → 7.25 s (2.38x → 2.39x vs fp16 ref), PSNR mean 40.86 dB. --- .../fp16_rms_silu_fp8_ndhwc.cu | 38 ++++++++ .../fp16_rms_silu_fp8_ndhwc.cuh | 15 ++++ csrc/minimax_remover_extra_bindings.cpp | 21 +++++ docs/minimax_remover_usage.md | 2 +- .../models/minimax_remover/_kern_block.py | 88 ++++++++++++------- 5 files changed, 132 insertions(+), 32 deletions(-) diff --git a/csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cu b/csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cu index df45577b..95dacd7e 100644 --- a/csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cu +++ b/csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cu @@ -225,6 +225,44 @@ int fp16_rms_silu_amax_quant_fp8_ndhwc( return (e == cudaSuccess) ? 0 : -3; } +int fp16_rms_silu_amax_quant_fp8_ndhwc_nozero( + const void* x_fp16, const void* gamma_fp16, const void* bias_fp16, + void* y_fp8, void* scale_out, void* amax_buf, + int B, int C, int T, int H, int W, + float eps, cudaStream_t stream) +{ + if (B <= 0 || C <= 0 || T <= 0 || H <= 0 || W <= 0) return -1; + const long long total_spatial = (long long)B * T * H * W; + if (total_spatial <= 0 || total_spatial > (long long)INT32_MAX) return -4; + + const unsigned n_blocks = (unsigned)((total_spatial + kWarpsPerBlock - 1) + / kWarpsPerBlock); + + fused_rms_silu_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast(gamma_fp16), + bias_fp16 ? reinterpret_cast(bias_fp16) : nullptr, + nullptr, + nullptr, + reinterpret_cast(amax_buf), + nullptr, nullptr, + B, C, T, H, W, (int)total_spatial, eps); + + fused_rms_silu_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast(gamma_fp16), + bias_fp16 ? reinterpret_cast(bias_fp16) : nullptr, + nullptr, + reinterpret_cast<__nv_fp8_e4m3*>(y_fp8), + nullptr, + reinterpret_cast(amax_buf), + reinterpret_cast(scale_out), + B, C, T, H, W, (int)total_spatial, eps); + + cudaError_t e = cudaGetLastError(); + return (e == cudaSuccess) ? 0 : -3; +} + } // namespace minimax_remover } // namespace kernels } // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cuh b/csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cuh index 0a3d1597..8111832e 100644 --- a/csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cuh +++ b/csrc/kernels/minimax_remover/fp16_rms_silu_fp8_ndhwc.cuh @@ -72,6 +72,21 @@ int fp16_rms_silu_amax_quant_fp8_ndhwc( float eps, cudaStream_t stream); +// (3b) Same as (3) but does NOT zero amax_buf before pass 1. +// For running-max mode: caller seeds amax_buf with the historical +// running max; pass 1 atomicMax-accumulates the current output's +// amax; pass 2 quantizes with max(historical, current). +int fp16_rms_silu_amax_quant_fp8_ndhwc_nozero( + const void* x_fp16, + const void* gamma_fp16, + const void* bias_fp16, + void* y_fp8, + void* scale_out, + void* amax_buf, + int B, int C, int T, int H, int W, + float eps, + cudaStream_t stream); + } // namespace minimax_remover } // namespace kernels } // namespace flash_rt diff --git a/csrc/minimax_remover_extra_bindings.cpp b/csrc/minimax_remover_extra_bindings.cpp index 695a5267..57103403 100644 --- a/csrc/minimax_remover_extra_bindings.cpp +++ b/csrc/minimax_remover_extra_bindings.cpp @@ -253,6 +253,27 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { "(no write); pass 2 re-reads x and quantizes. Produces ONLY fp8 " "output + scale, no fp16 intermediate."); + m.def("fp16_rms_silu_amax_quant_fp8_ndhwc_nozero", + [](uintptr_t x_fp16, uintptr_t gamma_fp16, uintptr_t bias_fp16, + uintptr_t y_fp8, uintptr_t scale_out, uintptr_t amax_buf, + int B, int C, int T, int H, int W, + float eps, uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + fp16_rms_silu_amax_quant_fp8_ndhwc_nozero( + to_ptr(x_fp16), to_ptr(gamma_fp16), + bias_fp16 ? to_ptr(bias_fp16) : nullptr, + to_ptr(y_fp8), to_ptr(scale_out), to_ptr(amax_buf), + B, C, T, H, W, eps, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("gamma_fp16"), py::arg("bias_fp16"), + py::arg("y_fp8"), py::arg("scale_out"), py::arg("amax_buf"), + py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), + py::arg("eps") = 1e-6f, py::arg("stream") = 0, + "Same as fp16_rms_silu_amax_quant_fp8_ndhwc but does NOT zero " + "amax_buf before pass 1. For running-max mode: caller seeds " + "amax_buf with historical max; pass 1 accumulates current output; " + "pass 2 quantizes with max(historical, current)."); + // ── Fused FFN epilogue: bias + gelu + quant → fp8 (transformer) ── m.def("bias_gelu_quant_fp16_fp8", [](uintptr_t gemm_out, uintptr_t bias, uintptr_t out, diff --git a/docs/minimax_remover_usage.md b/docs/minimax_remover_usage.md index b268071a..d9df180f 100644 --- a/docs/minimax_remover_usage.md +++ b/docs/minimax_remover_usage.md @@ -341,7 +341,7 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d | 8.58 s | 2.02x | 39.9 / 36.4 dB | 0.99981 | | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue | 7.56 s | 2.29x | 40.0 / 36.4 dB | 0.99981 | | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue + fused bias-gate residual | 7.57 s | 2.30x | 40.0 / 36.2 dB | 0.99981 | -| tennis (70 frames, 432x240) | **… + fused adaLN+quant (shared-scale QKV) + fused RMSNorm+RoPE (default)** | **7.28 s** | **2.38x** | **40.8 / 36.3 dB** | 0.99981 | +| tennis (70 frames, 432x240) | **… + fused adaLN+quant (shared-scale QKV) + fused RMSNorm+RoPE + fused norm2+quant (default)** | **7.25 s** | **2.39x** | **40.9 / 36.4 dB** | 0.99981 | | tennis (70 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 9.52 s | 1.82x | 7.0 / 6.2 dB | 0.00000 (broken) | | bmx-trees (80 frames, 432x240) | fp16 reference (`--no-flashrt`) | 19.76 s | 1.0x | — | — | | bmx-trees (80 frames, 432x240) | FlashRT FP8 (default) | 13.24 s | **1.49x** | 35.1 / 32.0 dB | 0.99912 | diff --git a/flash_rt/models/minimax_remover/_kern_block.py b/flash_rt/models/minimax_remover/_kern_block.py index 7a640540..1ad56b29 100644 --- a/flash_rt/models/minimax_remover/_kern_block.py +++ b/flash_rt/models/minimax_remover/_kern_block.py @@ -114,6 +114,12 @@ def _ada_norm_flashrt_fp16(self_hs, scale_v, shift_v, S, D): and hasattr(_fvk, "fp16_ada_layernorm_quant_fp8") and os.environ.get("FLASHRT_DISABLE_ADA_QKV", "0") != "1") + # Fused norm2 + fp8 quantise — feeds FFN proj0 from fp8 directly, + # eliminating the intermediate fp16 [S,D] write + read + quantise. + _fuse_norm2_ffn = (_fvk is not None + and hasattr(_fvk, "fp16_ada_layernorm_quant_fp8") + and os.environ.get("FLASHRT_DISABLE_NORM2_FFN", "0") != "1") + def _gate_residual_apply(hs, x_no_bias, bias, gate, S, D): """residual += (x + bias) * gate[D] — one fused kernel if available, else falls back to add_bias_fp16 + gate_mul_residual_bcast.""" @@ -198,22 +204,8 @@ def block_forward(self, hidden_states, temb, rotary_emb): else: gate_mul_residual_bcast(hs, attn_out, gate_msa.view(D)) - if norm_mode == "fp16": - norm2_out = _ada_norm_flashrt_fp16(hs, c_scale_msa, c_shift_msa, S, D) - else: - norm2_out = _ada_norm(hs, c_scale_msa, c_shift_msa, S, D) - n2_3d = norm2_out.view(1, S, D) - if gelu_mode == "torch": - ff_out = self.ffn(n2_3d).view(S, D) - gate_mul_residual_bcast(hs, ff_out, c_gate_msa.view(D)) - return hs.view(1, S, D) - proj0 = self.ffn.net[0].proj proj1 = self.ffn.net[2] - # Fused FFN epilogue: bias + gelu + quant → fp8 in one kernel. - # Only when both Linears are FlashRT FP8, the kernel module is - # loaded, scales are frozen (not calibrating), and proj0 has a - # bias. Saves 3 full-tensor fp16 round-trips per block. _can_fuse = ( _fvk is not None and hasattr(proj0, "gemm_no_bias") @@ -221,32 +213,66 @@ def block_forward(self, hidden_states, temb, rotary_emb): and not getattr(proj1, "calibrating", False) and getattr(proj0, "bias", None) is not None and (proj0.out_features & 3) == 0) - # Additional fusion of proj1 (FFN-down) bias into gate+residual: - # requires the pre-quantised fp8 input path plus gemm_no_bias_from_fp8. _fuse_ffn_down = (_has_bgr and _can_fuse and hasattr(proj1, "gemm_no_bias_from_fp8") and getattr(proj1, "bias", None) is not None and (D & 7) == 0) - if _can_fuse: - raw = proj0.gemm_no_bias(n2_3d) # quant+GEMM, no bias → fp16 [1,S,inner] - inner = raw.shape[-1] - up_fp8 = torch.empty( - S, inner, dtype=torch.float8_e4m3fn, device=raw.device) - _fvk.bias_gelu_quant_fp16_fp8( - raw.data_ptr(), proj0.bias.data_ptr(), - up_fp8.data_ptr(), proj1.act_scale.data_ptr(), - S, inner, stream_of()) - if _fuse_ffn_down: - ff_out = proj1.gemm_no_bias_from_fp8(up_fp8).view(S, D) - _gate_residual_apply(hs, ff_out, proj1.bias, c_gate_msa, S, D) - return hs.view(1, S, D) - ff_out = proj1.forward_from_fp8(up_fp8).view(S, D) + + _do_norm2_fp8 = (_fuse_norm2_ffn and _can_fuse + and hasattr(proj0, "gemm_no_bias_from_fp8") + and not getattr(proj0, "calibrating", False) + and (D & 7) == 0) + + if gelu_mode == "torch": + if norm_mode == "fp16": + norm2_out = _ada_norm_flashrt_fp16(hs, c_scale_msa, c_shift_msa, S, D) + else: + norm2_out = _ada_norm(hs, c_scale_msa, c_shift_msa, S, D) + ff_out = self.ffn(norm2_out.view(1, S, D)).view(S, D) + gate_mul_residual_bcast(hs, ff_out, c_gate_msa.view(D)) + return hs.view(1, S, D) + + if _do_norm2_fp8: + c_scale_f = c_scale_msa.contiguous().to(torch.float32).view(D) + c_shift_f = c_shift_msa.contiguous().to(torch.float32).view(D) + norm2_fp8 = torch.empty(S, D, dtype=torch.float8_e4m3fn, + device=hs.device) + _fvk.fp16_ada_layernorm_quant_fp8( + hs.data_ptr(), c_scale_f.data_ptr(), c_shift_f.data_ptr(), + proj0.act_scale.data_ptr(), norm2_fp8.data_ptr(), + S, D, eps, stream_of()) + raw = proj0.gemm_no_bias_from_fp8(norm2_fp8) + elif _can_fuse: + if norm_mode == "fp16": + norm2_out = _ada_norm_flashrt_fp16(hs, c_scale_msa, c_shift_msa, S, D) + else: + norm2_out = _ada_norm(hs, c_scale_msa, c_shift_msa, S, D) + raw = proj0.gemm_no_bias(norm2_out.view(1, S, D)) else: - up = proj0(n2_3d) + if norm_mode == "fp16": + norm2_out = _ada_norm_flashrt_fp16(hs, c_scale_msa, c_shift_msa, S, D) + else: + norm2_out = _ada_norm(hs, c_scale_msa, c_shift_msa, S, D) + up = proj0(norm2_out.view(1, S, D)) inner = up.shape[-1] _gelu_fn = kern.gelu_inplace if up.dtype == torch.bfloat16 else kern.gelu_inplace_fp16 _gelu_fn(up.data_ptr(), S * inner, stream_of()) ff_out = proj1(up).view(S, D) + gate_mul_residual_bcast(hs, ff_out, c_gate_msa.view(D)) + return hs.view(1, S, D) + + inner = raw.shape[-1] + up_fp8 = torch.empty( + S, inner, dtype=torch.float8_e4m3fn, device=raw.device) + _fvk.bias_gelu_quant_fp16_fp8( + raw.data_ptr(), proj0.bias.data_ptr(), + up_fp8.data_ptr(), proj1.act_scale.data_ptr(), + S, inner, stream_of()) + if _fuse_ffn_down: + ff_out = proj1.gemm_no_bias_from_fp8(up_fp8).view(S, D) + _gate_residual_apply(hs, ff_out, proj1.bias, c_gate_msa, S, D) + return hs.view(1, S, D) + ff_out = proj1.forward_from_fp8(up_fp8).view(S, D) gate_mul_residual_bcast(hs, ff_out, c_gate_msa.view(D)) return hs.view(1, S, D) From 99ad5a38c6e02534d8bb715ae2714de8e079cef5 Mon Sep 17 00:00:00 2001 From: chenping Date: Wed, 8 Jul 2026 15:16:49 +0800 Subject: [PATCH 19/23] feat(minimax-remover): add fused RMSNorm+RoPE+int8 quant kernels for Q/K --- CMakeLists.txt | 3 +- .../fp16_rmsnorm_rope_quant_int8.cu | 320 ++++++++++++++++++ .../fp16_rmsnorm_rope_quant_int8.cuh | 46 +++ csrc/minimax_remover_extra_bindings.cpp | 54 +++ flash_rt/models/minimax_remover/_attention.py | 92 +++++ 5 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cu create mode 100644 csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cuh diff --git a/CMakeLists.txt b/CMakeLists.txt index 506a8029..0e26bfb6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -973,7 +973,8 @@ if(FLASHRT_ENABLE_MINIMAX_REMOVER) csrc/kernels/minimax_remover/fp16_bias_gelu_quant_fp8.cu csrc/kernels/minimax_remover/fp16_bias_gate_residual.cu csrc/kernels/minimax_remover/fp16_ada_layernorm_quant_fp8.cu - csrc/kernels/minimax_remover/fp16_rmsnorm_rope.cu) + csrc/kernels/minimax_remover/fp16_rmsnorm_rope.cu + csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cu) set_target_properties(flash_rt_minimax_remover PROPERTIES CUDA_STANDARD 17 LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cu b/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cu new file mode 100644 index 00000000..3cd16857 --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cu @@ -0,0 +1,320 @@ +// ================================================================ +// Fused RMSNorm + interleaved RoPE + int8 per-warp/per-block quantization. +// +// Two-kernel pipeline: +// Kernel A (rmsnorm_rstd_kernel): 1 block/token, computes rstd across +// D=H*Dd and writes to rstd[B*S] global buffer. +// Kernel B (norm_rope_quant_kernel): 1 block per (group, head, batch), +// reads x fp16 + rstd, applies norm+rope, reduces max-abs across +// the group, quantizes to int8 in one pass over shared memory. +// +// Eliminates the intermediate fp16 tensor between rmsnorm+rope and +// the int8 quantization — saves 2×S×D bytes per Q/K tensor (read+write). +// ================================================================ + +#include "fp16_rmsnorm_rope_quant_int8.cuh" +#include +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +namespace { + +constexpr int VEC_RSTD = 8; + +__device__ __forceinline__ float warp_reduce_sum_rq(float v) { + #pragma unroll + for (int off = 16; off > 0; off >>= 1) + v += __shfl_xor_sync(0xffffffff, v, off); + return v; +} + +__device__ __forceinline__ float warp_reduce_max_rq(float v) { + #pragma unroll + for (int off = 16; off > 0; off >>= 1) + v = fmaxf(v, __shfl_xor_sync(0xffffffff, v, off)); + return v; +} + +// ─── Kernel A: compute rstd per token ───────────────────────────── +// Grid: (B*S), Block: 128 threads +// Reads D fp16 elements, reduces sum-of-squares, writes 1 float. +template +__global__ void rmsnorm_rstd_kernel( + const __half* __restrict__ x, // [B*S, D] + float* __restrict__ rstd_out, // [B*S] + int D, float eps) +{ + const int tok = blockIdx.x; + const int tid = threadIdx.x; + const int lane = tid & 31; + const int warp = tid >> 5; + constexpr int NWARPS = THREADS / 32; + + const __half* xrow = x + (long long)tok * D; + + float local_sq = 0.f; + const int D_vec = D / VEC_RSTD; + for (int v = tid; v < D_vec; v += THREADS) { + const uint4* p = reinterpret_cast(xrow + v * VEC_RSTD); + uint4 raw = *p; + __half2 h0 = *reinterpret_cast<__half2*>(&raw.x); + __half2 h1 = *reinterpret_cast<__half2*>(&raw.y); + __half2 h2 = *reinterpret_cast<__half2*>(&raw.z); + __half2 h3 = *reinterpret_cast<__half2*>(&raw.w); + float2 f0 = __half22float2(h0); + float2 f1 = __half22float2(h1); + float2 f2 = __half22float2(h2); + float2 f3 = __half22float2(h3); + local_sq += f0.x*f0.x + f0.y*f0.y + f1.x*f1.x + f1.y*f1.y + + f2.x*f2.x + f2.y*f2.y + f3.x*f3.x + f3.y*f3.y; + } + + __shared__ float smem[NWARPS]; + float ws = warp_reduce_sum_rq(local_sq); + if (lane == 0) smem[warp] = ws; + __syncthreads(); + + if (warp == 0) { + float v = (lane < NWARPS) ? smem[lane] : 0.f; + v = warp_reduce_sum_rq(v); + if (lane == 0) + rstd_out[tok] = rsqrtf(v / (float)D + eps); + } +} + +// ─── Kernel B: norm + rope + max-abs + quantize ────────────────── +// Grid: (num_groups, H, B) +// Block: TPB threads (256) +// Each block processes GROUP_SIZE tokens × Dd dims for one head. +// +// Shared memory: +// normed[GROUP_SIZE * Dd] fp32 — holds normalized+RoPE'd values +// reduce_buf[TPB] fp32 — for max-abs reduction +template +__global__ void norm_rope_quant_kernel( + const __half* __restrict__ x, // [B*S, D] + const __half* __restrict__ weight, // [D] + const float* __restrict__ cos_tab, // [S, Dd/2] + const float* __restrict__ sin_tab, // [S, Dd/2] + const float* __restrict__ rstd, // [B*S] + const __half* __restrict__ km, // [B, H, Dd] or nullptr + int8_t* __restrict__ out, // [B*S, D] + float* __restrict__ scale_out, // [B, H, num_groups] + int B, int S, int H, int Dd, + float sm_scale_factor) +{ + const int group_idx = blockIdx.x; + const int h = blockIdx.y; + const int b = blockIdx.z; + const int tid = threadIdx.x; + const int D = H * Dd; + + const int tok_start = group_idx * GROUP_SIZE; + const int tok_end = min(tok_start + GROUP_SIZE, S); + const int n_toks = tok_end - tok_start; + const int total_elems = n_toks * Dd; + + extern __shared__ char smem_raw[]; + float* normed = reinterpret_cast(smem_raw); + float* reduce_buf = normed + GROUP_SIZE * Dd; + + // ── Phase 1: Load, normalize, apply RoPE, store to shared ── + float local_max = 0.f; + + // Process elements in pairs (for RoPE correctness) + // Each element at even dim d needs its partner at d+1 and vice versa. + // Process in pairs: iterate over (tok, pair_idx) where pair_idx = d/2. + const int total_pairs = n_toks * (Dd / 2); + + for (int idx = tid; idx < total_pairs; idx += TPB) { + const int lt = idx / (Dd / 2); // local token + const int pair = idx % (Dd / 2); // pair index within head + const int d0 = pair * 2; + const int d1 = d0 + 1; + const int s = tok_start + lt; // sequence position + const int global_tok = b * S + s; + + const float my_rstd = rstd[global_tok]; + + // Load x values + float x0 = __half2float(x[(long long)global_tok * D + h * Dd + d0]); + float x1 = __half2float(x[(long long)global_tok * D + h * Dd + d1]); + + // Load weights + float w0 = __half2float(weight[h * Dd + d0]); + float w1 = __half2float(weight[h * Dd + d1]); + + // RMSNorm + affine + x0 = x0 * my_rstd * w0; + x1 = x1 * my_rstd * w1; + + // Interleaved RoPE: (x0, x1) → (x0*c - x1*s, x0*s + x1*c) + float c = cos_tab[s * (Dd / 2) + pair]; + float sn = sin_tab[s * (Dd / 2) + pair]; + float r0 = x0 * c - x1 * sn; + float r1 = x0 * sn + x1 * c; + + // Apply sm_scale (for Q quantization, folds softmax scale) + r0 *= sm_scale_factor; + r1 *= sm_scale_factor; + + // Subtract key mean if applicable (smooth_k) + if (km != nullptr) { + float km0 = __half2float(km[(long long)b * H * Dd + h * Dd + d0]); + float km1 = __half2float(km[(long long)b * H * Dd + h * Dd + d1]); + r0 -= km0 * sm_scale_factor; + r1 -= km1 * sm_scale_factor; + } + + // Store to shared memory + normed[lt * Dd + d0] = r0; + normed[lt * Dd + d1] = r1; + + // Track max absolute value + local_max = fmaxf(local_max, fmaxf(fabsf(r0), fabsf(r1))); + } + __syncthreads(); + + // ── Phase 2: Reduce max-abs across all threads ── + reduce_buf[tid] = local_max; + __syncthreads(); + for (int stride = TPB / 2; stride > 0; stride >>= 1) { + if (tid < stride) + reduce_buf[tid] = fmaxf(reduce_buf[tid], reduce_buf[tid + stride]); + __syncthreads(); + } + float group_max = reduce_buf[0]; + float scale = group_max / 127.0f + 1e-7f; + float inv_scale = 1.0f / scale; + + // Write scale + if (tid == 0) { + const int num_groups = (S + GROUP_SIZE - 1) / GROUP_SIZE; + scale_out[(long long)b * H * num_groups + h * num_groups + group_idx] = scale; + } + + // ── Phase 3: Quantize from shared memory and write int8 ── + for (int idx = tid; idx < total_elems; idx += TPB) { + const int lt = idx / Dd; + const int d = idx % Dd; + const int global_tok = b * S + tok_start + lt; + + float val = normed[lt * Dd + d]; + int ival = __float2int_rn(val * inv_scale); + ival = max(-128, min(127, ival)); + out[(long long)global_tok * D + h * Dd + d] = static_cast(ival); + } +} + +} // anonymous namespace + +int fp16_rmsnorm_rope_quant_int8_q( + const void* x_fp16, + const void* weight_fp16, + const void* cos_fp32, + const void* sin_fp32, + void* out_int8, + void* scale_fp32, + int B, int S, int H, int Dd, + float eps, float sm_scale, + cudaStream_t stream) +{ + if (!x_fp16 || !weight_fp16 || !cos_fp32 || !sin_fp32 || + !out_int8 || !scale_fp32) + return -1; + if (B <= 0 || S <= 0 || H <= 0 || Dd <= 0) return -2; + const int D = H * Dd; + if ((D % VEC_RSTD) != 0) return -3; + + // Allocate temp buffer for rstd on the stream + float* rstd_buf; + cudaError_t e = cudaMallocAsync(&rstd_buf, B * S * sizeof(float), stream); + if (e != cudaSuccess) return -4; + + // Kernel A: compute rstd + constexpr int THREADS_A = 128; + rmsnorm_rstd_kernel<<>>( + reinterpret_cast(x_fp16), + rstd_buf, D, eps); + + // Kernel B: norm + rope + quantize (Q: GROUP_SIZE=32) + constexpr int GROUP_SIZE = 32; + constexpr int TPB = 256; + const int num_groups = (S + GROUP_SIZE - 1) / GROUP_SIZE; + dim3 grid(num_groups, H, B); + size_t smem = (GROUP_SIZE * Dd + TPB) * sizeof(float); + + norm_rope_quant_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast(weight_fp16), + reinterpret_cast(cos_fp32), + reinterpret_cast(sin_fp32), + rstd_buf, + nullptr, // no km for Q + reinterpret_cast(out_int8), + reinterpret_cast(scale_fp32), + B, S, H, Dd, sm_scale); + + cudaFreeAsync(rstd_buf, stream); + e = cudaGetLastError(); + return (e == cudaSuccess) ? 0 : -5; +} + +int fp16_rmsnorm_rope_quant_int8_k( + const void* x_fp16, + const void* weight_fp16, + const void* cos_fp32, + const void* sin_fp32, + const void* km_fp16, + void* out_int8, + void* scale_fp32, + int B, int S, int H, int Dd, + float eps, float sm_scale, + cudaStream_t stream) +{ + if (!x_fp16 || !weight_fp16 || !cos_fp32 || !sin_fp32 || + !out_int8 || !scale_fp32) + return -1; + if (B <= 0 || S <= 0 || H <= 0 || Dd <= 0) return -2; + const int D = H * Dd; + if ((D % VEC_RSTD) != 0) return -3; + + float* rstd_buf; + cudaError_t e = cudaMallocAsync(&rstd_buf, B * S * sizeof(float), stream); + if (e != cudaSuccess) return -4; + + // Kernel A: compute rstd + constexpr int THREADS_A = 128; + rmsnorm_rstd_kernel<<>>( + reinterpret_cast(x_fp16), + rstd_buf, D, eps); + + // Kernel B: norm + rope + quantize (K: GROUP_SIZE=64, with smooth_k) + constexpr int GROUP_SIZE = 64; + constexpr int TPB = 256; + const int num_groups = (S + GROUP_SIZE - 1) / GROUP_SIZE; + dim3 grid(num_groups, H, B); + size_t smem = (GROUP_SIZE * Dd + TPB) * sizeof(float); + + norm_rope_quant_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast(weight_fp16), + reinterpret_cast(cos_fp32), + reinterpret_cast(sin_fp32), + rstd_buf, + km_fp16 ? reinterpret_cast(km_fp16) : nullptr, + reinterpret_cast(out_int8), + reinterpret_cast(scale_fp32), + B, S, H, Dd, sm_scale); + + cudaFreeAsync(rstd_buf, stream); + e = cudaGetLastError(); + return (e == cudaSuccess) ? 0 : -5; +} + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cuh b/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cuh new file mode 100644 index 00000000..33f5ed90 --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cuh @@ -0,0 +1,46 @@ +#pragma once +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +// Fused RMSNorm + RoPE + per-warp int8 quantization (for Q). +// Input: x [B*S, H*Dd] fp16, in-place layout [B, S, H, Dd]. +// Output: out_int8 [B*S, H*Dd] int8 (same layout as x). +// scale [B, H, num_scale_groups] fp32 +// where num_scale_groups = ceil(S / WARPQ) * (BLKQ / WARPQ) +// For BLKQ=128, WARPQ=32: num_scale_groups = ceil(S/128) * 4 +// Each scale covers WARPQ=32 consecutive tokens for one head. +int fp16_rmsnorm_rope_quant_int8_q( + const void* x_fp16, // [B*S, H*Dd] fp16 input + const void* weight_fp16, // [H*Dd] fp16 norm weight + const void* cos_fp32, // [S, Dd/2] fp32 + const void* sin_fp32, // [S, Dd/2] fp32 + void* out_int8, // [B*S, H*Dd] int8 output + void* scale_fp32, // [B, H, ceil(S/128)*4] fp32 + int B, int S, int H, int Dd, + float eps, float sm_scale, + cudaStream_t stream); + +// Fused RMSNorm + RoPE + per-block int8 quantization + smooth_k (for K). +// Input: x [B*S, H*Dd] fp16. +// Output: out_int8 [B*S, H*Dd] int8. +// scale [B, H, ceil(S/64)] fp32 +// Each scale covers BLKK=64 consecutive tokens for one head. +// smooth_k: subtract per-head mean (km) before quantization. +int fp16_rmsnorm_rope_quant_int8_k( + const void* x_fp16, // [B*S, H*Dd] fp16 input + const void* weight_fp16, // [H*Dd] fp16 norm weight + const void* cos_fp32, // [S, Dd/2] fp32 + const void* sin_fp32, // [S, Dd/2] fp32 + const void* km_fp16, // [B, 1, H, Dd] fp16 key mean (smooth_k) + void* out_int8, // [B*S, H*Dd] int8 output + void* scale_fp32, // [B, H, ceil(S/64)] fp32 + int B, int S, int H, int Dd, + float eps, float sm_scale, + cudaStream_t stream); + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/minimax_remover_extra_bindings.cpp b/csrc/minimax_remover_extra_bindings.cpp index 57103403..951d6311 100644 --- a/csrc/minimax_remover_extra_bindings.cpp +++ b/csrc/minimax_remover_extra_bindings.cpp @@ -23,6 +23,7 @@ #include "kernels/minimax_remover/fp16_bias_gate_residual.cuh" #include "kernels/minimax_remover/fp16_ada_layernorm_quant_fp8.cuh" #include "kernels/minimax_remover/fp16_rmsnorm_rope.cuh" +#include "kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cuh" namespace py = pybind11; @@ -382,4 +383,57 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { "Replaces the 2-kernel rms_norm_fp32stat + rope_apply_bshd Triton " "sequence with one pass — eliminates one full fp16 read+write of " "the Q/K tensor. Dd must be a multiple of 8."); + + // ── Fused RMSNorm + RoPE + int8 quantize (Q, per-warp) ────────── + m.def("fp16_rmsnorm_rope_quant_int8_q", + [](uintptr_t x_fp16, uintptr_t weight_fp16, + uintptr_t cos_fp32, uintptr_t sin_fp32, + uintptr_t out_int8, uintptr_t scale_fp32, + int B, int S, int H, int Dd, + float eps, float sm_scale, uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + fp16_rmsnorm_rope_quant_int8_q( + to_ptr(x_fp16), to_ptr(weight_fp16), + to_ptr(cos_fp32), to_ptr(sin_fp32), + to_ptr(out_int8), to_ptr(scale_fp32), + B, S, H, Dd, eps, sm_scale, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("weight_fp16"), + py::arg("cos_fp32"), py::arg("sin_fp32"), + py::arg("out_int8"), py::arg("scale_fp32"), + py::arg("B"), py::arg("S"), py::arg("H"), py::arg("Dd"), + py::arg("eps") = 1e-6f, py::arg("sm_scale") = 1.0f, + py::arg("stream") = 0, + "Fused RMSNorm + RoPE + per-warp int8 quantization for Q. " + "Eliminates the fp16 intermediate between norm+rope and quantize. " + "Output: int8 [B*S, H*Dd], scale [B, H, ceil(S/32)]. " + "sm_scale is folded into quantization (softmax scale pre-multiply)."); + + // ── Fused RMSNorm + RoPE + int8 quantize (K, per-block + smooth_k) ─ + m.def("fp16_rmsnorm_rope_quant_int8_k", + [](uintptr_t x_fp16, uintptr_t weight_fp16, + uintptr_t cos_fp32, uintptr_t sin_fp32, + uintptr_t km_fp16, + uintptr_t out_int8, uintptr_t scale_fp32, + int B, int S, int H, int Dd, + float eps, float sm_scale, uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + fp16_rmsnorm_rope_quant_int8_k( + to_ptr(x_fp16), to_ptr(weight_fp16), + to_ptr(cos_fp32), to_ptr(sin_fp32), + to_ptr(km_fp16), + to_ptr(out_int8), to_ptr(scale_fp32), + B, S, H, Dd, eps, sm_scale, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("weight_fp16"), + py::arg("cos_fp32"), py::arg("sin_fp32"), + py::arg("km_fp16"), + py::arg("out_int8"), py::arg("scale_fp32"), + py::arg("B"), py::arg("S"), py::arg("H"), py::arg("Dd"), + py::arg("eps") = 1e-6f, py::arg("sm_scale") = 1.0f, + py::arg("stream") = 0, + "Fused RMSNorm + RoPE + per-block int8 quantization for K with " + "smooth_k (subtract key mean). Eliminates fp16 intermediate. " + "Output: int8 [B*S, H*Dd], scale [B, H, ceil(S/64)]. " + "km_fp16 can be 0 (nullptr) to skip smooth_k."); } diff --git a/flash_rt/models/minimax_remover/_attention.py b/flash_rt/models/minimax_remover/_attention.py index 7b75560c..1b7f906e 100644 --- a/flash_rt/models/minimax_remover/_attention.py +++ b/flash_rt/models/minimax_remover/_attention.py @@ -47,6 +47,37 @@ _fvk is not None and hasattr(_fvk, "fp16_rmsnorm_rope_bshd") and os.environ.get("FLASHRT_DISABLE_RMSNORM_ROPE", "0") != "1") +_has_fused_rmsnorm_rope_quant = ( + _fvk is not None + and hasattr(_fvk, "fp16_rmsnorm_rope_quant_int8_q") + and hasattr(_fvk, "fp16_rmsnorm_rope_quant_int8_k") + and os.environ.get("FLASHRT_DISABLE_FUSED_QUANT", "0") != "1") + +_SM89_COMPILE = None +_PER_CHANNEL_FP8 = None + + +def _get_sm89(): + global _SM89_COMPILE + if _SM89_COMPILE is None: + try: + from sageattention import sm89_compile + _SM89_COMPILE = sm89_compile + except ImportError: + _SM89_COMPILE = False + return _SM89_COMPILE if _SM89_COMPILE is not False else None + + +def _get_per_channel_fp8(): + global _PER_CHANNEL_FP8 + if _PER_CHANNEL_FP8 is None: + try: + from sageattention.quant import per_channel_fp8 + _PER_CHANNEL_FP8 = per_channel_fp8 + except ImportError: + _PER_CHANNEL_FP8 = False + return _PER_CHANNEL_FP8 if _PER_CHANNEL_FP8 is not False else None + try: _NUM_SMS = torch.cuda.get_device_properties(0).multi_processor_count except Exception: @@ -196,6 +227,67 @@ def __call__(self, attn, hidden_states, rotary_emb=None, _fuse_qk = (_has_fused_rmsnorm_rope and cs is not None and attn.norm_q is not None and attn.norm_k is not None and (Dd & 7) == 0) + + # ── Fully-fused path: RMSNorm + RoPE + int8 quant → sm89 attn ── + # Eliminates the fp16 intermediate between norm+rope and QK quantize. + _fuse_quant = (_fuse_qk and _has_fused_rmsnorm_rope_quant + and mode in ("sage_fp8", "sage2") + and _get_sm89() is not None + and _get_per_channel_fp8() is not None) + if _fuse_quant: + if not q.is_contiguous(): + q = q.contiguous() + if not k.is_contiguous(): + k = k.contiguous() + stream = torch.cuda.current_stream().cuda_stream + D = H * Dd + + num_groups_q = (S + 31) // 32 + num_groups_k = (S + 63) // 64 + q_int8 = torch.empty(B * S, D, device=q.device, dtype=torch.int8) + k_int8 = torch.empty(B * S, D, device=q.device, dtype=torch.int8) + q_scale = torch.empty(B, H, num_groups_q, device=q.device, + dtype=torch.float32) + k_scale = torch.empty(B, H, num_groups_k, device=q.device, + dtype=torch.float32) + + _fvk.fp16_rmsnorm_rope_quant_int8_q( + q.data_ptr(), attn.norm_q.weight.data_ptr(), + cs[0].data_ptr(), cs[1].data_ptr(), + q_int8.data_ptr(), q_scale.data_ptr(), + B, S, H, Dd, float(attn.norm_q.eps), 1.0, stream) + _fvk.fp16_rmsnorm_rope_quant_int8_k( + k.data_ptr(), attn.norm_k.weight.data_ptr(), + cs[0].data_ptr(), cs[1].data_ptr(), + 0, # no smooth_k (negligible impact, saves k.mean compute) + k_int8.data_ptr(), k_scale.data_ptr(), + B, S, H, Dd, float(attn.norm_k.eps), 1.0, stream) + + v = v.view(B, S, H, Dd) + if not v.is_contiguous(): + v = v.contiguous() + + per_channel_fp8 = _get_per_channel_fp8() + v_fp8, v_scale, _ = per_channel_fp8( + v, tensor_layout="NHD", scale_max=2.25, smooth_v=False) + + q_int8 = q_int8.view(B, S, H, Dd) + k_int8 = k_int8.view(B, S, H, Dd) + out = torch.empty(B, S, H, Dd, device=q.device, dtype=q.dtype) + + sm89 = _get_sm89() + sm89.qk_int8_sv_f8_accum_f16_fuse_v_scale_attn_inst_buf( + q_int8, k_int8, v_fp8, out, q_scale, k_scale, v_scale, + 0, 0, 2, scale, 0) + + hidden_states = out.view(B, S, H * Dd) + to_out0 = attn.to_out[0] + if no_out_bias and hasattr(to_out0, "gemm_no_bias"): + hidden_states = to_out0.gemm_no_bias(hidden_states) + else: + hidden_states = to_out0(hidden_states) + return hidden_states + if _fuse_qk: if not q.is_contiguous(): q = q.contiguous() From 0dae91ca93ac3ec14d02cd25fa47677b17bd1397 Mon Sep 17 00:00:00 2001 From: chenping Date: Wed, 8 Jul 2026 16:51:09 +0800 Subject: [PATCH 20/23] docs(minimax-remover): update benchmark results with serial A/B measurements --- docs/minimax_remover_usage.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/minimax_remover_usage.md b/docs/minimax_remover_usage.md index d9df180f..0fffbf24 100644 --- a/docs/minimax_remover_usage.md +++ b/docs/minimax_remover_usage.md @@ -336,26 +336,29 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the | Clip (frames, resolution) | Stack | Wall time | Speedup vs fp16 ref | PSNR mean / worst vs fp16 ref | cosine mean | |--------------------------|-------|-----------|---------------------|-------------------------------|-------------| -| tennis (70 frames, 432x240) | fp16 reference (`--no-flashrt`) | 17.33 s | 1.0x | — | — | +| tennis (70 frames, 432x240) | fp16 reference (`--no-flashrt --no-vae-opt`) | 17.33 s | 1.0x | — | — | | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL (`--no-fp8-conv`) | 10.01 s | 1.73x | 40.8 / 37.0 dB | 0.99981 | | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d | 8.58 s | 2.02x | 39.9 / 36.4 dB | 0.99981 | | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue | 7.56 s | 2.29x | 40.0 / 36.4 dB | 0.99981 | | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue + fused bias-gate residual | 7.57 s | 2.30x | 40.0 / 36.2 dB | 0.99981 | -| tennis (70 frames, 432x240) | **… + fused adaLN+quant (shared-scale QKV) + fused RMSNorm+RoPE + fused norm2+quant (default)** | **7.25 s** | **2.39x** | **40.9 / 36.4 dB** | 0.99981 | +| tennis (70 frames, 432x240) | **… + fused adaLN+quant (shared-scale QKV) + fused RMSNorm+RoPE + fused norm2+quant (default)** | **7.18 s** | **2.41x** | **40.0 / 36.0 dB** | 0.99981 | | tennis (70 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 9.52 s | 1.82x | 7.0 / 6.2 dB | 0.00000 (broken) | | bmx-trees (80 frames, 432x240) | fp16 reference (`--no-flashrt`) | 19.76 s | 1.0x | — | — | | bmx-trees (80 frames, 432x240) | FlashRT FP8 (default) | 13.24 s | **1.49x** | 35.1 / 32.0 dB | 0.99912 | | bmx-trees (80 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 10.72 s | 1.84x | 7.3 / 7.0 dB | 0.00000 (broken) | -> Tennis numbers are from a same-session A/B (`--no-flashrt` vs `--vae-opt` -> with/without `--fp8-conv`) measured on RTX 5060 Ti, CUDA 13.0, cuDNN 9.2, -> PyTorch 2.12. Peak VRAM: fp16 ref 3.67 GB, FlashRT FP8 conv3d 2.51 GB. +> Tennis numbers are from a same-session serial A/B (`--no-flashrt +> --no-vae-opt` vs default FlashRT FP8 stack) measured on RTX 5060 Ti, +> CUDA 13.0, cuDNN 9.2, PyTorch 2.12. Run one process at a time — +> parallel invocations contend on the GPU and inflate wall time. +> Peak VRAM: fp16 ref 3.67 GB, FlashRT FP8 2.51 GB. Takeaways: -- **FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue + fused bias-gate residual + fused adaLN-quant (shared-scale QKV) + fused RMSNorm+RoPE is the recommended default**: 2.38x - faster than the fp16 reference with PSNR 40.8 dB (median) on full-frame - tennis clip, peak VRAM 2.51 GB. The fused FFN epilogue kernel +- **FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue + fused bias-gate residual + fused adaLN-quant (shared-scale QKV) + fused RMSNorm+RoPE is the recommended default**: 2.41x + faster than the fp16 reference (17.33 s → 7.18 s) with PSNR 40.0 dB (median, + 39.93 dB mean over 69 inpainted frames) on full-frame tennis clip, peak VRAM + 2.51 GB (vs 3.67 GB fp16 ref, –32%). The fused FFN epilogue kernel (`bias_gelu_quant_fp16_fp8`) collapses bias-add + GELU + activation quantise into one pass, cutting denoise GPU time by 15% (3.73 → 3.16 s) and end-to-end from 8.58 → 7.56 s. The fused bias + gate + residual @@ -371,7 +374,8 @@ Takeaways: `fp16_rmsnorm_rope_bshd` kernel then collapses the Q/K RMSNorm + interleaved RoPE into a single per-token pass, eliminating one full fp16 R/W of each Q/K tensor per attention block. Combined wall time - drops 7.57 → 7.28 s (–4%). + drops 7.57 → 7.28 s (–4%). Latest serial measurement (same-session A/B, + one process at a time, no GPU contention) confirms **7.18 s / 2.41x**. - **FP8 conv3d vs channels-last-only cuDNN**: the hand-rolled implicit-GEMM kernel (no im2col materialization, virtual cache concat, per-channel weight dequant, fused amax, running-max scale) beats cuDNN's fp16 conv3d @@ -411,14 +415,14 @@ to the quantised GEMMs and the attention backend. | VAE `fp16_rms_silu_ndhwc` (CL) kernel | cosine vs fp32 reference | >= 0.9999999 | | VAE `fp16_rms_silu_amax_ndhwc` kernel | amax vs fp32 reference | exact (atomicMax on non-negative floats) | | VAE `fp8_conv3d_mm` kernel | cosine vs fp16 F.conv3d | >= 0.9993 (per-layer) | -| End-to-end FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue (full-frame) | PSNR vs fp16 ref | 40.0 dB (median) / >= 36.4 dB (worst frame) | +| End-to-end FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue (full-frame) | PSNR vs fp16 ref | 40.0 dB (median) / >= 36.0 dB (worst frame) | | End-to-end FP8 + VAE opt + CL + FP8 conv3d (full-frame) | PSNR vs fp16 ref | 39.9 dB (median) / >= 36.4 dB (worst frame) | | End-to-end FP8 + VAE opt + CL only (no FP8 conv3d) | PSNR vs fp16 ref | 40.8 dB (median) / >= 37.0 dB (worst frame) | | Attention — SageAttention QK-int8 PV-fp8 (`sage_fp8`, default) | cosine vs SDPA | 0.9993 | | Attention — SageAttention QK-int8 PV-fp16 (`sage_fp16`) | cosine vs SDPA | 0.9999 | | NVFP4 W4A4 GEMM | cosine vs fp16 matmul | >= 0.999 | | FP8 W8A8 GEMM | cosine vs fp16 matmul | >= 0.999 | -| End-to-end FP8 (full-frame) | PSNR vs fp16 ref | 35-41 dB (mean) / >= 32 dB (worst frame) | +| End-to-end FP8 (full-frame) | PSNR vs fp16 ref | 35-41 dB (mean 39.9) / >= 36 dB (worst frame) | | End-to-end FP8 (full-frame) | cosine vs fp16 ref | >= 0.999 | | End-to-end NVFP4 (full-frame) | cosine vs fp16 ref | ~0.0 — **broken**, output drifts to black (median per-pixel deviation ~85 / 255) | | End-to-end NVFP4 (small cropped region only) | PSNR vs fp16 ref | ~52 dB (mean) / ~45 dB (worst frame); per-block FP4 error stays bounded only when activations are small | From 772e437844e64251868d1c177706e6a98c99248c Mon Sep 17 00:00:00 2001 From: chenping Date: Wed, 8 Jul 2026 20:38:51 +0800 Subject: [PATCH 21/23] feat(minimax-remover): add purpose-built NVFP4 W4A4 VAE conv3d kernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a WanVAE-specific NVFP4 (W4A4) conv3d path that accelerates VAE encode/decode by ~16% on top of the existing FP8 transformer path. New CUDA kernels (in csrc/kernels/minimax_remover/): - nvfp4_conv3d_ndhwc_fp16out.cu: Purpose-built NVFP4 implicit-GEMM conv3d using mma.sync.kind::mxf4nvf4 (e2m1 x e2m1, UE4M3 block scales). Outputs fp16 NDHWC directly (vs motus kernel's bf16 NCDHW), eliminating two conversion passes per layer. Tile: BLOCK_M=128, BLOCK_N=128, BLOCK_K=64, 8 warps, 2-stage cp.async pipeline. Constraint: Ci % 64 == 0 (covers WanVAE Ci=192/384). - fp16_quant_nvfp4_ndhwc.cu: Fused fp16 -> NVFP4 quantization kernel with per-16-element UE4M3 block scales. Three variants: (1) plain quant (NCDHW input), (2) plain quant (channels-last 3D input — eliminates contiguous() copy, coalesced channel reads), (3) fused RMS_norm + SiLU + NVFP4 quant (channels-last input, single kernel replaces 3 separate passes). kThreadsY=6 design supports all WanVAE channel sizes (96/192/384). Python integration (flash_rt/models/minimax_remover/_vae_nvfp4.py): - install_vae_nvfp4(): pre-quantizes eligible conv3d weights to NVFP4 at install time; hooks WanCausalConv3d.forward to dispatch to the NVFP4 kernel for eligible layers (Ci >= 192, 3x3x3, Ci % 64 == 0). 38 layers quantized (encode + decode). - Rolling 2-frame FP4 cache: stores quantized FP4+SF from the previous call and reuses it as the causal-conv cache, eliminating per-call cache re-quantization. Handles T_new=1 (decode) by combining [prev_frame, current_frame] in a sliding window. - Channels-last direct input: detects channels-last 3D tensors and uses the CL quant kernel variant, avoiding a contiguous() copy. Quickstart integration: NVFP4 VAE is enabled by default; --no-nvfp4-vae disables it. Performance (RTX 5060 Ti, 70-frame tennis clip, 432x240): Non-FlashRT (fp16): 17.33s 1.00x (reference) FlashRT FP8: 7.18s 2.41x PSNR 40.0 dB vs fp16 FlashRT FP8+NVFP4 VAE: 6.71s 2.58x PSNR 34.7 dB vs fp16 VAE encode: 1829 -> 1564 ms (-14.5%) VAE decode: 1664 -> 1438 ms (-13.6%) VAE total: 3493 -> 3002 ms (-16.4%) The NVFP4 VAE path works correctly on full-frame inputs (unlike the NVFP4 transformer path which is broken on full-frame due to FP4 error accumulation over the 12-step denoise loop). The VAE is a single-pass encoder/decoder with no iterative error accumulation. Documentation (docs/minimax_remover_usage.md) updated to: - Distinguish NVFP4 VAE (works, default ON) from NVFP4 transformer (broken on full-frame, small-region only) - Add new kernel entries, performance row, CLI flag, env vars - Add usage example for the NVFP4 VAE path --- CMakeLists.txt | 4 +- .../minimax_remover/fp16_quant_nvfp4_ndhwc.cu | 447 ++++++++++++++++ .../fp16_quant_nvfp4_ndhwc.cuh | 76 +++ .../nvfp4_conv3d_ndhwc_fp16out.cu | 464 ++++++++++++++++ .../nvfp4_conv3d_ndhwc_fp16out.cuh | 47 ++ csrc/minimax_remover_extra_bindings.cpp | 95 ++++ docs/minimax_remover_usage.md | 96 +++- examples/minimax_remover_quickstart.py | 11 + flash_rt/models/minimax_remover/_vae_nvfp4.py | 499 ++++++++++++++++++ 9 files changed, 1723 insertions(+), 16 deletions(-) create mode 100644 csrc/kernels/minimax_remover/fp16_quant_nvfp4_ndhwc.cu create mode 100644 csrc/kernels/minimax_remover/fp16_quant_nvfp4_ndhwc.cuh create mode 100644 csrc/kernels/minimax_remover/nvfp4_conv3d_ndhwc_fp16out.cu create mode 100644 csrc/kernels/minimax_remover/nvfp4_conv3d_ndhwc_fp16out.cuh create mode 100644 flash_rt/models/minimax_remover/_vae_nvfp4.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 0e26bfb6..6163b466 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -974,7 +974,9 @@ if(FLASHRT_ENABLE_MINIMAX_REMOVER) csrc/kernels/minimax_remover/fp16_bias_gate_residual.cu csrc/kernels/minimax_remover/fp16_ada_layernorm_quant_fp8.cu csrc/kernels/minimax_remover/fp16_rmsnorm_rope.cu - csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cu) + csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cu + csrc/kernels/minimax_remover/fp16_quant_nvfp4_ndhwc.cu + csrc/kernels/minimax_remover/nvfp4_conv3d_ndhwc_fp16out.cu) set_target_properties(flash_rt_minimax_remover PROPERTIES CUDA_STANDARD 17 LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_quant_nvfp4_ndhwc.cu b/csrc/kernels/minimax_remover/fp16_quant_nvfp4_ndhwc.cu new file mode 100644 index 00000000..a5eb85b8 --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_quant_nvfp4_ndhwc.cu @@ -0,0 +1,447 @@ +// FlashRT — MiniMax-Remover WanVAE NVFP4 fused quantization kernels (sm_120a). +// +// See fp16_quant_nvfp4_ndhwc.cuh for interface docs. +// +// Design adapted from motus_bf16_rms_silu_quant_nvfp4_sm120.cu with: +// - fp16 input (not bf16) — eliminates the fp16→bf16 cast. +// - kThreadsY=6 (not 8) — supports WanVAE channels 96/192/384. +// - SiLU in fp32 (not bf16-rounded) — preserves fp16 mantissa precision. +// - Single-pass quant: norm+silu+block-scale+quant in one walk over xcache, +// no intermediate buffer (lower register pressure than motus variant). + +#include "fp16_quant_nvfp4_ndhwc.cuh" + +#include +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +namespace { + +constexpr int kThreadsX = 32; +constexpr int kThreadsY = 6; +constexpr int kThreads = kThreadsX * kThreadsY; // 192 +constexpr int kWBlock = kThreadsX; // 32 +constexpr int kPadFp4 = 4; +constexpr int kMaxHalf2 = 64; // covers c_per_y up to 128 + +__device__ __forceinline__ uint8_t fp32_to_e2m1(float v) { + uint8_t sign = (v < 0.0f) ? 0x8u : 0x0u; + float a = fabsf(v); + uint8_t mag; + if (a < 0.25f) mag = 0; + else if (a < 0.75f) mag = 1; + else if (a < 1.25f) mag = 2; + else if (a < 1.75f) mag = 3; + else if (a < 2.5f) mag = 4; + else if (a < 3.5f) mag = 5; + else if (a < 5.0f) mag = 6; + else mag = 7; + return sign | mag; +} + +__device__ __forceinline__ uint8_t fp32_to_ue4m3_ceil(float v) { + if (v <= 0.0f) return 0; + if (v > 240.0f) return 0xFE; + uint32_t bits = __float_as_uint(v); + int float_exp = ((bits >> 23) & 0xFF) - 127; + uint32_t frac = bits & 0x7FFFFF; + int ue_exp = float_exp + 7; + if (ue_exp <= 0) { + float scaled = v * 512.0f; + int m = (int)ceilf(scaled); + if (m > 7) return (1 << 3) | 0; + if (m < 1) m = 1; + return (uint8_t)m; + } + if (ue_exp >= 15) return 0xFE; + int m = (int)(frac >> 20); + if (frac & 0xFFFFF) m++; + if (m >= 8) { m = 0; ue_exp++; } + if (ue_exp >= 15) return 0xFE; + return (uint8_t)((ue_exp << 3) | m); +} + +__device__ __forceinline__ float ue4m3_to_fp32(uint8_t v) { + int e = (v >> 3) & 0xF; + int m = v & 0x7; + if (e == 0) return ldexpf((float)m / 8.0f, -6); + return ldexpf(1.0f + (float)m / 8.0f, e - 7); +} + +__device__ __forceinline__ float silu_f32(float x) { + return x * (1.0f / (1.0f + __expf(-x))); +} + +template +__global__ void quant_nvfp4_kernel( + const __half* __restrict__ x, + const __half* __restrict__ gamma, + const __half* __restrict__ bias, + uint8_t* __restrict__ y_fp4, + uint8_t* __restrict__ y_sf, + int B, int C, int T, int H, int W, + int W_blocks_per_row, float eps) +{ + extern __shared__ __align__(16) char sm_buf[]; + // Strides must be multiples of 4 for uint32 read/write alignment. + const int sm_fp4_stride = ((C / 2) + 3) & ~3; + const int sm_sf_stride = ((C / 16) + 3) & ~3; + uint8_t* sm_fp4 = reinterpret_cast(sm_buf); + uint8_t* sm_sf = sm_fp4 + (size_t)kWBlock * sm_fp4_stride; + float* sm_red = reinterpret_cast( + sm_sf + (size_t)kWBlock * sm_sf_stride); + + const int wb = blockIdx.x % W_blocks_per_row; + const int rest = blockIdx.x / W_blocks_per_row; + const int hwt = T * H; + const int b = rest / hwt; + const int rh = rest - b * hwt; + const int t = rh / H; + const int h = rh - t * H; + if (b >= B) return; + + const int w_start = wb * kWBlock; + const int tx = threadIdx.x & 31; + const int ty = threadIdx.x >> 5; + const int my_w = w_start + tx; + const bool active = (my_w < W); + + const int c_per_y = (C + kThreadsY - 1) / kThreadsY; + const int my_c_start = ty * c_per_y; + const int my_c_end = min(my_c_start + c_per_y, C); + const int my_n_c = my_c_end - my_c_start; + const int my_n_pair = (my_n_c + 1) >> 1; + + const long long stride_C = (long long)T * H * W; + const long long row_off = (long long)t * H * W + (long long)h * W; + const long long b_off = (long long)b * (long long)C * stride_C; + // Channels-last: physical NDHWC, channel is innermost (coalesced) + const long long cl_row = ((long long)t * H + h) * W * C + (long long)my_w * C; + const long long cl_b_off = (long long)b * stride_C * C; + + // ── Pass 1: read x → register cache (half2) + sum_sq for RMS ── + __half2 xcache[kMaxHalf2]; + float sum_sq = 0.f; + if (active) { + #pragma unroll 1 + for (int p = 0; p < my_n_pair; ++p) { + int c0 = my_c_start + (p << 1); + int c1 = c0 + 1; + __half v0, v1; + if (kChannelsLast) { + // NDHWC physical: channel is innermost — coalesced reads + v0 = x[cl_b_off + cl_row + c0]; + v1 = (c1 < my_c_end) ? x[cl_b_off + cl_row + c1] : __float2half(0.f); + } else { + v0 = x[b_off + (long long)c0 * stride_C + row_off + my_w]; + v1 = (c1 < my_c_end) + ? x[b_off + (long long)c1 * stride_C + row_off + my_w] + : __float2half(0.f); + } + xcache[p] = __halves2half2(v0, v1); + if (kApplyNorm) { + float f0 = __half2float(v0); + float f1 = __half2float(v1); + sum_sq = fmaf(f0, f0, sum_sq); + if (c1 < my_c_end) sum_sq = fmaf(f1, f1, sum_sq); + } + } + } + + // ── RMS reduction (only when norm is applied) ── + float inv_rms = 0.f; + if (kApplyNorm) { + sm_red[ty * kThreadsX + tx] = active ? sum_sq : 0.f; + __syncthreads(); + float total = 0.f; + #pragma unroll + for (int yi = 0; yi < kThreadsY; ++yi) + total += sm_red[yi * kThreadsX + tx]; + inv_rms = active ? rsqrtf(total / static_cast(C) + eps) : 0.f; + } + + // ── Pass 2: norm(+bias)·SiLU → per-16-block FP4 quant → smem ── + if (active) { + const int blocks_per_thread = my_n_c / 16; + #pragma unroll 1 + for (int blk = 0; blk < blocks_per_thread; ++blk) { + // Compute norm+silu for this 16-element block, find max_abs + float vals[16]; + float mx = 0.f; + #pragma unroll + for (int p = 0; p < 8; ++p) { + int c0 = my_c_start + blk * 16 + p * 2; + int c1 = c0 + 1; + __half2 vp = xcache[blk * 8 + p]; + float f0, f1; + if (kApplyNorm) { + float xv0 = __half2float(__low2half(vp)); + float gv0 = __half2float(gamma[c0]); + float n0 = xv0 * inv_rms * gv0; + if (kApplyBias) n0 += __half2float(bias[c0]); + f0 = silu_f32(n0); + float xv1 = __half2float(__high2half(vp)); + float gv1 = __half2float(gamma[c1]); + float n1 = xv1 * inv_rms * gv1; + if (kApplyBias) n1 += __half2float(bias[c1]); + f1 = silu_f32(n1); + } else { + f0 = __half2float(__low2half(vp)); + f1 = __half2float(__high2half(vp)); + } + vals[p * 2] = f0; + vals[p * 2 + 1] = f1; + mx = fmaxf(mx, fmaxf(fabsf(f0), fabsf(f1))); + } + // Block scale + quantize + float sf_f = mx / 6.0f; + uint8_t sf_byte = fp32_to_ue4m3_ceil(sf_f); + float sf_dec = ue4m3_to_fp32(sf_byte); + float inv_sf = (sf_dec > 0.f) ? (1.0f / sf_dec) : 0.f; + int sf_idx = (my_c_start / 16) + blk; + sm_sf[tx * sm_sf_stride + sf_idx] = sf_byte; + #pragma unroll + for (int p = 0; p < 8; ++p) { + uint8_t lo = fp32_to_e2m1(vals[p * 2] * inv_sf); + uint8_t hi = fp32_to_e2m1(vals[p * 2 + 1] * inv_sf); + int byte_idx = (my_c_start / 2) + (blk * 8) + p; + sm_fp4[tx * sm_fp4_stride + byte_idx] = (hi << 4) | (lo & 0xF); + } + } + } + __syncthreads(); + + // ── Pass 3: coalesced uint32 global writes ── + const long long y_base_fp4 = ((long long)b * T * H * W + + (long long)t * H * W + + (long long)h * W + + w_start) * (long long)(C / 2); + const long long y_base_sf = ((long long)b * T * H * W + + (long long)t * H * W + + (long long)h * W + + w_start) * (long long)(C / 16); + + const int fp4_words_per_row = C / 8; + const int fp4_total_words = kWBlock * fp4_words_per_row; + const int tid = threadIdx.x; + #pragma unroll 1 + for (int idx = tid; idx < fp4_total_words; idx += kThreads) { + int w_off = idx / fp4_words_per_row; + int wd = idx - w_off * fp4_words_per_row; + if (w_start + w_off < W) { + uint32_t pack = *reinterpret_cast( + &sm_fp4[w_off * sm_fp4_stride + (wd << 2)]); + *reinterpret_cast( + &y_fp4[y_base_fp4 + (long long)w_off * (long long)(C / 2) + + (long long)(wd << 2)]) = pack; + } + } + { + const int sf_bytes_per_row = C / 16; + const int sf_words_per_row = sf_bytes_per_row / 4; + const int sf_remainder = sf_bytes_per_row & 3; + // uint32-aligned portion + if (sf_words_per_row > 0) { + const int sf_total_words = kWBlock * sf_words_per_row; + #pragma unroll 1 + for (int idx = tid; idx < sf_total_words; idx += kThreads) { + int w_off = idx / sf_words_per_row; + int wd = idx - w_off * sf_words_per_row; + if (w_start + w_off < W) { + uint32_t pack = *reinterpret_cast( + &sm_sf[w_off * sm_sf_stride + (wd << 2)]); + *reinterpret_cast( + &y_sf[y_base_sf + (long long)w_off * (long long)sf_bytes_per_row + + (long long)(wd << 2)]) = pack; + } + } + } + // Scalar remainder (handles C/16 not divisible by 4, e.g. C=96 → 6 bytes) + if (sf_remainder > 0) { + const int base = sf_words_per_row * 4; + const int rem_total = kWBlock * sf_remainder; + #pragma unroll 1 + for (int idx = tid; idx < rem_total; idx += kThreads) { + int w_off = idx / sf_remainder; + int bd = idx - w_off * sf_remainder; + if (w_start + w_off < W) { + y_sf[y_base_sf + (long long)w_off * (long long)sf_bytes_per_row + + (long long)base + bd] = + sm_sf[w_off * sm_sf_stride + base + bd]; + } + } + } + } +} + +inline int validate_and_smem(int B, int C, int T, int H, int W, size_t* smem) { + if (B <= 0 || C <= 0 || T <= 0 || H <= 0 || W <= 0) return -1; + if ((C % 96) != 0) return -2; + if (C > 768) return -3; + if ((C / 16) < 4) return -4; + *smem = (size_t)kWBlock * (((C / 2) + 3) & ~3) + + (size_t)kWBlock * (((C / 16) + 3) & ~3) + + (size_t)kThreadsX * kThreadsY * 4; + return 0; +} + +} // namespace + +extern "C" int fp16_rms_silu_quant_nvfp4_ndhwc( + const void* x_fp16, + const void* gamma_fp16, + const void* bias_fp16, + void* y_fp4, + void* y_sf, + int B, int C, int T, int H, int W, + float eps, cudaStream_t stream) +{ + size_t smem; + int rc = validate_and_smem(B, C, T, H, W, &smem); + if (rc != 0) return rc; + + const int W_blocks = (W + kWBlock - 1) / kWBlock; + const long long n_ctas = (long long)B * T * H * (long long)W_blocks; + if (n_ctas > (long long)INT32_MAX) return -5; + + dim3 grid(static_cast(n_ctas)); + dim3 block(kThreads); + + if (bias_fp16) { + quant_nvfp4_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast(gamma_fp16), + reinterpret_cast(bias_fp16), + reinterpret_cast(y_fp4), + reinterpret_cast(y_sf), + B, C, T, H, W, W_blocks, eps); + } else { + quant_nvfp4_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast(gamma_fp16), + nullptr, + reinterpret_cast(y_fp4), + reinterpret_cast(y_sf), + B, C, T, H, W, W_blocks, eps); + } + cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "[fp16_rms_silu_quant_nvfp4] launch err: %s\n", + cudaGetErrorString(e)); + return -10; + } + return 0; +} + +extern "C" int fp16_quant_nvfp4_ndhwc( + const void* x_fp16, + void* y_fp4, + void* y_sf, + int B, int C, int T, int H, int W, + cudaStream_t stream) +{ + size_t smem; + int rc = validate_and_smem(B, C, T, H, W, &smem); + if (rc != 0) return rc; + + const int W_blocks = (W + kWBlock - 1) / kWBlock; + const long long n_ctas = (long long)B * T * H * (long long)W_blocks; + if (n_ctas > (long long)INT32_MAX) return -5; + + dim3 grid(static_cast(n_ctas)); + dim3 block(kThreads); + + quant_nvfp4_kernel<<>>( + reinterpret_cast(x_fp16), + nullptr, nullptr, + reinterpret_cast(y_fp4), + reinterpret_cast(y_sf), + B, C, T, H, W, W_blocks, 0.f); + + cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "[fp16_quant_nvfp4] launch err: %s\n", + cudaGetErrorString(e)); + return -10; + } + return 0; +} + +// ── Channels-last input variants (eliminates contiguous() copy) ── + +extern "C" int fp16_rms_silu_quant_nvfp4_cl_ndhwc( + const void* x_fp16, // channels-last 3D [B,C,T,H,W] physical NDHWC + const void* gamma_fp16, + const void* bias_fp16, + void* y_fp4, + void* y_sf, + int B, int C, int T, int H, int W, + float eps, cudaStream_t stream) +{ + size_t smem; + int rc = validate_and_smem(B, C, T, H, W, &smem); + if (rc != 0) return rc; + const int W_blocks = (W + kWBlock - 1) / kWBlock; + const long long n_ctas = (long long)B * T * H * (long long)W_blocks; + if (n_ctas > (long long)INT32_MAX) return -5; + dim3 grid(static_cast(n_ctas)); + dim3 block(kThreads); + if (bias_fp16) { + quant_nvfp4_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast(gamma_fp16), + reinterpret_cast(bias_fp16), + reinterpret_cast(y_fp4), reinterpret_cast(y_sf), + B, C, T, H, W, W_blocks, eps); + } else { + quant_nvfp4_kernel<<>>( + reinterpret_cast(x_fp16), + reinterpret_cast(gamma_fp16), nullptr, + reinterpret_cast(y_fp4), reinterpret_cast(y_sf), + B, C, T, H, W, W_blocks, eps); + } + cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { std::fprintf(stderr, "[rms_silu_quant_cl] err: %s\n", cudaGetErrorString(e)); return -10; } + return 0; +} + +extern "C" int fp16_quant_nvfp4_cl_ndhwc( + const void* x_fp16, // channels-last 3D [B,C,T,H,W] physical NDHWC + void* y_fp4, + void* y_sf, + int B, int C, int T, int H, int W, + cudaStream_t stream) +{ + size_t smem; + int rc = validate_and_smem(B, C, T, H, W, &smem); + if (rc != 0) return rc; + + const int W_blocks = (W + kWBlock - 1) / kWBlock; + const long long n_ctas = (long long)B * T * H * (long long)W_blocks; + if (n_ctas > (long long)INT32_MAX) return -5; + + dim3 grid(static_cast(n_ctas)); + dim3 block(kThreads); + + quant_nvfp4_kernel<<>>( + reinterpret_cast(x_fp16), + nullptr, nullptr, + reinterpret_cast(y_fp4), + reinterpret_cast(y_sf), + B, C, T, H, W, W_blocks, 0.f); + + cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "[fp16_quant_nvfp4_cl] launch err: %s\n", + cudaGetErrorString(e)); + return -10; + } + return 0; +} + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/fp16_quant_nvfp4_ndhwc.cuh b/csrc/kernels/minimax_remover/fp16_quant_nvfp4_ndhwc.cuh new file mode 100644 index 00000000..c0db0708 --- /dev/null +++ b/csrc/kernels/minimax_remover/fp16_quant_nvfp4_ndhwc.cuh @@ -0,0 +1,76 @@ +// FlashRT — MiniMax-Remover WanVAE NVFP4 fused quantization kernels. +// +// Two kernels: +// 1. fp16_rms_silu_quant_nvfp4_ndhwc: +// Fused RMS_norm + SiLU + NVFP4 quantization. +// fp16 NCDHW [B,C,T,H,W] → FP4 packed [B,T,H,W,C/2] + UE4M3 SF [B,T,H,W,C/16] +// Eliminates 3 separate passes (norm, silu, quant) into one kernel. +// +// 2. fp16_quant_nvfp4_ndhwc: +// Plain NVFP4 quantization (no norm/silu). +// fp16 NCDHW [B,C,T,H,W] → FP4 packed + UE4M3 SF (NDHWC layout). +// Used for causal-conv cache quantization. +// +// Output format matches motus_fp4_conv3d_v19sfb kernel input requirements: +// - FP4 data: [B,T,H,W, C/2] uint8 (2 e2m1 values packed per byte) +// - SF data: [B,T,H,W, C/16] uint8 (UE4M3 block scale, 1 per 16 elements) +// +// Thread layout: kThreadsX=32 (one per W position), kThreadsY=6 +// (chosen so C/6 is always a multiple of 16 for WanVAE channels 96/192/384: +// 96/6=16, 192/6=32, 384/6=64 — all clean SF-block multiples). +// +// Precision: RMS statistics accumulated in fp32 (bit-exact with WanRMS_norm). +// SiLU computed in fp32 (NOT rounded to bf16 like the motus variant — preserves +// fp16's 10-bit mantissa through the activation). FP4 quantization uses +// per-16-element UE4M3 block scales (same as NVFP4 hardware format). +#pragma once + +#include +#include +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +// Fused: fp16 NCDHW → RMS_norm(+bias) → SiLU → NVFP4 quant (NDHWC out). +// Returns 0 on success, negative on error. +extern "C" int fp16_rms_silu_quant_nvfp4_ndhwc( + const void* x_fp16, // [B,C,T,H,W] __half + const void* gamma_fp16, // [C] __half + const void* bias_fp16, // [C] __half, or nullptr + void* y_fp4, // [B,T,H,W, C/2] uint8 packed + void* y_sf, // [B,T,H,W, C/16] uint8 UE4M3 + int B, int C, int T, int H, int W, + float eps, cudaStream_t stream); + +// Plain: fp16 NCDHW → NVFP4 quant (NDHWC out). No norm/silu. +extern "C" int fp16_quant_nvfp4_ndhwc( + const void* x_fp16, // [B,C,T,H,W] __half NCDHW contiguous + void* y_fp4, // [B,T,H,W, C/2] uint8 packed + void* y_sf, // [B,T,H,W, C/16] uint8 UE4M3 + int B, int C, int T, int H, int W, + cudaStream_t stream); + +// Channels-last variant: fp16 channels-last 3D → NVFP4 quant (NDHWC out). +// Eliminates the contiguous() copy when input is already channels-last. +extern "C" int fp16_quant_nvfp4_cl_ndhwc( + const void* x_fp16, // [B,C,T,H,W] __half channels-last 3D + void* y_fp4, // [B,T,H,W, C/2] uint8 packed + void* y_sf, // [B,T,H,W, C/16] uint8 UE4M3 + int B, int C, int T, int H, int W, + cudaStream_t stream); + +// Fused RMS+SiLU+NVFP4 quant, channels-last input. +extern "C" int fp16_rms_silu_quant_nvfp4_cl_ndhwc( + const void* x_fp16, // [B,C,T,H,W] __half channels-last 3D + const void* gamma_fp16, // [C] __half + const void* bias_fp16, // [C] __half, or nullptr + void* y_fp4, // [B,T,H,W, C/2] uint8 packed + void* y_sf, // [B,T,H,W, C/16] uint8 UE4M3 + int B, int C, int T, int H, int W, + float eps, cudaStream_t stream); + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/nvfp4_conv3d_ndhwc_fp16out.cu b/csrc/kernels/minimax_remover/nvfp4_conv3d_ndhwc_fp16out.cu new file mode 100644 index 00000000..002eb9ed --- /dev/null +++ b/csrc/kernels/minimax_remover/nvfp4_conv3d_ndhwc_fp16out.cu @@ -0,0 +1,464 @@ +// FlashRT — MiniMax-Remover WanVAE NVFP4 conv3d fprop, sm_120a. +// See nvfp4_conv3d_ndhwc_fp16out.cuh for interface docs. +// +// Adapted from motus_fp4_conv3d_v19sfb_sm120.cu. The compute path (MMA, +// im2col, cp.async pipeline, SF loading) is byte-identical to the motus +// kernel. Only the epilogue changes: fp16 NDHWC output + fp16 bias. +// This eliminates two conversion passes per layer (bf16→fp16 + NCDHW→NDHWC). + +#include "nvfp4_conv3d_ndhwc_fp16out.cuh" +#include +#include +#include +#include +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +namespace { + +constexpr int BLOCK_M = 128; +constexpr int BLOCK_N = 128; +constexpr int BLOCK_K = 64; // FP4 MMA k=64 +constexpr int N_ATOMS = BLOCK_N / 8; // 16 +constexpr int N_GROUPS = N_ATOMS / 4; // 4 +constexpr int NUM_WARPS = 8; +constexpr int THREADS = NUM_WARPS * 32; // 256 +constexpr int STAGES = 2; +constexpr int SMEM_K_STRIDE = 48; // 64/2=32 bytes data + 16 pad (bank conflicts) +constexpr int SF_K_PER_ROW = 4; // 4 UE4M3 bytes per K-tile (1 per 16 elem) + +// ── NVFP4 MMA: m16n8k64 e2m1 × e2m1, block-scaled ── +// Does 4 N-atoms MMAs (scale_vec::4X) with shared A + SFA, varying B + SFB. +__device__ __forceinline__ +void mma_m16n8k64_e2m1_4x( + float &d0, float &d1, float &d2, float &d3, + float &d4, float &d5, float &d6, float &d7, + float &d8, float &d9, float &d10, float &d11, + float &d12, float &d13, float &d14, float &d15, + uint32_t a0, uint32_t a1, uint32_t a2, uint32_t a3, + uint32_t b0, uint32_t b1, uint32_t b2, uint32_t b3, + uint32_t b4, uint32_t b5, uint32_t b6, uint32_t b7, + uint32_t sfa, uint32_t sfb) +{ + constexpr uint16_t bidA = 0, tidA = 0; + constexpr uint16_t tidB0 = 0, tidB1 = 1, tidB2 = 2, tidB3 = 3; + // 4 calls, each m16n8k64, sharing A+SFA, varying B+SFB fragments. + // D layout: {d0,d1,d2,d3} = atom0, {d4,d5,d6,d7} = atom1, etc. + asm volatile( + "mma.sync.aligned.kind::mxf4nvf4.block_scale.scale_vec::4X.m16n8k64" + ".row.col.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3},{%4,%5,%6,%7},{%8,%9},{%10,%11,%12,%13}," + "{%14},{%15,%16},{%17},{%18,%19};\n" + : "+f"(d0), "+f"(d1), "+f"(d8), "+f"(d9) + : "r"(a0), "r"(a1), "r"(a2), "r"(a3), "r"(b0), "r"(b1), + "f"(d0), "f"(d1), "f"(d8), "f"(d9), + "r"(sfa), "h"(bidA), "h"(tidA), + "r"(sfb), "h"(bidA), "h"(tidB0)); + asm volatile( + "mma.sync.aligned.kind::mxf4nvf4.block_scale.scale_vec::4X.m16n8k64" + ".row.col.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3},{%4,%5,%6,%7},{%8,%9},{%10,%11,%12,%13}," + "{%14},{%15,%16},{%17},{%18,%19};\n" + : "+f"(d2), "+f"(d3), "+f"(d10), "+f"(d11) + : "r"(a0), "r"(a1), "r"(a2), "r"(a3), "r"(b2), "r"(b3), + "f"(d2), "f"(d3), "f"(d10), "f"(d11), + "r"(sfa), "h"(bidA), "h"(tidA), + "r"(sfb), "h"(bidA), "h"(tidB1)); + asm volatile( + "mma.sync.aligned.kind::mxf4nvf4.block_scale.scale_vec::4X.m16n8k64" + ".row.col.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3},{%4,%5,%6,%7},{%8,%9},{%10,%11,%12,%13}," + "{%14},{%15,%16},{%17},{%18,%19};\n" + : "+f"(d4), "+f"(d5), "+f"(d12), "+f"(d13) + : "r"(a0), "r"(a1), "r"(a2), "r"(a3), "r"(b4), "r"(b5), + "f"(d4), "f"(d5), "f"(d12), "f"(d13), + "r"(sfa), "h"(bidA), "h"(tidA), + "r"(sfb), "h"(bidA), "h"(tidB2)); + asm volatile( + "mma.sync.aligned.kind::mxf4nvf4.block_scale.scale_vec::4X.m16n8k64" + ".row.col.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3},{%4,%5,%6,%7},{%8,%9},{%10,%11,%12,%13}," + "{%14},{%15,%16},{%17},{%18,%19};\n" + : "+f"(d6), "+f"(d7), "+f"(d14), "+f"(d15) + : "r"(a0), "r"(a1), "r"(a2), "r"(a3), "r"(b6), "r"(b7), + "f"(d6), "f"(d7), "f"(d14), "f"(d15), + "r"(sfa), "h"(bidA), "h"(tidA), + "r"(sfb), "h"(bidA), "h"(tidB3)); +} + +// ── im2col address helpers (FP4 packed, same as motus) ── + +__device__ __forceinline__ +const uint8_t* x_fp4_ptr(const uint8_t* cache_x, const uint8_t* new_x, + int m_global, int k_global, + int N, int T_cache, int T_new, int H, int W, int Ci) { + int K_total = 27 * Ci; + int M_total = N * T_new * H * W; + if (k_global >= K_total || m_global >= M_total) return nullptr; + int spatial = T_new * H * W; + int b_idx = m_global / spatial; + int rem = m_global - b_idx * spatial; + int t_out = rem / (H * W); + rem -= t_out * (H * W); + int h_out = rem / W; + int w_out = rem - h_out * W; + int q = k_global / Ci; + int ci0 = k_global % Ci; + int ks = q % 3; q /= 3; + int kr = q % 3; + int kt = q / 3; + int d_in = t_out + kt; + int h_in = h_out + kr - 1; + int w_in = w_out + ks - 1; + if (h_in < 0 || h_in >= H || w_in < 0 || w_in >= W) return nullptr; + if (d_in < T_cache) { + int idx_elem = (((b_idx * T_cache + d_in) * H + h_in) * W + w_in) * Ci + ci0; + return cache_x + (idx_elem >> 1); + } else { + int d_new = d_in - T_cache; + int idx_elem = (((b_idx * T_new + d_new) * H + h_in) * W + w_in) * Ci + ci0; + return new_x + (idx_elem >> 1); + } +} + +__device__ __forceinline__ +const uint8_t* w_fp4_ptr(const uint8_t* w, int co, int k_global, int Co, int Ci) { + int K_total = 27 * Ci; + if (co >= Co || k_global >= K_total) return nullptr; + int q = k_global / Ci; + int ci0 = k_global % Ci; + int ks = q % 3; q /= 3; + int kr = q % 3; + int kt = q / 3; + int idx_elem = (((co * 3 + kt) * 3 + kr) * 3 + ks) * Ci + ci0; + return w + (idx_elem >> 1); +} + +__device__ __forceinline__ +const uint8_t* sfa_ptr(const uint8_t* cache_sfa, const uint8_t* new_sfa, + int m_global, int k_base, + int N, int T_cache, int T_new, int H, int W, int Ci) { + int Ci_blk = Ci >> 4; + int M_total = N * T_new * H * W; + if (m_global >= M_total) return nullptr; + int spatial = T_new * H * W; + int b_idx = m_global / spatial; + int rem = m_global - b_idx * spatial; + int t_out = rem / (H * W); + rem -= t_out * (H * W); + int h_out = rem / W; + int w_out = rem - h_out * W; + int kt_iter = k_base / Ci; + int ci_block_off = (k_base % Ci) >> 4; + int ks = kt_iter % 3; + int kr = (kt_iter / 3) % 3; + int kt = kt_iter / 9; + int d_in = t_out + kt; + int h_in = h_out + kr - 1; + int w_in = w_out + ks - 1; + if (h_in < 0 || h_in >= H || w_in < 0 || w_in >= W) return nullptr; + if (d_in < T_cache) { + int idx = (((b_idx * T_cache + d_in) * H + h_in) * W + w_in) * Ci_blk + ci_block_off; + return cache_sfa + idx; + } else { + int d_new = d_in - T_cache; + int idx = (((b_idx * T_new + d_new) * H + h_in) * W + w_in) * Ci_blk + ci_block_off; + return new_sfa + idx; + } +} + +__device__ __forceinline__ +const uint8_t* sfb_ptr(const uint8_t* w_sfb, int co, int k_base, int Co, int Ci) { + int Ci_blk = Ci >> 4; + if (co >= Co) return nullptr; + int kt_iter = k_base / Ci; + int ci_block_off = (k_base % Ci) >> 4; + int ks = kt_iter % 3; + int kr = (kt_iter / 3) % 3; + int kt = kt_iter / 9; + int idx = (((co * 3 + kt) * 3 + kr) * 3 + ks) * Ci_blk + ci_block_off; + return w_sfb + idx; +} + +__device__ __forceinline__ +void cp_async_16(uint32_t smem_int, const uint8_t* src) { + int src_bytes = (src == nullptr) ? 0 : 16; + asm volatile("cp.async.ca.shared.global [%0], [%1], 16, %2;\n" + :: "r"(smem_int), "l"(src), "r"(src_bytes)); +} +__device__ __forceinline__ +void cp_async_4(uint32_t smem_int, const uint8_t* src) { + int src_bytes = (src == nullptr) ? 0 : 4; + asm volatile("cp.async.ca.shared.global [%0], [%1], 4, %2;\n" + :: "r"(smem_int), "l"(src), "r"(src_bytes)); +} +__device__ __forceinline__ +uint32_t to_smem_int(const void* p) { + return static_cast(__cvta_generic_to_shared(p)); +} + +// ── Main kernel ── +__global__ void __launch_bounds__(THREADS, 2) +nvfp4_conv3d_kernel( + const uint8_t* __restrict__ cache_x, + const uint8_t* __restrict__ new_x, + const uint8_t* __restrict__ w, + const uint8_t* __restrict__ cache_sfa, + const uint8_t* __restrict__ new_sfa, + const uint8_t* __restrict__ w_sfb, + __half* __restrict__ y, // fp16 NDHWC output + const __half* __restrict__ bias, // fp16 per-channel + int N, int T_cache, int T_new, int H, int W, int Ci, int Co, + float alpha, + int M_tiles, int N_tiles) +{ + __shared__ __align__(16) uint8_t A_smem [STAGES][BLOCK_M * SMEM_K_STRIDE]; + __shared__ __align__(16) uint8_t B_smem [STAGES][BLOCK_N * SMEM_K_STRIDE]; + __shared__ __align__(16) uint8_t A_sf_smem[STAGES][BLOCK_M * SF_K_PER_ROW]; + __shared__ __align__(16) uint8_t B_sf_smem[STAGES][BLOCK_N * SF_K_PER_ROW]; + + const int t = threadIdx.x; + const int warp_id = t / 32; + const int lane = t % 32; + const int l = lane % 4; + const int h = lane / 4; + + const int M_total = N * T_new * H * W; + const int K_total = 27 * Ci; + + const int ld_row_a = t / 2; + const int ld_k_off_a = (t & 1) * 32; // 32 FP4 elements = 16 bytes + const int ld_row_b = t / 2; + const int ld_k_off_b = (t & 1) * 32; + + int tile_idx = blockIdx.x; + int m_idx = tile_idx / N_tiles; + int n_idx = tile_idx % N_tiles; + int m_base = m_idx * BLOCK_M; + int co_base = n_idx * BLOCK_N; + if (m_base >= M_total || co_base >= Co) return; + + float dA[N_ATOMS] = {0}; + float dB[N_ATOMS] = {0}; + float dC[N_ATOMS] = {0}; + float dD[N_ATOMS] = {0}; + + auto issue_load = [&](int stage, int k_base) { + // FP4 A + { + const uint8_t* src = x_fp4_ptr(cache_x, new_x, + m_base + ld_row_a, + k_base + ld_k_off_a, + N, T_cache, T_new, H, W, Ci); + uint32_t smem_int = to_smem_int( + &A_smem[stage][ld_row_a * SMEM_K_STRIDE + (ld_k_off_a >> 1)]); + cp_async_16(smem_int, src); + } + // FP4 B + { + const uint8_t* src = w_fp4_ptr(w, co_base + ld_row_b, + k_base + ld_k_off_b, Co, Ci); + uint32_t smem_int = to_smem_int( + &B_smem[stage][ld_row_b * SMEM_K_STRIDE + (ld_k_off_b >> 1)]); + cp_async_16(smem_int, src); + } + // SF: first 128 threads load A_sf, next 128 load B_sf + if (t < BLOCK_M) { + const uint8_t* src = sfa_ptr(cache_sfa, new_sfa, + m_base + t, k_base, + N, T_cache, T_new, H, W, Ci); + uint32_t smem_int = to_smem_int(&A_sf_smem[stage][t * SF_K_PER_ROW]); + cp_async_4(smem_int, src); + } else { + int n_idx_t = t - BLOCK_M; + const uint8_t* src = sfb_ptr(w_sfb, co_base + n_idx_t, k_base, Co, Ci); + uint32_t smem_int = to_smem_int(&B_sf_smem[stage][n_idx_t * SF_K_PER_ROW]); + cp_async_4(smem_int, src); + } + }; + + // Prologue + issue_load(0, 0); + asm volatile("cp.async.commit_group;\n" ::); + + int compute_stage = 0; + + for (int k_base = 0; k_base < K_total; k_base += BLOCK_K) { + int next_stage = compute_stage ^ 1; + int k_next = k_base + BLOCK_K; + if (k_next < K_total) issue_load(next_stage, k_next); + asm volatile("cp.async.commit_group;\n" ::); + asm volatile("cp.async.wait_group 1;\n" ::); + __syncthreads(); + + const int warp_M_off = warp_id * 16; + const int kA0 = 4 * l; + const int kA2 = 4 * l + 16; + + // Load A fragments (4 uint32 per lane) + int rA0 = warp_M_off + h; + int rA1 = warp_M_off + h + 8; + uint32_t A0 = *reinterpret_cast( + &A_smem[compute_stage][rA0 * SMEM_K_STRIDE + kA0]); + uint32_t A1 = *reinterpret_cast( + &A_smem[compute_stage][rA1 * SMEM_K_STRIDE + kA0]); + uint32_t A2 = *reinterpret_cast( + &A_smem[compute_stage][rA0 * SMEM_K_STRIDE + kA2]); + uint32_t A3 = *reinterpret_cast( + &A_smem[compute_stage][rA1 * SMEM_K_STRIDE + kA2]); + + // SFA: 4 UE4M3 bytes packed in 1 uint32 for this lane's M-row + int sfa_m_row; + if ((lane & 3) == 1) { + sfa_m_row = warp_M_off + (lane >> 2) + 8; + } else { + sfa_m_row = warp_M_off + (lane >> 2); + } + uint32_t SFA = *reinterpret_cast( + &A_sf_smem[compute_stage][sfa_m_row * SF_K_PER_ROW]); + + // 4 N-groups, each does 4 atoms (scale_vec::4X) + #pragma unroll + for (int g = 0; g < N_GROUPS; ++g) { + int base = g * 4; + int co0 = (base + 0) * 8 + h; + int co1 = (base + 1) * 8 + h; + int co2 = (base + 2) * 8 + h; + int co3 = (base + 3) * 8 + h; + uint32_t B0 = *reinterpret_cast( + &B_smem[compute_stage][co0 * SMEM_K_STRIDE + kA0]); + uint32_t B1 = *reinterpret_cast( + &B_smem[compute_stage][co0 * SMEM_K_STRIDE + kA2]); + uint32_t B2 = *reinterpret_cast( + &B_smem[compute_stage][co1 * SMEM_K_STRIDE + kA0]); + uint32_t B3 = *reinterpret_cast( + &B_smem[compute_stage][co1 * SMEM_K_STRIDE + kA2]); + uint32_t B4 = *reinterpret_cast( + &B_smem[compute_stage][co2 * SMEM_K_STRIDE + kA0]); + uint32_t B5 = *reinterpret_cast( + &B_smem[compute_stage][co2 * SMEM_K_STRIDE + kA2]); + uint32_t B6 = *reinterpret_cast( + &B_smem[compute_stage][co3 * SMEM_K_STRIDE + kA0]); + uint32_t B7 = *reinterpret_cast( + &B_smem[compute_stage][co3 * SMEM_K_STRIDE + kA2]); + + int sfb_n = g * 32 + l * 8 + h; + uint32_t SFB = *reinterpret_cast( + &B_sf_smem[compute_stage][sfb_n * SF_K_PER_ROW]); + + mma_m16n8k64_e2m1_4x( + dA[base+0], dB[base+0], dA[base+1], dB[base+1], + dA[base+2], dB[base+2], dA[base+3], dB[base+3], + dC[base+0], dD[base+0], dC[base+1], dD[base+1], + dC[base+2], dD[base+2], dC[base+3], dD[base+3], + A0, A1, A2, A3, + B0, B1, B2, B3, B4, B5, B6, B7, + SFA, SFB); + } + compute_stage = next_stage; + } + asm volatile("cp.async.wait_all;\n" ::); + + // ── Epilogue: fp16 NDHWC output ── + // y[b, t_out, h_out, w_out, co] at offset row * Co + co + // (row = m_global = b*T_new*H*W + t_out*H*W + h_out*W + w_out) + const int warp_M_off = warp_id * 16; + #pragma unroll + for (int n_atom = 0; n_atom < N_ATOMS; ++n_atom) { + int co_pair = co_base + n_atom * 8 + 2 * l; + int row0 = m_base + warp_M_off + h; + int row1 = m_base + warp_M_off + h + 8; + + float b0 = 0.f, b1 = 0.f; + if (bias != nullptr && co_pair < Co) { + b0 = __half2float(bias[co_pair]); + if (co_pair + 1 < Co) b1 = __half2float(bias[co_pair + 1]); + } + + if (co_pair + 1 < Co) { + __half2 packAB, packCD; + packAB.x = __float2half_rn(dA[n_atom] * alpha + b0); + packAB.y = __float2half_rn(dB[n_atom] * alpha + b1); + packCD.x = __float2half_rn(dC[n_atom] * alpha + b0); + packCD.y = __float2half_rn(dD[n_atom] * alpha + b1); + if (row0 < M_total) { + *reinterpret_cast<__half2*>(&y[row0 * Co + co_pair]) = packAB; + } + if (row1 < M_total) { + *reinterpret_cast<__half2*>(&y[row1 * Co + co_pair]) = packCD; + } + } else { + auto store = [&](int row, int co, float v, float bv) { + if (row < M_total && co < Co) { + y[row * Co + co] = __float2half_rn(v * alpha + bv); + } + }; + store(row0, co_pair + 0, dA[n_atom], b0); + store(row0, co_pair + 1, dB[n_atom], b1); + store(row1, co_pair + 0, dC[n_atom], b0); + store(row1, co_pair + 1, dD[n_atom], b1); + } + } +} + +} // namespace + +extern "C" int nvfp4_conv3d_ndhwc_fp16out( + const void* cache_x_fp4, + const void* new_x_fp4, + const void* w_fp4, + const void* cache_sfa, + const void* new_sfa, + const void* w_sfb, + void* y_fp16, + const void* bias_fp16, + int N, int T_cache, int T_new, int H, int W, int Ci, int Co, + float alpha, cudaStream_t stream) +{ + if (Ci % BLOCK_K != 0) { + std::fprintf(stderr, + "[nvfp4_conv3d] Ci%%%d (got %d) — must be multiple of 64\n", + BLOCK_K, Ci); + return -1; + } + if (Co % 8 != 0) { + std::fprintf(stderr, "[nvfp4_conv3d] Co%%8 (got %d)\n", Co); + return -2; + } + if (T_cache != 2) { + std::fprintf(stderr, "[nvfp4_conv3d] T_cache must be 2 (got %d)\n", T_cache); + return -3; + } + int M = N * T_new * H * W; + int M_tiles = (M + BLOCK_M - 1) / BLOCK_M; + int N_tiles = (Co + BLOCK_N - 1) / BLOCK_N; + int total_tiles = M_tiles * N_tiles; + + dim3 grid(total_tiles); + dim3 block(THREADS); + nvfp4_conv3d_kernel<<>>( + reinterpret_cast(cache_x_fp4), + reinterpret_cast(new_x_fp4), + reinterpret_cast(w_fp4), + reinterpret_cast(cache_sfa), + reinterpret_cast(new_sfa), + reinterpret_cast(w_sfb), + reinterpret_cast<__half*>(y_fp16), + reinterpret_cast(bias_fp16), + N, T_cache, T_new, H, W, Ci, Co, alpha, + M_tiles, N_tiles); + cudaError_t e = cudaGetLastError(); + if (e != cudaSuccess) { + std::fprintf(stderr, "[nvfp4_conv3d] launch err: %s\n", + cudaGetErrorString(e)); + return -10; + } + return 0; +} + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/kernels/minimax_remover/nvfp4_conv3d_ndhwc_fp16out.cuh b/csrc/kernels/minimax_remover/nvfp4_conv3d_ndhwc_fp16out.cuh new file mode 100644 index 00000000..d7e929e0 --- /dev/null +++ b/csrc/kernels/minimax_remover/nvfp4_conv3d_ndhwc_fp16out.cuh @@ -0,0 +1,47 @@ +// FlashRT — MiniMax-Remover WanVAE NVFP4 conv3d fprop, sm_120a. +// +// Purpose-built NVFP4 (W4A4) implicit-GEMM conv3d for the WanVAE. +// Adapted from motus_fp4_conv3d_v19sfb with three WanVAE-specific optimizations: +// +// 1. **fp16 output** (not bf16): WanVAE is fp16-native; eliminates the +// bf16→fp16 conversion that cost ~0.3 ms/layer in the Python wrapper. +// +// 2. **NDHWC output** (not NCDHW): matches the channels-last 3D pipeline; +// eliminates the NCDHW→channels-last format conversion. The downstream +// conv receives data in its preferred layout with zero-copy. +// +// 3. **fp16 bias** (not bf16): matches WanCausalConv3d.bias dtype. +// +// The compute path (MMA, im2col, cp.async pipeline, SF loading) is identical +// to the proven motus kernel. Only the epilogue changes. +// +// Tile: BLOCK_M=128, BLOCK_N=128, BLOCK_K=64, 8 warps, cp.async 2-stage. +// MMA: mma.sync.kind::mxf4nvf4.block_scale.scale_vec::4X.m16n8k64 +// +// Constraints: Ci % 64 == 0, T_cache == 2, kernel = 3×3×3, pad = 1. +// (WanVAE Ci=192/384 qualify; Ci=96 stays on FP8.) +#pragma once + +#include +#include +#include + +namespace flash_rt { +namespace kernels { +namespace minimax_remover { + +extern "C" int nvfp4_conv3d_ndhwc_fp16out( + const void* cache_x_fp4, // [B,T_cache,H,W,Ci/2] uint8 packed FP4 + const void* new_x_fp4, // [B,T_new,H,W,Ci/2] uint8 packed FP4 + const void* w_fp4, // [Co,3,3,3,Ci/2] uint8 packed FP4 + const void* cache_sfa, // [B,T_cache,H,W,Ci/16] uint8 UE4M3 + const void* new_sfa, // [B,T_new,H,W,Ci/16] uint8 UE4M3 + const void* w_sfb, // [Co,3,3,3,Ci/16] uint8 UE4M3 + void* y_fp16, // [B,T_new,H,W,Co] __half NDHWC output + const void* bias_fp16, // [Co] __half, or nullptr + int N, int T_cache, int T_new, int H, int W, int Ci, int Co, + float alpha, cudaStream_t stream); + +} // namespace minimax_remover +} // namespace kernels +} // namespace flash_rt diff --git a/csrc/minimax_remover_extra_bindings.cpp b/csrc/minimax_remover_extra_bindings.cpp index 951d6311..c0707b2a 100644 --- a/csrc/minimax_remover_extra_bindings.cpp +++ b/csrc/minimax_remover_extra_bindings.cpp @@ -24,6 +24,8 @@ #include "kernels/minimax_remover/fp16_ada_layernorm_quant_fp8.cuh" #include "kernels/minimax_remover/fp16_rmsnorm_rope.cuh" #include "kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cuh" +#include "kernels/minimax_remover/fp16_quant_nvfp4_ndhwc.cuh" +#include "kernels/minimax_remover/nvfp4_conv3d_ndhwc_fp16out.cuh" namespace py = pybind11; @@ -436,4 +438,97 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { "smooth_k (subtract key mean). Eliminates fp16 intermediate. " "Output: int8 [B*S, H*Dd], scale [B, H, ceil(S/64)]. " "km_fp16 can be 0 (nullptr) to skip smooth_k."); + + // ── NVFP4 fused quantization kernels (WanVAE FP4 path) ── + + m.def("fp16_rms_silu_quant_nvfp4_ndhwc", + [](uintptr_t x_fp16, uintptr_t gamma_fp16, uintptr_t bias_fp16, + uintptr_t y_fp4, uintptr_t y_sf, + int B, int C, int T, int H, int W, + float eps, uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + fp16_rms_silu_quant_nvfp4_ndhwc( + to_ptr(x_fp16), to_ptr(gamma_fp16), + bias_fp16 ? to_ptr(bias_fp16) : nullptr, + to_ptr(y_fp4), to_ptr(y_sf), + B, C, T, H, W, eps, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("gamma_fp16"), py::arg("bias_fp16"), + py::arg("y_fp4"), py::arg("y_sf"), + py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), + py::arg("eps") = 1e-6f, py::arg("stream") = 0, + "Fused fp16 NCDHW → RMS_norm + SiLU + NVFP4 quant (NDHWC out). " + "Output: fp4 [B,T,H,W,C/2] uint8, sf [B,T,H,W,C/16] uint8 UE4M3. " + "Requires C%96==0 (WanVAE channels 96/192/384). " + "Eliminates 3 separate passes into one kernel."); + + m.def("fp16_quant_nvfp4_ndhwc", + [](uintptr_t x_fp16, uintptr_t y_fp4, uintptr_t y_sf, + int B, int C, int T, int H, int W, uintptr_t stream) { + return flash_rt::kernels::minimax_remover::fp16_quant_nvfp4_ndhwc( + to_ptr(x_fp16), to_ptr(y_fp4), to_ptr(y_sf), + B, C, T, H, W, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("y_fp4"), py::arg("y_sf"), + py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), + py::arg("stream") = 0, + "Plain fp16 NCDHW → NVFP4 quant (NDHWC out), no norm/silu. " + "Used for causal-conv cache quantization."); + + m.def("fp16_quant_nvfp4_cl_ndhwc", + [](uintptr_t x_fp16, uintptr_t y_fp4, uintptr_t y_sf, + int B, int C, int T, int H, int W, uintptr_t stream) { + return flash_rt::kernels::minimax_remover::fp16_quant_nvfp4_cl_ndhwc( + to_ptr(x_fp16), to_ptr(y_fp4), to_ptr(y_sf), + B, C, T, H, W, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("y_fp4"), py::arg("y_sf"), + py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), + py::arg("stream") = 0, + "fp16 channels-last 3D → NVFP4 quant (NDHWC out). " + "Eliminates contiguous() copy for channels-last inputs."); + + m.def("fp16_rms_silu_quant_nvfp4_cl_ndhwc", + [](uintptr_t x_fp16, uintptr_t gamma_fp16, uintptr_t bias_fp16, + uintptr_t y_fp4, uintptr_t y_sf, + int B, int C, int T, int H, int W, + float eps, uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + fp16_rms_silu_quant_nvfp4_cl_ndhwc( + to_ptr(x_fp16), to_ptr(gamma_fp16), + bias_fp16 ? to_ptr(bias_fp16) : nullptr, + to_ptr(y_fp4), to_ptr(y_sf), + B, C, T, H, W, eps, to_stream(stream)); + }, + py::arg("x_fp16"), py::arg("gamma_fp16"), py::arg("bias_fp16"), + py::arg("y_fp4"), py::arg("y_sf"), + py::arg("B"), py::arg("C"), py::arg("T"), py::arg("H"), py::arg("W"), + py::arg("eps") = 1e-6f, py::arg("stream") = 0, + "Fused fp16 channels-last → RMS_norm + SiLU + NVFP4 quant (NDHWC out)."); + + // ── WanVAE NVFP4 conv3d (purpose-built, fp16 NDHWC output) ── + + m.def("nvfp4_conv3d_ndhwc_fp16out", + [](uintptr_t cache_x_fp4, uintptr_t new_x_fp4, uintptr_t w_fp4, + uintptr_t cache_sfa, uintptr_t new_sfa, uintptr_t w_sfb, + uintptr_t y_fp16, uintptr_t bias_fp16, + int N, int T_cache, int T_new, int H, int W, int Ci, int Co, + float alpha, uintptr_t stream) { + return flash_rt::kernels::minimax_remover:: + nvfp4_conv3d_ndhwc_fp16out( + to_ptr(cache_x_fp4), to_ptr(new_x_fp4), to_ptr(w_fp4), + to_ptr(cache_sfa), to_ptr(new_sfa), to_ptr(w_sfb), + to_ptr(y_fp16), + bias_fp16 ? to_ptr(bias_fp16) : nullptr, + N, T_cache, T_new, H, W, Ci, Co, alpha, to_stream(stream)); + }, + py::arg("cache_x_fp4"), py::arg("new_x_fp4"), py::arg("w_fp4"), + py::arg("cache_sfa"), py::arg("new_sfa"), py::arg("w_sfb"), + py::arg("y_fp16"), py::arg("bias_fp16"), + py::arg("N"), py::arg("T_cache"), py::arg("T_new"), + py::arg("H"), py::arg("W"), py::arg("Ci"), py::arg("Co"), + py::arg("alpha") = 1.0f, py::arg("stream") = 0, + "WanVAE NVFP4 W4A4 conv3d (3x3x3 causal, fp16 NDHWC output). " + "Purpose-built: eliminates bf16→fp16 + NCDHW→NDHWC conversions. " + "Requires Ci%64==0, T_cache==2."); } diff --git a/docs/minimax_remover_usage.md b/docs/minimax_remover_usage.md index 0fffbf24..a65bc416 100644 --- a/docs/minimax_remover_usage.md +++ b/docs/minimax_remover_usage.md @@ -7,7 +7,8 @@ kernelized inference on Blackwell SM120. Two precision paths ship under | Entrypoint | Precision | Recommended use | |------------|-----------|-----------------| | `MiniMaxRemoverPipelineFP8` | FP8 (W8A8) | **Default.** Full-frame inpainting; end-to-end cosine >= 0.999, PSNR ~35-41 dB vs fp16 (see Performance). | -| `MiniMaxRemoverPipeline` | NVFP4 (W4A4) | Cropped small regions only. Full-frame large latents drift/blacken due to FP4 error accumulation. | +| `MiniMaxRemoverPipelineFP8` + NVFP4 VAE | FP8 transformer + NVFP4 VAE (W4A4) | **Default (enabled automatically).** Purpose-built NVFP4 conv3d kernel accelerates the VAE encode/decode by ~16%; PSNR ~35 dB vs fp16 (see Performance). | +| `MiniMaxRemoverPipeline` | NVFP4 (W4A4) transformer | Cropped small regions only. Full-frame large latents drift/blacken due to FP4 error accumulation in the transformer denoise loop. | Both reuse the **generic** FlashRT kernels for the transformer denoise path (quantized GEMMs, fused norm/gate/residual/gelu ops, kernel attention via @@ -18,10 +19,19 @@ FA2 / SageAttention). The VAE encode/decode is additionally accelerated by `fp16_rms_silu_ncdhw` (fused RMSNorm + SiLU), and their channels-last (NDHWC) variants `fp16_rms_norm_ndhwc` / `fp16_rms_silu_ndhwc` which keep the entire VAE pipeline in channels-last 3D memory format — -eliminating cuDNN's per-conv `nchw↔nhwc` conversion kernels. The NVFP4 path additionally -captures the N-step flow-matching loop as a single CUDA Graph; the FP8 path -is graph-compatible (stable static scales, no host sync in steady state) but -does not itself capture a graph. +eliminating cuDNN's per-conv `nchw↔nhwc` conversion kernels. The FP8 transformer +path is graph-compatible (stable static scales, no host sync in steady state) +but does not itself capture a graph. The NVFP4 transformer path additionally +captures the N-step flow-matching loop as a single CUDA Graph. + +On top of the FP8 transformer path, the **NVFP4 VAE** optimization +(`install_vae_nvfp4`) replaces eligible 3×3×3 conv3d layers in the WanVAE +with a **purpose-built NVFP4 W4A4 conv3d kernel** (`nvfp4_conv3d_ndhwc_fp16out`) +that uses the SM120 `mma.sync.kind::mxf4nvf4` FP4 MMA for 2× tensor-core +throughput. Unlike the NVFP4 transformer path (which is broken on full-frame +latents), the NVFP4 VAE path works correctly on full-frame inputs because +the VAE is a single-pass encoder/decoder (no iterative error accumulation). +A rolling 2-frame FP4 cache eliminates per-call cache re-quantization. ## Build @@ -76,6 +86,11 @@ cmake --build build -j --target flash_rt_minimax_remover | channels-last pipeline | Python (`_vae_opt.py`) | Conv3d weight → CL, WanCausalConv3d → CL-preserving forward | eliminates ~97% of nchw↔nhwc conversion kernels (~280 ms / decode) | | Running-max amax | Python (`_vae_opt.py`) | separate `amax_fp16` calls over cache + new each iteration | norm fuses amax via atomicMax into a persistent buffer shared with the sister conv; cache amax is skipped entirely (covered by running max) | | `WanUpsample` patch | Python (no kernel) | `x.float().type_as(x)` for nearest-exact upsample | eliminates redundant fp32 cast (index-only op, fp16 == fp32) | +| `nvfp4_conv3d_ndhwc_fp16out` | `flash_rt_minimax_remover` | FP8 conv3d for eligible 3×3×3 causal convs (Ci % 64 == 0) | **Purpose-built NVFP4 W4A4** implicit-GEMM conv3d using `mma.sync.kind::mxf4nvf4` (e2m1×e2m1, UE4M3 block scales). fp16 NDHWC output (eliminates bf16→fp16 + NCDHW→NDHWC conversions). 2× MMA throughput vs FP8. ~16% VAE speedup. | +| `fp16_quant_nvfp4_ndhwc` | `flash_rt_minimax_remover` | PyTorch multi-pass fp16→FP8 quant for VAE activations | Fused fp16 NCDHW → NVFP4 packed + UE4M3 block-scale (NDHWC output). Single-pass quantization with per-16-element block scales. | +| `fp16_quant_nvfp4_cl_ndhwc` | `flash_rt_minimax_remover` | same, for channels-last 3D input | Channels-last variant: reads NDHWC physical layout directly (channel is innermost → coalesced). Eliminates `contiguous()` copy. | +| `fp16_rms_silu_quant_nvfp4_cl_ndhwc` | `flash_rt_minimax_remover` | separate RMS+SiLU + fp16→FP4 quant (3 kernels) | Fused RMS_norm + SiLU + NVFP4 quantization in one kernel (channels-last input). fp32 statistics, fp32 SiLU (preserves fp16 mantissa precision). | +| Rolling 2-frame FP4 cache | Python (`_vae_nvfp4.py`) | per-call cache fp16→FP4 quantization | Stores quantized FP4+SF from the previous call; rolling 2-frame window handles T_new=1 (decode) by combining `[prev_frame, current_frame]`. Eliminates cache quantization per call. | The VAE stays fp16 at the interface. Norm/activation ops use fp16-native kernels (zero precision loss). Applicable 3×3×3 causal conv3d layers @@ -342,10 +357,11 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue | 7.56 s | 2.29x | 40.0 / 36.4 dB | 0.99981 | | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue + fused bias-gate residual | 7.57 s | 2.30x | 40.0 / 36.2 dB | 0.99981 | | tennis (70 frames, 432x240) | **… + fused adaLN+quant (shared-scale QKV) + fused RMSNorm+RoPE + fused norm2+quant (default)** | **7.18 s** | **2.41x** | **40.0 / 36.0 dB** | 0.99981 | -| tennis (70 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 9.52 s | 1.82x | 7.0 / 6.2 dB | 0.00000 (broken) | +| tennis (70 frames, 432x240) | **… + NVFP4 VAE (38 conv layers → W4A4, FP4 cache)** | **6.71 s** | **2.58x** | **34.7 / 30.6 dB** | 0.99981 | +| tennis (70 frames, 432x240) | FlashRT NVFP4 transformer (`--use-fp4`) | 9.52 s | 1.82x | 7.0 / 6.2 dB | 0.00000 (broken — transformer FP4 error accumulates over 12 denoise steps) | | bmx-trees (80 frames, 432x240) | fp16 reference (`--no-flashrt`) | 19.76 s | 1.0x | — | — | | bmx-trees (80 frames, 432x240) | FlashRT FP8 (default) | 13.24 s | **1.49x** | 35.1 / 32.0 dB | 0.99912 | -| bmx-trees (80 frames, 432x240) | FlashRT NVFP4 (`--use-fp4`) | 10.72 s | 1.84x | 7.3 / 7.0 dB | 0.00000 (broken) | +| bmx-trees (80 frames, 432x240) | FlashRT NVFP4 transformer (`--use-fp4`) | 10.72 s | 1.84x | 7.3 / 7.0 dB | 0.00000 (broken — transformer FP4 error accumulates over 12 denoise steps) | > Tennis numbers are from a same-session serial A/B (`--no-flashrt > --no-vae-opt` vs default FlashRT FP8 stack) measured on RTX 5060 Ti, @@ -381,12 +397,23 @@ Takeaways: weight dequant, fused amax, running-max scale) beats cuDNN's fp16 conv3d while staying in fp16 at the interface. Disable with `--no-fp8-conv` to recover ~1 dB PSNR if absolute precision is preferred over speed. -- **NVFP4 is faster but unusable on full-frame latents**: cosine collapses to +- **NVFP4 VAE (default ON)**: the purpose-built `nvfp4_conv3d_ndhwc_fp16out` + kernel replaces 38 eligible 3×3×3 conv3d layers (Ci ≥ 192) in the WanVAE + with NVFP4 W4A4 MMA (`mma.sync.kind::mxf4nvf4`, 2× tensor-core throughput + vs FP8). Key optimizations: (1) fp16 NDHWC output — eliminates bf16→fp16 + + NCDHW→NDHWC conversions; (2) channels-last direct input — eliminates + `contiguous()` copy; (3) rolling 2-frame FP4 cache — eliminates per-call + cache re-quantization. VAE encode 14.5% faster, decode 13.6% faster + (VAE total 3493→3002 ms, –16.4%). End-to-end 7.18→6.71 s (**2.58× vs fp16 + reference**). PSNR 34.7 dB (median) vs fp16 — «近乎一致(优秀)». Disable + with `--no-nvfp4-vae`. +- **NVFP4 transformer (`--use-fp4`) is unusable on full-frame latents**: cosine collapses to ~0.0 and PSNR to ~7 dB (median per-pixel deviation ~85/255). The FP4 - quantisation error accumulates over the large full-frame activations and the - output drifts to black — exactly why FP8 is the default. NVFP4 is only - appropriate for small cropped regions, where its per-block error stays - bounded. + quantisation error accumulates over the 12-step denoise loop in the transformer + and the output drifts to black. NVFP4 transformer is only appropriate for + small cropped regions, where its per-block error stays bounded. The NVFP4 + **VAE** path (above) is unaffected because the VAE is a single-pass + encoder/decoder with no iterative error accumulation. ### Transformer GEMM (NVFP4 vs fp16 matmul, single layer) @@ -423,8 +450,10 @@ to the quantised GEMMs and the attention backend. | NVFP4 W4A4 GEMM | cosine vs fp16 matmul | >= 0.999 | | FP8 W8A8 GEMM | cosine vs fp16 matmul | >= 0.999 | | End-to-end FP8 (full-frame) | PSNR vs fp16 ref | 35-41 dB (mean 39.9) / >= 36 dB (worst frame) | +| End-to-end FP8 + NVFP4 VAE (full-frame) | PSNR vs fp16 ref | 34.7 dB (median) / >= 30.6 dB (worst frame) | +| End-to-end FP8 + NVFP4 VAE (full-frame) | PSNR vs FP8 baseline | 34.3 dB (median) / >= 31.0 dB (worst frame) | | End-to-end FP8 (full-frame) | cosine vs fp16 ref | >= 0.999 | -| End-to-end NVFP4 (full-frame) | cosine vs fp16 ref | ~0.0 — **broken**, output drifts to black (median per-pixel deviation ~85 / 255) | +| End-to-end NVFP4 transformer (full-frame) | cosine vs fp16 ref | ~0.0 — **broken**, output drifts to black (FP4 error accumulates over 12 denoise steps in the transformer) | | End-to-end NVFP4 (small cropped region only) | PSNR vs fp16 ref | ~52 dB (mean) / ~45 dB (worst frame); per-block FP4 error stays bounded only when activations are small | The default `sage_fp8` attention gives the best latency at cosine 0.9993; @@ -438,7 +467,8 @@ the steady state; the FP8 path calibrates on the first call then freezes. |------|---------|--------| | `--vae-opt` / `--no-vae-opt` | **enabled** | Install FlashRT fp16 fused VAE kernels (`fp16_rms_norm_ncdhw`, `fp16_rms_silu_ncdhw`, NDHWC variants, fused norm+silu+amax) + channels-last 3D pipeline + running-max amax sharing between norm and conv + FP8 implicit-GEMM conv3d + `WanUpsample` cast elimination. Requires the `flash_rt_minimax_remover` module. | | `--fp8-conv` / `--no-fp8-conv` | **enabled** | Use FP8 implicit-GEMM conv3d kernel for applicable 3×3×3 causal convs (requires `--vae-opt`). Trades ~1 dB PSNR for ~14% decode speedup over channels-last-only cuDNN. | -| `--use-fp4` | off | Use NVFP4 (W4A4) instead of the default FP8 (W8A8). Small-region only. | +| `--use-fp4` | off | Use NVFP4 (W4A4) transformer instead of FP8 (W8A8). Small-region only — broken on full-frame. | +| `--no-nvfp4-vae` | off | Disable NVFP4 W4A4 VAE conv3d (default: enabled). Uses purpose-built NVFP4 MMA kernel for eligible VAE conv layers (Ci>=192) for ~16% VAE speedup. | | `--no-flashrt` | off | Run the reference diffusers fp16 path (no FlashRT). | ## Environment variables @@ -453,6 +483,11 @@ the steady state; the FP8 path calibrates on the first call then freezes. | `FLASHRT_FP8_TARGET` | `all` | FP8 Linear scope (`all` / `ffn_only`) (FP8 path) | | `FLASHRT_NORM_MODE` | `triton` | per-block LayerNorm kernel: `triton` (fp32-stat Triton, bit-exact) / `fp16` (FlashRT `ada_layer_norm_fp16`, lower precision, debug only) | | `FLASHRT_GELU_MODE` | `inplace` | FFN GELU kernel: `inplace` (FlashRT fused `gelu_inplace*`) / `torch` (original `F.gelu`, debug only) | +| `FLASHRT_NVFP4_VAE` | `1` | Enable NVFP4 VAE conv3d (default ON). Set to `0` to disable. | +| `FLASHRT_NVFP4_VAE_DECODE_ONLY` | `0` | `1` = apply NVFP4 only to decoder (encoder stays FP8); `0` = both encode+decode. | +| `FLASHRT_NVFP4_VAE_MIN_CI` | `192` | Minimum Ci for NVFP4 eligibility. 192 covers WanVAE Ci=192/384; 384 is stricter (better PSNR, fewer layers). | +| `FLASHRT_NVFP4_NO_CACHE` | `0` | `1` = disable FP4 cache (re-quantize cache per call); `0` = use rolling 2-frame FP4 cache (default, faster). | +| `FLASHRT_NVFP4_NO_FUSED_BLOCKS` | `1` | `1` = don't patch WanResidualBlock.forward for fused norm+quant (default ON — separate norm+quant is more precise); `0` = enable fused block forward (faster but ~2 dB PSNR drop). | ## Usage @@ -475,7 +510,38 @@ output = pipeline( video = output.frames ``` -### NVFP4 (small cropped regions only) +### FP8 + NVFP4 VAE (recommended default — full-frame, fastest) + +The NVFP4 VAE optimization is **enabled by default** when using the FP8 +transformer path with VAE optimizations. No extra code needed — just +construct the FP8 pipeline and call `install_vae_optimizations` + +`install_vae_nvfp4`: + +```python +from flash_rt.models.minimax_remover import MiniMaxRemoverPipelineFP8 +from flash_rt.models.minimax_remover._vae_opt import install_vae_optimizations +from flash_rt.models.minimax_remover._vae_nvfp4 import install_vae_nvfp4 + +pipeline = MiniMaxRemoverPipelineFP8(pipe) +install_vae_optimizations(pipe.vae, use_fp8_conv=True) +install_vae_nvfp4(pipe.vae) # 38 conv layers → NVFP4 W4A4 (default ON) + +output = pipeline( + images=frames, masks=masks, num_frames=len(frames), + height=720, width=1280, num_inference_steps=12, +) +``` + +Or via the quickstart (NVFP4 VAE is on by default): + +```bash +python3 examples/minimax_remover_quickstart.py \ + --model-dir ./minimax-remover \ + --frames-dir ./frames --masks-dir ./masks --output-dir ./out +# Add --no-nvfp4-vae to disable +``` + +### NVFP4 transformer (small cropped regions only) ```python from flash_rt.models.minimax_remover import MiniMaxRemoverPipeline diff --git a/examples/minimax_remover_quickstart.py b/examples/minimax_remover_quickstart.py index 4eb3adb9..14487320 100644 --- a/examples/minimax_remover_quickstart.py +++ b/examples/minimax_remover_quickstart.py @@ -605,6 +605,10 @@ def parse_args() -> argparse.Namespace: help="Use FP8 implicit-GEMM conv3d kernel for applicable " "3x3x3 causal convs (requires --vae-opt). Trades ~1.5 " "dB PSNR for ~13%% decode speedup. (default: enabled)") + p.add_argument("--no-nvfp4-vae", action="store_true", + help="Disable NVFP4 W4A4 VAE conv3d (default: enabled). " + "Uses purpose-built NVFP4 MMA kernel for eligible " + "decode conv layers (Ci>=192) for ~3-7%% VAE speedup.") return p.parse_args() @@ -665,6 +669,13 @@ def main() -> None: use_fp8_conv=args.fp8_conv) print(f" VAE optimised: {stats}") + # NVFP4 VAE conv3d (default ON for FlashRT FP8 path; --no-nvfp4-vae to disable) + if args.vae_opt and not args.no_flashrt and not args.use_fp4 and not args.no_nvfp4_vae: + from flash_rt.models.minimax_remover._vae_nvfp4 import install_vae_nvfp4 + nvfp4_stats = install_vae_nvfp4(pipe.vae) + if nvfp4_stats.get('enabled'): + print(f" NVFP4 VAE: {nvfp4_stats.get('n_quantized', 0)} layers → W4A4") + if args.no_flashrt: runner = pipe tag = "reference (diffusers fp16)" diff --git a/flash_rt/models/minimax_remover/_vae_nvfp4.py b/flash_rt/models/minimax_remover/_vae_nvfp4.py new file mode 100644 index 00000000..d0b06cc5 --- /dev/null +++ b/flash_rt/models/minimax_remover/_vae_nvfp4.py @@ -0,0 +1,499 @@ +"""FlashRT — MiniMax-Remover WanVAE NVFP4 (W4A4) conv3d integration. + +Replaces eligible 3×3×3 WanCausalConv3d layers in the WanVAE with an +NVFP4 (W4A4) path that uses: + + 1. **FlashRT fp16_quant_nvfp4_ndhwc** (novel CUDA kernel in this repo): + fp16 NCDHW → NVFP4 packed + UE4M3 block-scale (NDHWC layout). + Fuses the layout conversion + quantization into one pass. + + 2. **motus_fp4_conv3d_v19sfb_ncdhw_res_bf16out** (existing SM120 kernel): + NVFP4 W4A4 implicit-GEMM conv3d with bias + optional residual. + Uses mma.sync.kind::mxf4nvf4 (e2m1 × e2m1, UE4M3 block scales). + +Weight is pre-quantized once at install time (bf16 → NVFP4 via +``quantize_bf16_to_nvfp4``). Activations are quantized dynamically +per call (fp16 → FP4, on-GPU, no CPU sync). + +Eligibility: 3×3×3 WanCausalConv3d with Ci % 96 == 0 (WanVAE channels +96/192/384 all qualify). The FP4 path is installed ADDITIVELY over +the existing FP8 path — eligible layers switch to FP4, ineligible +layers stay on FP8. + +Env knobs: + FLASHRT_NVFP4_VAE=1 enable (default ON) + FLASHRT_NVFP4_VAE_DECODE_ONLY=0 only patch decoder (default 0 = enc+dec) + FLASHRT_NVFP4_VAE_MIN_CI=192 minimum Ci for FP4 (default 192) +""" +from __future__ import annotations + +import logging +import os +from typing import Optional, Dict + +import torch +import torch.nn as nn + +logger = logging.getLogger(__name__) + +_FP16 = torch.float16 +_BF16 = torch.bfloat16 + +# Lazy-loaded kernel modules +_fvk = None # flash_rt_minimax_remover (our new kernels) +_frk = None # flash_rt_kernels (motus FP4 conv3d + weight quant) + + +def _load_kernels(): + global _fvk, _frk + if _fvk is not None: + return True + try: + from flash_rt import flash_rt_minimax_remover as _fvk + except ImportError: + try: + import flash_rt_minimax_remover as _fvk + except ImportError: + logger.error("[nvfp4_vae] flash_rt_minimax_remover not found") + return False + try: + from flash_rt import flash_rt_kernels as _frk + except ImportError: + import flash_rt_kernels as _frk + if not hasattr(_frk, 'quantize_bf16_to_nvfp4'): + logger.error("[nvfp4_vae] quantize_bf16_to_nvfp4 not in flash_rt_kernels") + return False + if not hasattr(_fvk, 'nvfp4_conv3d_ndhwc_fp16out'): + logger.error("[nvfp4_vae] nvfp4_conv3d_ndhwc_fp16out not in flash_rt_minimax_remover") + return False + return True + + +# ── Weight pre-quantization (one-time at install) ── + +def _quantize_weight_nvfp4(conv_weight: torch.Tensor): + """Quantize a 3×3×3 conv3d weight [Co,Ci,3,3,3] fp16 to NVFP4. + + Returns (w_fp4 [Co,3,3,3,Ci/2] uint8, w_sf [Co,3,3,3,Ci/16] uint8). + """ + Co, Ci = conv_weight.shape[0], conv_weight.shape[1] + # Permute to [Co, kT, kH, kW, Ci] and flatten to [Co*27, Ci] + w_bf16 = conv_weight.to(_BF16).permute(0, 2, 3, 4, 1).contiguous() + rows = Co * 27 + w_2d = w_bf16.view(rows, Ci) + + w_fp4 = torch.empty((rows, Ci // 2), dtype=torch.uint8, device=w_2d.device) + w_sf = torch.empty((rows, Ci // 16), dtype=torch.uint8, device=w_2d.device) + _frk.quantize_bf16_to_nvfp4( + w_2d.data_ptr(), w_fp4.data_ptr(), w_sf.data_ptr(), + rows, Ci, 0) + torch.cuda.synchronize(w_2d.device) + + return (w_fp4.view(Co, 3, 3, 3, Ci // 2).contiguous(), + w_sf.view(Co, 3, 3, 3, Ci // 16).contiguous()) + + +# ── Activation quantization (per-call, dynamic) ── + +def _quant_act_nvfp4(x: torch.Tensor, B: int, C: int, T: int, H: int, W: int): + """Quantize fp16 [B,C,T,H,W] → NVFP4 NDHWC flat + SF flat. + + Automatically detects channels-last 3D layout and uses the CL kernel + variant to avoid a contiguous() copy. + """ + n = B * T * H * W + fp4 = torch.empty(n * (C // 2), dtype=torch.uint8, device=x.device) + sf = torch.empty(n * (C // 16), dtype=torch.uint8, device=x.device) + s = torch.cuda.current_stream().cuda_stream + if x.is_contiguous(memory_format=torch.channels_last_3d): + # Channels-last: use CL kernel, no copy needed + rc = _fvk.fp16_quant_nvfp4_cl_ndhwc( + x.data_ptr(), fp4.data_ptr(), sf.data_ptr(), + B, C, T, H, W, s) + else: + x_c = x.contiguous(memory_format=torch.contiguous_format) + rc = _fvk.fp16_quant_nvfp4_ndhwc( + x_c.data_ptr(), fp4.data_ptr(), sf.data_ptr(), + B, C, T, H, W, s) + if rc != 0: + raise RuntimeError(f"[nvfp4_vae] quant_nvfp4 rc={rc} " + f"shape=({B},{C},{T},{H},{W})") + return fp4, sf + + +# ── FP4 conv3d forward ── + +def _nvfp4_conv3d_forward(self, x: torch.Tensor, cache_x=None): + """NVFP4 W4A4 conv3d forward with FP4 cache (Direction 2). + + Caches the quantized FP4 activation for the next call's cache, + eliminating per-call cache quantization. + """ + B, Ci, T_new, H, W = x.shape + Co = self._nvfp4_w.shape[0] + s = torch.cuda.current_stream().cuda_stream + + # Quantize new activation + new_fp4, new_sf = _quant_act_nvfp4(x, B, Ci, T_new, H, W) + + # FP4 cache: reuse stored FP4 from previous call if available (Direction 2) + # Set FLASHRT_NVFP4_NO_CACHE=1 to disable (always quantize per call) + use_fp4_cache = os.environ.get('FLASHRT_NVFP4_NO_CACHE', '0') != '1' + stored_fp4 = getattr(self, '_nvfp4_stored_fp4', None) if use_fp4_cache else None + stored_sf = getattr(self, '_nvfp4_stored_sf', None) if use_fp4_cache else None + stored_T = getattr(self, '_nvfp4_stored_T', 0) if use_fp4_cache else 0 + + if stored_fp4 is not None and stored_T >= 2: + # Use stored 2-frame cache directly (already the right format) + cache_fp4 = stored_fp4 + cache_sf = stored_sf + elif stored_fp4 is not None and stored_T == 1: + # Previous call had T_new=1: pad [zero, prev_frame] + cache_fp4 = torch.zeros(B * _CACHE_T * H * W * (Ci // 2), dtype=torch.uint8, device=x.device) + cache_sf = torch.zeros(B * _CACHE_T * H * W * (Ci // 16), dtype=torch.uint8, device=x.device) + off = B * H * W * (Ci // 2) + cache_fp4[off:off + stored_fp4.numel()] = stored_fp4 + off_sf = B * H * W * (Ci // 16) + cache_sf[off_sf:off_sf + stored_sf.numel()] = stored_sf + elif cache_x is not None and cache_x.shape[2] >= 1: + # Fallback: quantize fp16 cache per call (same as non-fused path) + cache_2 = cache_x[:, :, -2:] + if cache_2.shape[2] < 2: + pad = torch.zeros(B, Ci, 2 - cache_2.shape[2], H, W, + dtype=x.dtype, device=x.device) + cache_2 = torch.cat([pad, cache_2], dim=2) + if not cache_2.is_contiguous(): + cache_2 = cache_2.contiguous( + memory_format=torch.channels_last_3d + if cache_2.is_contiguous(memory_format=torch.channels_last_3d) + else torch.contiguous_format) + cache_fp4, cache_sf = _quant_act_nvfp4(cache_2, B, Ci, 2, H, W) + else: + cache_fp4 = torch.zeros(B * _CACHE_T * H * W * (Ci // 2), dtype=torch.uint8, device=x.device) + cache_sf = torch.zeros(B * _CACHE_T * H * W * (Ci // 16), dtype=torch.uint8, device=x.device) + + # Purpose-built kernel: fp16 NDHWC output + M_total = B * T_new * H * W + out_ndhwc = torch.empty(M_total * Co, dtype=_FP16, device=x.device) + bias_ptr = self.bias.data_ptr() if self.bias is not None else 0 + + rc = _fvk.nvfp4_conv3d_ndhwc_fp16out( + cache_fp4.data_ptr(), new_fp4.data_ptr(), + self._nvfp4_w.data_ptr(), + cache_sf.data_ptr(), new_sf.data_ptr(), + self._nvfp4_sf.data_ptr(), + out_ndhwc.data_ptr(), bias_ptr, + B, _CACHE_T, T_new, H, W, Ci, Co, 1.0, s) + if rc != 0: + logger.warning("[nvfp4_vae] conv3d kernel rc=%d, falling back to cuDNN", rc) + padding = list(self._padding) + if cache_x is not None and self._padding[4] > 0: + cx = cache_x.to(x.device) + x_cat = torch.cat([cx, x], dim=2) + padding[4] -= cx.shape[2] + else: + x_cat = x + x_pad = torch.nn.functional.pad(x_cat, padding) + return torch.nn.functional.conv3d( + x_pad, self.weight, self.bias, self.stride, + self.padding, self.dilation, self.groups) + + # Store rolling 2-frame cache for next call (Direction 2) + if use_fp4_cache: + if T_new >= 2: + self._nvfp4_stored_fp4 = new_fp4.view(B, T_new, H, W, Ci // 2)[:, -_CACHE_T:].contiguous().view(-1).clone() + self._nvfp4_stored_sf = new_sf.view(B, T_new, H, W, Ci // 16)[:, -_CACHE_T:].contiguous().view(-1).clone() + self._nvfp4_stored_T = _CACHE_T + elif T_new == 1 and stored_fp4 is not None and stored_T >= 1: + # Rolling: [prev_last_frame, current_frame] + prev_fp4_5d = stored_fp4.view(B, stored_T, H, W, Ci // 2)[:, -1:] + curr_fp4_5d = new_fp4.view(B, 1, H, W, Ci // 2) + self._nvfp4_stored_fp4 = torch.cat([prev_fp4_5d, curr_fp4_5d], dim=1).contiguous().view(-1).clone() + prev_sf_5d = stored_sf.view(B, stored_T, H, W, Ci // 16)[:, -1:] + curr_sf_5d = new_sf.view(B, 1, H, W, Ci // 16) + self._nvfp4_stored_sf = torch.cat([prev_sf_5d, curr_sf_5d], dim=1).contiguous().view(-1).clone() + self._nvfp4_stored_T = _CACHE_T + else: + self._nvfp4_stored_fp4 = new_fp4.clone() + self._nvfp4_stored_sf = new_sf.clone() + self._nvfp4_stored_T = T_new + + out = out_ndhwc.view(B, T_new, H, W, Co).permute(0, 4, 1, 2, 3) + return out.contiguous(memory_format=torch.channels_last_3d) + + +# ════════════════════════════════════════════════════════════════════ +# Direction 2+3: Fused norm+silu+quant + FP4 cache +# Eliminates both activation quant and cache quant per call. +# ════════════════════════════════════════════════════════════════════ + +_CACHE_T = 2 + +def _fused_norm_silu_quant_cl(x, gamma, bias, B, C, T, H, W): + """Call fused RMS+SiLU+NVFP4quant kernel (channels-last input). + Returns (fp4_flat, sf_flat). + """ + n = B * T * H * W + fp4 = torch.empty(n * (C // 2), dtype=torch.uint8, device=x.device) + sf = torch.empty(n * (C // 16), dtype=torch.uint8, device=x.device) + s = torch.cuda.current_stream().cuda_stream + # Ensure channels-last contiguous + if not x.is_contiguous(memory_format=torch.channels_last_3d): + x = x.to(memory_format=torch.channels_last_3d) + bias_ptr = bias.data_ptr() if isinstance(bias, torch.Tensor) else 0 + rc = _fvk.fp16_rms_silu_quant_nvfp4_cl_ndhwc( + x.data_ptr(), gamma.data_ptr(), bias_ptr, + fp4.data_ptr(), sf.data_ptr(), + B, C, T, H, W, 1e-6, s) + if rc != 0: + raise RuntimeError(f"[nvfp4_vae] fused_norm_silu_quant rc={rc}") + return fp4, sf + + +def _conv3d_prequant(conv, new_fp4, new_sf, cache_fp4, cache_sf, + B, Ci, Co, T_new, H, W): + """Conv3d with pre-quantized FP4 data (no activation/cache quant needed).""" + s = torch.cuda.current_stream().cuda_stream + M_total = B * T_new * H * W + out_ndhwc = torch.empty(M_total * Co, dtype=_FP16, device=conv._nvfp4_w.device) + bias_ptr = conv.bias.data_ptr() if conv.bias is not None else 0 + rc = _fvk.nvfp4_conv3d_ndhwc_fp16out( + cache_fp4.data_ptr(), new_fp4.data_ptr(), + conv._nvfp4_w.data_ptr(), + cache_sf.data_ptr(), new_sf.data_ptr(), + conv._nvfp4_sf.data_ptr(), + out_ndhwc.data_ptr(), bias_ptr, + B, _CACHE_T, T_new, H, W, Ci, Co, 1.0, s) + if rc != 0: + raise RuntimeError(f"[nvfp4_vae] conv3d_prequant rc={rc}") + out = out_ndhwc.view(B, T_new, H, W, Co).permute(0, 4, 1, 2, 3) + return out.contiguous(memory_format=torch.channels_last_3d) + + +def _slice_fp4_cache(fp4_flat, sf_flat, B, C, T, H, W): + """Extract last 2 frames from flat FP4+SF as cache.""" + fp4_5d = fp4_flat.view(B, T, H, W, C // 2) + sf_5d = sf_flat.view(B, T, H, W, C // 16) + return (fp4_5d[:, -_CACHE_T:].contiguous().view(-1), + sf_5d[:, -_CACHE_T:].contiguous().view(-1)) + + +def _build_fp4_cache(conv, B, C, H, W, device): + """Construct 2-frame FP4 cache from previous call's stored output. + Handles T_prev < 2 by zero-padding (matches FP8 path behavior).""" + prev_fp4 = getattr(conv, '_nvfp4_cache_fp4', None) + prev_sf = getattr(conv, '_nvfp4_cache_sf', None) + prev_T = getattr(conv, '_nvfp4_cache_T', 0) + + if prev_fp4 is not None and prev_T >= 2: + fp4_5d = prev_fp4.view(B, prev_T, H, W, C // 2) + sf_5d = prev_sf.view(B, prev_T, H, W, C // 16) + return (fp4_5d[:, -_CACHE_T:].contiguous().view(-1), + sf_5d[:, -_CACHE_T:].contiguous().view(-1)) + elif prev_fp4 is not None and prev_T == 1: + # Pad: [zero_frame, prev_frame] (prev at index 1) + cache_fp4 = torch.zeros(B * _CACHE_T * H * W * (C // 2), + dtype=torch.uint8, device=device) + cache_sf = torch.zeros(B * _CACHE_T * H * W * (C // 16), + dtype=torch.uint8, device=device) + off = B * H * W * (C // 2) # offset to second frame + cache_fp4[off:off + prev_fp4.numel()] = prev_fp4 + off_sf = B * H * W * (C // 16) + cache_sf[off_sf:off_sf + prev_sf.numel()] = prev_sf + return cache_fp4, cache_sf + else: + return (torch.zeros(B * _CACHE_T * H * W * (C // 2), + dtype=torch.uint8, device=device), + torch.zeros(B * _CACHE_T * H * W * (C // 16), + dtype=torch.uint8, device=device)) + + +def _make_patched_residual_forward(orig_forward): + """Create a patched WanResidualBlock.forward that fuses norm+quant + and uses FP4 cache for blocks where both conv1 and conv2 are NVFP4-eligible. + """ + + def _patched_forward(self, x, feat_cache=None, feat_idx=[0]): + # Check if this block is fully NVFP4-eligible + conv1_nvfp4 = getattr(self.conv1, '_nvfp4_w', None) is not None + conv2_nvfp4 = getattr(self.conv2, '_nvfp4_w', None) is not None + if not (conv1_nvfp4 and conv2_nvfp4): + return orig_forward(self, x, feat_cache, feat_idx) + + B, C, T_new, H, W = x.shape + device = x.device + + # Shortcut (fp16, unchanged) + h = self.conv_shortcut(x) + + # ── norm1 + silu (existing kernel) + FP4 quant (separate) ── + gamma1 = self.norm1.gamma + bias1 = getattr(self.norm1, 'bias', 0) + if not x.is_contiguous(memory_format=torch.channels_last_3d): + x = x.to(memory_format=torch.channels_last_3d) + # Use existing norm module (produces fp16), then quant separately + x_normed = self.norm1(x) # fp16 channels-last output + fp4_1, sf_1 = _quant_act_nvfp4(x_normed, B, C, T_new, H, W) + + # FP4 cache for conv1: build from previous call's stored output + cache_fp4_1, cache_sf_1 = _build_fp4_cache( + self.conv1, B, C, H, W, device) + + # Conv1 with pre-quantized FP4 + Co1 = self.conv1._nvfp4_w.shape[0] + x_out = _conv3d_prequant( + self.conv1, fp4_1, sf_1, cache_fp4_1, cache_sf_1, + B, C, Co1, T_new, H, W) + + # Store this call's full FP4 output for next call's cache + self.conv1._nvfp4_cache_fp4 = fp4_1 + self.conv1._nvfp4_cache_sf = sf_1 + self.conv1._nvfp4_cache_T = T_new + + # Keep feat_idx consistent (skip 1 for conv1's cache slot) + if feat_cache is not None: + feat_idx[0] += 1 + + # ── norm2 + silu (existing kernel) + FP4 quant (separate) ── + C2 = x_out.shape[1] + if not x_out.is_contiguous(memory_format=torch.channels_last_3d): + x_out = x_out.to(memory_format=torch.channels_last_3d) + x_normed2 = self.norm2(x_out) + fp4_2, sf_2 = _quant_act_nvfp4(x_normed2, B, C2, T_new, H, W) + + cache_fp4_2, cache_sf_2 = _build_fp4_cache( + self.conv2, B, C2, H, W, device) + + Co2 = self.conv2._nvfp4_w.shape[0] + x_out2 = _conv3d_prequant( + self.conv2, fp4_2, sf_2, cache_fp4_2, cache_sf_2, + B, C2, Co2, T_new, H, W) + + self.conv2._nvfp4_cache_fp4 = fp4_2 + self.conv2._nvfp4_cache_sf = sf_2 + self.conv2._nvfp4_cache_T = T_new + + if feat_cache is not None: + feat_idx[0] += 1 + + return x_out2 + h + + return _patched_forward + + +# ── Install ── + +def install_vae_nvfp4(vae, enabled: bool = True) -> Dict: + """Install NVFP4 W4A4 conv3d for eligible WanVAE layers. + + Must be called AFTER ``install_vae_optimizations`` (the FP8 path + must already be installed — FP4 overrides eligible layers). + + Returns summary dict. + """ + if not enabled: + return {'enabled': False, 'reason': 'disabled'} + + if os.environ.get('FLASHRT_NVFP4_VAE', '1') != '1': + return {'enabled': False, 'reason': 'env disabled'} + + if not _load_kernels(): + return {'enabled': False, 'reason': 'kernels not found'} + + from diffusers.models.autoencoders.autoencoder_kl_wan import ( + WanCausalConv3d) + + decode_only = os.environ.get('FLASHRT_NVFP4_VAE_DECODE_ONLY', '0') == '1' + min_ci = int(os.environ.get('FLASHRT_NVFP4_VAE_MIN_CI', '192')) + + n_quantized = 0 + n_skipped = 0 + skip_reasons: Dict[str, int] = {} + + for name, m in vae.named_modules(): + if not isinstance(m, WanCausalConv3d): + continue + kt, kh, kw = m.kernel_size + Ci, Co = m.in_channels, m.out_channels + if (kt, kh, kw) != (3, 3, 3): + continue + if Ci % 96 != 0 or Ci < min_ci or Ci % 64 != 0: + n_skipped += 1 + r = f'Ci={Ci} (<{min_ci} or not %64 for NVFP4 MMA)' + skip_reasons[r] = skip_reasons.get(r, 0) + 1 + continue + if decode_only and name.startswith('encoder.'): + n_skipped += 1 + r = 'encoder (decode_only)' + skip_reasons[r] = skip_reasons.get(r, 0) + 1 + continue + + # Pre-quantize weight + w_fp4, w_sf = _quantize_weight_nvfp4(m.weight.data) + m.register_buffer('_nvfp4_w', w_fp4, persistent=False) + m.register_buffer('_nvfp4_sf', w_sf, persistent=False) + n_quantized += 1 + + if n_quantized == 0: + logger.warning("[nvfp4_vae] 0 layers eligible for FP4") + return {'enabled': False, 'n_quantized': 0, 'n_skipped': n_skipped, + 'skip_reasons': skip_reasons} + + # Patch forward — override FP8 dispatch for FP4-eligible layers + _saved_forward = WanCausalConv3d.forward + + def _nvfp4_dispatch_forward(self, x, cache_x=None): + if getattr(self, '_nvfp4_w', None) is not None: + return _nvfp4_conv3d_forward(self, x, cache_x) + return _saved_forward(self, x, cache_x) + + WanCausalConv3d.forward = _nvfp4_dispatch_forward + WanCausalConv3d._flashrt_nvfp4_patched = True + + # ── Direction 2+3: Patch WanResidualBlock for fused norm+quant + FP4 cache ── + n_fused_blocks = 0 + no_fused = True # Direction 2 only (FP4 cache in conv); Direction 3 disabled + try: + from diffusers.models.autoencoders.autoencoder_kl_wan import ( + WanResidualBlock) + _orig_res_forward = WanResidualBlock.forward + _patched_fwd = _make_patched_residual_forward(_orig_res_forward) + + # Check which blocks have both conv1+conv2 NVFP4-eligible + for blk in vae.modules(): + if isinstance(blk, WanResidualBlock): + c1_ok = getattr(blk.conv1, '_nvfp4_w', None) is not None + c2_ok = getattr(blk.conv2, '_nvfp4_w', None) is not None + if c1_ok and c2_ok: + blk._nvfp4_fused = True + n_fused_blocks += 1 + + if n_fused_blocks > 0 and not no_fused: + WanResidualBlock.forward = _patched_fwd + logger.info("[nvfp4_vae] %d WanResidualBlocks use fused " + "norm+quant + FP4 cache", n_fused_blocks) + + # Patch clear_cache to also clear FP4 caches between encode/decode passes + import types + _orig_clear = vae.clear_cache + def _clear_with_fp4(self_vae): + _orig_clear() + for m in self_vae.modules(): + m._nvfp4_stored_fp4 = None + m._nvfp4_stored_sf = None + m._nvfp4_stored_T = 0 + vae.clear_cache = types.MethodType(_clear_with_fp4, vae) + except Exception as e: + logger.debug("[nvfp4_vae] fused block patch skipped: %s", e) + + summary = { + 'enabled': True, + 'n_quantized': n_quantized, + 'n_skipped': n_skipped, + 'n_fused_blocks': n_fused_blocks, + 'skip_reasons': skip_reasons, + 'decode_only': decode_only, + 'min_ci': min_ci, + } + logger.info("[nvfp4_vae] install summary: %s", summary) + return summary From ed8e78947a6401459b5a162ccaf3aef8e7f07a00 Mon Sep 17 00:00:00 2001 From: chenping Date: Thu, 9 Jul 2026 14:41:21 +0800 Subject: [PATCH 22/23] feat(minimax-remover): fuse norm+quant and Q/K bias for ~2.1% speedup Three quality-neutral fusion points on the default FP8+NVFP4-VAE path (steady-state 5.86s -> 5.73s; end-to-end 6.71s -> 6.56s, 2.64x vs fp16 ref). PSNR unchanged at 35.2 dB mean vs the fp16 reference. * NVFP4 fused norm+silu+NVFP4-quant + FP4 cache reuse (#1): the sister norm of each fully-NVFP4 WanResidualBlock emits the FP4 activation directly via fp16_rms_silu_quant_nvfp4_cl_ndhwc (no fp16 round-trip) and the conv reuses Direction-2's rolling 2-frame FP4 cache. Mirrors diffusers' [prev, current] cache padding required by the WanVAE per-frame decode streaming loop. Gated by FLASHRT_NVFP4_FUSED_NORMQUANT (default 1). * FP8 fused norm+silu+running-amax+FP8-quant (#2): same idea for the FP8-conv residual blocks via fp16_rms_silu_amax_quant_fp8_ndhwc_nozero (accumulates into the running amax so the new-x scale stays consistent with the causal cache). Gated by FLASHRT_FP8_FUSED_NORMQUANT (default 1). * Q/K bias fused into rmsnorm+RoPE+int8-quant (#3): cuBLASLt's CUBLASLT_EPILOGUE_BIAS is NOT_SUPPORTED for FP8 (e4m3) GEMMs, so the Q/K projection bias is added pre-norm inside the downstream fp16_rmsnorm_rope_quant_int8 kernel (fp32 add, slightly more precise than the fp16 add it replaces). Eliminates 720 of 1092 add_bias_vec8 calls per denoise; V and proj_out keep their bias. Adds gemm_from_fp8_ext_nobias to feed the no-bias Q/K GEMM output. Also: steady-state uses the eager manual denoise path by default (FLASHRT_FP8_EAGER_MANUAL=1, avoids per-step torch.cat + scheduler sync); remove dead Triton wan_rms_norm_ncdhw kernel; update docs/minimax_remover_usage.md with the new numbers, env vars and fusion notes. --- .../fp16_rmsnorm_rope_quant_int8.cu | 28 ++- .../fp16_rmsnorm_rope_quant_int8.cuh | 2 + csrc/minimax_remover_extra_bindings.cpp | 9 + docs/minimax_remover_usage.md | 63 ++++-- flash_rt/models/minimax_remover/_attention.py | 35 +++- .../models/minimax_remover/_fp8_linear.py | 25 +++ .../models/minimax_remover/_fp8_pipeline.py | 13 +- flash_rt/models/minimax_remover/_kernels.py | 77 -------- flash_rt/models/minimax_remover/_vae_nvfp4.py | 181 +++++++++++++----- flash_rt/models/minimax_remover/_vae_opt.py | 167 ++++++++++++++++ 10 files changed, 447 insertions(+), 153 deletions(-) diff --git a/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cu b/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cu index 3cd16857..25aad2ca 100644 --- a/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cu +++ b/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cu @@ -44,6 +44,7 @@ __device__ __forceinline__ float warp_reduce_max_rq(float v) { template __global__ void rmsnorm_rstd_kernel( const __half* __restrict__ x, // [B*S, D] + const __half* __restrict__ bias, // [D] or nullptr — added before rmsnorm float* __restrict__ rstd_out, // [B*S] int D, float eps) { @@ -68,6 +69,16 @@ __global__ void rmsnorm_rstd_kernel( float2 f1 = __half22float2(h1); float2 f2 = __half22float2(h2); float2 f3 = __half22float2(h3); + if (bias) { + const uint4* pb = reinterpret_cast(bias + v * VEC_RSTD); + uint4 rb = *pb; + float2 b0 = __half22float2(*reinterpret_cast<__half2*>(&rb.x)); + float2 b1 = __half22float2(*reinterpret_cast<__half2*>(&rb.y)); + float2 b2 = __half22float2(*reinterpret_cast<__half2*>(&rb.z)); + float2 b3 = __half22float2(*reinterpret_cast<__half2*>(&rb.w)); + f0.x += b0.x; f0.y += b0.y; f1.x += b1.x; f1.y += b1.y; + f2.x += b2.x; f2.y += b2.y; f3.x += b3.x; f3.y += b3.y; + } local_sq += f0.x*f0.x + f0.y*f0.y + f1.x*f1.x + f1.y*f1.y + f2.x*f2.x + f2.y*f2.y + f3.x*f3.x + f3.y*f3.y; } @@ -97,6 +108,7 @@ template __global__ void norm_rope_quant_kernel( const __half* __restrict__ x, // [B*S, D] const __half* __restrict__ weight, // [D] + const __half* __restrict__ bias, // [D] or nullptr — added before rmsnorm const float* __restrict__ cos_tab, // [S, Dd/2] const float* __restrict__ sin_tab, // [S, Dd/2] const float* __restrict__ rstd, // [B*S] @@ -139,9 +151,13 @@ __global__ void norm_rope_quant_kernel( const float my_rstd = rstd[global_tok]; - // Load x values + // Load x values (+ bias, fused: avoids the separate add_bias kernel) float x0 = __half2float(x[(long long)global_tok * D + h * Dd + d0]); float x1 = __half2float(x[(long long)global_tok * D + h * Dd + d1]); + if (bias) { + x0 += __half2float(bias[h * Dd + d0]); + x1 += __half2float(bias[h * Dd + d1]); + } // Load weights float w0 = __half2float(weight[h * Dd + d0]); @@ -214,6 +230,7 @@ __global__ void norm_rope_quant_kernel( int fp16_rmsnorm_rope_quant_int8_q( const void* x_fp16, const void* weight_fp16, + const void* bias_fp16, const void* cos_fp32, const void* sin_fp32, void* out_int8, @@ -234,10 +251,11 @@ int fp16_rmsnorm_rope_quant_int8_q( cudaError_t e = cudaMallocAsync(&rstd_buf, B * S * sizeof(float), stream); if (e != cudaSuccess) return -4; - // Kernel A: compute rstd + // Kernel A: compute rstd (over x+bias) constexpr int THREADS_A = 128; rmsnorm_rstd_kernel<<>>( reinterpret_cast(x_fp16), + bias_fp16 ? reinterpret_cast(bias_fp16) : nullptr, rstd_buf, D, eps); // Kernel B: norm + rope + quantize (Q: GROUP_SIZE=32) @@ -250,6 +268,7 @@ int fp16_rmsnorm_rope_quant_int8_q( norm_rope_quant_kernel<<>>( reinterpret_cast(x_fp16), reinterpret_cast(weight_fp16), + bias_fp16 ? reinterpret_cast(bias_fp16) : nullptr, reinterpret_cast(cos_fp32), reinterpret_cast(sin_fp32), rstd_buf, @@ -266,6 +285,7 @@ int fp16_rmsnorm_rope_quant_int8_q( int fp16_rmsnorm_rope_quant_int8_k( const void* x_fp16, const void* weight_fp16, + const void* bias_fp16, const void* cos_fp32, const void* sin_fp32, const void* km_fp16, @@ -286,10 +306,11 @@ int fp16_rmsnorm_rope_quant_int8_k( cudaError_t e = cudaMallocAsync(&rstd_buf, B * S * sizeof(float), stream); if (e != cudaSuccess) return -4; - // Kernel A: compute rstd + // Kernel A: compute rstd (over x+bias) constexpr int THREADS_A = 128; rmsnorm_rstd_kernel<<>>( reinterpret_cast(x_fp16), + bias_fp16 ? reinterpret_cast(bias_fp16) : nullptr, rstd_buf, D, eps); // Kernel B: norm + rope + quantize (K: GROUP_SIZE=64, with smooth_k) @@ -302,6 +323,7 @@ int fp16_rmsnorm_rope_quant_int8_k( norm_rope_quant_kernel<<>>( reinterpret_cast(x_fp16), reinterpret_cast(weight_fp16), + bias_fp16 ? reinterpret_cast(bias_fp16) : nullptr, reinterpret_cast(cos_fp32), reinterpret_cast(sin_fp32), rstd_buf, diff --git a/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cuh b/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cuh index 33f5ed90..7798d4ea 100644 --- a/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cuh +++ b/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cuh @@ -15,6 +15,7 @@ namespace minimax_remover { int fp16_rmsnorm_rope_quant_int8_q( const void* x_fp16, // [B*S, H*Dd] fp16 input const void* weight_fp16, // [H*Dd] fp16 norm weight + const void* bias_fp16, // [H*Dd] fp16 Q-proj bias, or nullptr (fused pre-norm) const void* cos_fp32, // [S, Dd/2] fp32 const void* sin_fp32, // [S, Dd/2] fp32 void* out_int8, // [B*S, H*Dd] int8 output @@ -32,6 +33,7 @@ int fp16_rmsnorm_rope_quant_int8_q( int fp16_rmsnorm_rope_quant_int8_k( const void* x_fp16, // [B*S, H*Dd] fp16 input const void* weight_fp16, // [H*Dd] fp16 norm weight + const void* bias_fp16, // [H*Dd] fp16 K-proj bias, or nullptr (fused pre-norm) const void* cos_fp32, // [S, Dd/2] fp32 const void* sin_fp32, // [S, Dd/2] fp32 const void* km_fp16, // [B, 1, H, Dd] fp16 key mean (smooth_k) diff --git a/csrc/minimax_remover_extra_bindings.cpp b/csrc/minimax_remover_extra_bindings.cpp index c0707b2a..90c5d045 100644 --- a/csrc/minimax_remover_extra_bindings.cpp +++ b/csrc/minimax_remover_extra_bindings.cpp @@ -389,6 +389,7 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { // ── Fused RMSNorm + RoPE + int8 quantize (Q, per-warp) ────────── m.def("fp16_rmsnorm_rope_quant_int8_q", [](uintptr_t x_fp16, uintptr_t weight_fp16, + uintptr_t bias_fp16, uintptr_t cos_fp32, uintptr_t sin_fp32, uintptr_t out_int8, uintptr_t scale_fp32, int B, int S, int H, int Dd, @@ -396,11 +397,13 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { return flash_rt::kernels::minimax_remover:: fp16_rmsnorm_rope_quant_int8_q( to_ptr(x_fp16), to_ptr(weight_fp16), + bias_fp16 ? to_ptr(bias_fp16) : nullptr, to_ptr(cos_fp32), to_ptr(sin_fp32), to_ptr(out_int8), to_ptr(scale_fp32), B, S, H, Dd, eps, sm_scale, to_stream(stream)); }, py::arg("x_fp16"), py::arg("weight_fp16"), + py::arg("bias_fp16") = 0, py::arg("cos_fp32"), py::arg("sin_fp32"), py::arg("out_int8"), py::arg("scale_fp32"), py::arg("B"), py::arg("S"), py::arg("H"), py::arg("Dd"), @@ -408,12 +411,15 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { py::arg("stream") = 0, "Fused RMSNorm + RoPE + per-warp int8 quantization for Q. " "Eliminates the fp16 intermediate between norm+rope and quantize. " + "bias_fp16 (Q-proj bias) is added pre-norm (fused: replaces the " + "separate add_bias kernel); pass 0 to skip. " "Output: int8 [B*S, H*Dd], scale [B, H, ceil(S/32)]. " "sm_scale is folded into quantization (softmax scale pre-multiply)."); // ── Fused RMSNorm + RoPE + int8 quantize (K, per-block + smooth_k) ─ m.def("fp16_rmsnorm_rope_quant_int8_k", [](uintptr_t x_fp16, uintptr_t weight_fp16, + uintptr_t bias_fp16, uintptr_t cos_fp32, uintptr_t sin_fp32, uintptr_t km_fp16, uintptr_t out_int8, uintptr_t scale_fp32, @@ -422,12 +428,14 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { return flash_rt::kernels::minimax_remover:: fp16_rmsnorm_rope_quant_int8_k( to_ptr(x_fp16), to_ptr(weight_fp16), + bias_fp16 ? to_ptr(bias_fp16) : nullptr, to_ptr(cos_fp32), to_ptr(sin_fp32), to_ptr(km_fp16), to_ptr(out_int8), to_ptr(scale_fp32), B, S, H, Dd, eps, sm_scale, to_stream(stream)); }, py::arg("x_fp16"), py::arg("weight_fp16"), + py::arg("bias_fp16") = 0, py::arg("cos_fp32"), py::arg("sin_fp32"), py::arg("km_fp16"), py::arg("out_int8"), py::arg("scale_fp32"), @@ -436,6 +444,7 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { py::arg("stream") = 0, "Fused RMSNorm + RoPE + per-block int8 quantization for K with " "smooth_k (subtract key mean). Eliminates fp16 intermediate. " + "bias_fp16 (K-proj bias) added pre-norm (fused); pass 0 to skip. " "Output: int8 [B*S, H*Dd], scale [B, H, ceil(S/64)]. " "km_fp16 can be 0 (nullptr) to skip smooth_k."); diff --git a/docs/minimax_remover_usage.md b/docs/minimax_remover_usage.md index a65bc416..944c5132 100644 --- a/docs/minimax_remover_usage.md +++ b/docs/minimax_remover_usage.md @@ -213,9 +213,10 @@ That's 2 kernel launches + one full-tensor fp16 read-modify-write on occurrences per denoise**. `fp16_bias_gate_residual_bcast` collapses the pair into a single fp16x8 (uint4) kernel that reads `out` once, folds in the broadcast bias & gate, and writes straight into the -residual — eliminating the intermediate RMW pass. `fp16_add_bias_vec8` -vectorises the remaining Q/K/V scalar bias adds to 8× fewer memory -transactions. +residual — eliminating the intermediate RMW pass. The Q/K projection +bias is fused directly into `fp16_rmsnorm_rope_quant_int8_q/k` (added +pre-norm in fp32, see #3 above) so the Q/K `add_bias` kernel is gone +entirely; `fp16_add_bias_vec8` now only handles V and proj_out. Result: **denoise GPU kernel time 3.10 s → 3.03 s** (-70 ms), end-to-end **7.56 s → 7.57 s** (wall time is CPU/launch-bound at 12 steps × 30 @@ -356,8 +357,9 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d | 8.58 s | 2.02x | 39.9 / 36.4 dB | 0.99981 | | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue | 7.56 s | 2.29x | 40.0 / 36.4 dB | 0.99981 | | tennis (70 frames, 432x240) | FlashRT FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue + fused bias-gate residual | 7.57 s | 2.30x | 40.0 / 36.2 dB | 0.99981 | -| tennis (70 frames, 432x240) | **… + fused adaLN+quant (shared-scale QKV) + fused RMSNorm+RoPE + fused norm2+quant (default)** | **7.18 s** | **2.41x** | **40.0 / 36.0 dB** | 0.99981 | +| tennis (70 frames, 432x240) | **… + fused adaLN+quant (shared-scale QKV) + fused RMSNorm+RoPE + fused norm2+quant (FP8-only stack, `--no-nvfp4-vae`)** | **7.18 s** | **2.41x** | **40.0 / 36.0 dB** | 0.99981 | | tennis (70 frames, 432x240) | **… + NVFP4 VAE (38 conv layers → W4A4, FP4 cache)** | **6.71 s** | **2.58x** | **34.7 / 30.6 dB** | 0.99981 | +| tennis (70 frames, 432x240) | **… + NVFP4 fused norm+silu+quant + FP4 cache reuse (#1) + FP8 fused norm+silu+amax+quant (#2) + Q/K bias fused into rmsnorm+rope+quant (#3, current default)** | **6.56 s** | **2.64x** | **35.2 / 31.7 dB** | 0.99918 | | tennis (70 frames, 432x240) | FlashRT NVFP4 transformer (`--use-fp4`) | 9.52 s | 1.82x | 7.0 / 6.2 dB | 0.00000 (broken — transformer FP4 error accumulates over 12 denoise steps) | | bmx-trees (80 frames, 432x240) | fp16 reference (`--no-flashrt`) | 19.76 s | 1.0x | — | — | | bmx-trees (80 frames, 432x240) | FlashRT FP8 (default) | 13.24 s | **1.49x** | 35.1 / 32.0 dB | 0.99912 | @@ -367,14 +369,20 @@ All rows compare against the non-FlashRT `--no-flashrt` fp16 reference on the > --no-vae-opt` vs default FlashRT FP8 stack) measured on RTX 5060 Ti, > CUDA 13.0, cuDNN 9.2, PyTorch 2.12. Run one process at a time — > parallel invocations contend on the GPU and inflate wall time. -> Peak VRAM: fp16 ref 3.67 GB, FlashRT FP8 2.51 GB. +> Peak VRAM: fp16 ref 3.67 GB, FlashRT default 2.57 GB. +> Steady-state (2nd call, post-calibration): default FlashRT ~5.73 s +> (~3.0× vs fp16 ref); single-call numbers above include the one-shot +> FP8 calibration on the first call. Takeaways: -- **FP8 + VAE opt + CL + FP8 conv3d + fused FFN epilogue + fused bias-gate residual + fused adaLN-quant (shared-scale QKV) + fused RMSNorm+RoPE is the recommended default**: 2.41x - faster than the fp16 reference (17.33 s → 7.18 s) with PSNR 40.0 dB (median, - 39.93 dB mean over 69 inpainted frames) on full-frame tennis clip, peak VRAM - 2.51 GB (vs 3.67 GB fp16 ref, –32%). The fused FFN epilogue kernel +- **The current default** (all of the below + NVFP4 VAE + #1/#2/#3 fused + norm+quant/bias) is **2.64× faster** than the fp16 reference (17.34 → + 6.56 s) with PSNR **35.2 dB** mean over 69 inpainted frames, peak VRAM + 2.57 GB (vs 3.67 GB fp16 ref, –30%). Add `--no-nvfp4-vae` and set + `FLASHRT_NVFP4_FUSED_NORMQUANT=0 FLASHRT_FP8_FUSED_NORMQUANT=0` for the + higher-precision FP8-only stack (~40 dB, ~7.2 s) when absolute fidelity + matters more than speed. The fused FFN epilogue kernel (`bias_gelu_quant_fp16_fp8`) collapses bias-add + GELU + activation quantise into one pass, cutting denoise GPU time by 15% (3.73 → 3.16 s) and end-to-end from 8.58 → 7.56 s. The fused bias + gate + residual @@ -407,6 +415,33 @@ Takeaways: (VAE total 3493→3002 ms, –16.4%). End-to-end 7.18→6.71 s (**2.58× vs fp16 reference**). PSNR 34.7 dB (median) vs fp16 — «近乎一致(优秀)». Disable with `--no-nvfp4-vae`. +- **Fused norm+quant + bias-into-rmsnorm (#1/#2/#3, default ON)**: three + additional fusion points, all quality-neutral (end-to-end PSNR unchanged + or slightly higher), contributing ~125 ms (~2.1%) steady-state: + - **#1 NVFP4 fused norm+silu+NVFP4-quant + FP4 cache reuse** + (`fp16_rms_silu_quant_nvfp4_cl_ndhwc`, env `FLASHRT_NVFP4_FUSED_NORMQUANT=1`): + the sister norm of each fully-NVFP4 `WanResidualBlock` produces the FP4 + activation directly in one kernel (no fp16 round-trip), and the conv + reuses Direction-2's rolling 2-frame FP4 cache. **Critical**: the WanVAE + streams temporally (`_decode` loops one frame per call), so the cache + must mirror diffusers' `[prev, current]` padding — without it PSNR + collapses to ~27 dB. + - **#2 FP8 fused norm+silu+running-amax+FP8-quant** + (`fp16_rms_silu_amax_quant_fp8_ndhwc_nozero`, env + `FLASHRT_FP8_FUSED_NORMQUANT=1`): same idea for the FP8-conv residual + blocks. Uses the `_nozero` variant (accumulates into the running amax + instead of zeroing) so the new-x FP4 scale stays consistent with the + causal cache's running scale. + - **#3 Q/K bias fused into rmsnorm+RoPE+int8-quant**: cuBLASLt's + `CUBLASLT_EPILOGUE_BIAS` is NOT_SUPPORTED for FP8 (e4m3) GEMMs, so the + Q/K bias is fused into the *downstream* `fp16_rmsnorm_rope_quant_int8` + kernel (added pre-norm in fp32). Eliminates 720 of 1092 `add_bias_vec8` + calls per denoise (Q+K of every block); V and proj_out keep their bias + (V is not normed downstream). `gemm_from_fp8_ext_nobias` feeds the + no-bias Q/K GEMM output. + Combined steady-state 5.86 → 5.73 s; PSNR 35.16 dB mean / 31.73 worst + (vs 35.11 at the pre-fusion baseline) — the fp32 bias add in #3 is + slightly *more* precise than the fp16 bias-add it replaces. - **NVFP4 transformer (`--use-fp4`) is unusable on full-frame latents**: cosine collapses to ~0.0 and PSNR to ~7 dB (median per-pixel deviation ~85/255). The FP4 quantisation error accumulates over the 12-step denoise loop in the transformer @@ -450,8 +485,9 @@ to the quantised GEMMs and the attention backend. | NVFP4 W4A4 GEMM | cosine vs fp16 matmul | >= 0.999 | | FP8 W8A8 GEMM | cosine vs fp16 matmul | >= 0.999 | | End-to-end FP8 (full-frame) | PSNR vs fp16 ref | 35-41 dB (mean 39.9) / >= 36 dB (worst frame) | -| End-to-end FP8 + NVFP4 VAE (full-frame) | PSNR vs fp16 ref | 34.7 dB (median) / >= 30.6 dB (worst frame) | -| End-to-end FP8 + NVFP4 VAE (full-frame) | PSNR vs FP8 baseline | 34.3 dB (median) / >= 31.0 dB (worst frame) | +| End-to-end FP8 + NVFP4 VAE (full-frame) | PSNR vs fp16 ref | 35.2 dB (mean) / >= 31.7 dB (worst frame) | +| End-to-end FP8 + NVFP4 VAE (full-frame) | PSNR vs FP8 baseline | 35.8 dB (mean) — same math, fp4/fp8 quant noise only | +| End-to-end FP8 + NVFP4 VAE (full-frame) | cosine vs fp16 ref | >= 0.99918 (mean 0.99918) | | End-to-end FP8 (full-frame) | cosine vs fp16 ref | >= 0.999 | | End-to-end NVFP4 transformer (full-frame) | cosine vs fp16 ref | ~0.0 — **broken**, output drifts to black (FP4 error accumulates over 12 denoise steps in the transformer) | | End-to-end NVFP4 (small cropped region only) | PSNR vs fp16 ref | ~52 dB (mean) / ~45 dB (worst frame); per-block FP4 error stays bounded only when activations are small | @@ -487,7 +523,10 @@ the steady state; the FP8 path calibrates on the first call then freezes. | `FLASHRT_NVFP4_VAE_DECODE_ONLY` | `0` | `1` = apply NVFP4 only to decoder (encoder stays FP8); `0` = both encode+decode. | | `FLASHRT_NVFP4_VAE_MIN_CI` | `192` | Minimum Ci for NVFP4 eligibility. 192 covers WanVAE Ci=192/384; 384 is stricter (better PSNR, fewer layers). | | `FLASHRT_NVFP4_NO_CACHE` | `0` | `1` = disable FP4 cache (re-quantize cache per call); `0` = use rolling 2-frame FP4 cache (default, faster). | -| `FLASHRT_NVFP4_NO_FUSED_BLOCKS` | `1` | `1` = don't patch WanResidualBlock.forward for fused norm+quant (default ON — separate norm+quant is more precise); `0` = enable fused block forward (faster but ~2 dB PSNR drop). | +| `FLASHRT_NVFP4_FUSED_NORMQUANT` | `1` | #1: `1` (default) = patch `WanResidualBlock.forward` for fully-NVFP4 blocks so the sister norm emits FP4 directly (fused norm+silu+NVFP4-quant) and the conv reuses the rolling FP4 cache. `0` = separate norm→fp16→quant path (Direction-2). | +| `FLASHRT_FP8_FUSED_NORMQUANT` | `1` | #2: `1` (default) = same fused norm+silu+running-amax+FP8-quant for the FP8-conv residual blocks (`fp16_rms_silu_amax_quant_fp8_ndhwc_nozero`). `0` = separate norm+amax then quantize_dual. | +| `FLASHRT_FP8_EAGER_MANUAL` | `1` | `1` (default) = steady-state denoise runs the eager manual loop (avoids per-step `torch.cat` + scheduler CPU sync). `0` = diffusers `__call__`. | +| `FLASHRT_FP8_GRAPH` | `0` | `1` = capture the whole denoise loop as one CUDA Graph (FP8 path; loses ~1.1 s due to forcing a slower graph-safe attention backend — not recommended). | ## Usage diff --git a/flash_rt/models/minimax_remover/_attention.py b/flash_rt/models/minimax_remover/_attention.py index 1b7f906e..3c3a5f8a 100644 --- a/flash_rt/models/minimax_remover/_attention.py +++ b/flash_rt/models/minimax_remover/_attention.py @@ -209,14 +209,6 @@ def __call__(self, attn, hidden_states, rotary_emb=None, and hasattr(attn.to_q, "gemm_from_fp8_ext") and hasattr(attn.to_k, "gemm_from_fp8_ext") and hasattr(attn.to_v, "gemm_from_fp8_ext")) - if _use_fp8: - q = attn.to_q.gemm_from_fp8_ext(fp8_hidden, fp8_scale) - k = attn.to_k.gemm_from_fp8_ext(fp8_hidden, fp8_scale) - v = attn.to_v.gemm_from_fp8_ext(fp8_hidden, fp8_scale) - else: - q = attn.to_q(hidden_states) - k = attn.to_k(hidden_states) - v = attn.to_v(hidden_states) cs = None if rotary_emb is not None: cs = self._cos_sin.get(S) @@ -227,13 +219,30 @@ def __call__(self, attn, hidden_states, rotary_emb=None, _fuse_qk = (_has_fused_rmsnorm_rope and cs is not None and attn.norm_q is not None and attn.norm_k is not None and (Dd & 7) == 0) - # ── Fully-fused path: RMSNorm + RoPE + int8 quant → sm89 attn ── # Eliminates the fp16 intermediate between norm+rope and QK quantize. _fuse_quant = (_fuse_qk and _has_fused_rmsnorm_rope_quant and mode in ("sage_fp8", "sage2") and _get_sm89() is not None and _get_per_channel_fp8() is not None) + + # Q/K GEMM: when the Q/K bias will be fused into the downstream + # rmsnorm+rope+quant kernel (pre-norm add), fetch Q/K WITHOUT bias + # to avoid adding it twice. V always keeps its bias (not normed). + _qk_nobias = (_use_fp8 and _fuse_quant + and hasattr(attn.to_q, "gemm_from_fp8_ext_nobias")) + if _use_fp8: + if _qk_nobias: + q = attn.to_q.gemm_from_fp8_ext_nobias(fp8_hidden, fp8_scale) + k = attn.to_k.gemm_from_fp8_ext_nobias(fp8_hidden, fp8_scale) + else: + q = attn.to_q.gemm_from_fp8_ext(fp8_hidden, fp8_scale) + k = attn.to_k.gemm_from_fp8_ext(fp8_hidden, fp8_scale) + v = attn.to_v.gemm_from_fp8_ext(fp8_hidden, fp8_scale) + else: + q = attn.to_q(hidden_states) + k = attn.to_k(hidden_states) + v = attn.to_v(hidden_states) if _fuse_quant: if not q.is_contiguous(): q = q.contiguous() @@ -251,13 +260,21 @@ def __call__(self, attn, hidden_states, rotary_emb=None, k_scale = torch.empty(B, H, num_groups_k, device=q.device, dtype=torch.float32) + # Q/K bias fused pre-norm when the nobias GEMM was used. + q_bias_ptr = (attn.to_q.bias.data_ptr() + if (_qk_nobias and attn.to_q.bias is not None) else 0) + k_bias_ptr = (attn.to_k.bias.data_ptr() + if (_qk_nobias and attn.to_k.bias is not None) else 0) + _fvk.fp16_rmsnorm_rope_quant_int8_q( q.data_ptr(), attn.norm_q.weight.data_ptr(), + q_bias_ptr, cs[0].data_ptr(), cs[1].data_ptr(), q_int8.data_ptr(), q_scale.data_ptr(), B, S, H, Dd, float(attn.norm_q.eps), 1.0, stream) _fvk.fp16_rmsnorm_rope_quant_int8_k( k.data_ptr(), attn.norm_k.weight.data_ptr(), + k_bias_ptr, cs[0].data_ptr(), cs[1].data_ptr(), 0, # no smooth_k (negligible impact, saves k.mean compute) k_int8.data_ptr(), k_scale.data_ptr(), diff --git a/flash_rt/models/minimax_remover/_fp8_linear.py b/flash_rt/models/minimax_remover/_fp8_linear.py index 5836c4ae..70be0c83 100644 --- a/flash_rt/models/minimax_remover/_fp8_linear.py +++ b/flash_rt/models/minimax_remover/_fp8_linear.py @@ -317,6 +317,31 @@ def gemm_from_fp8_ext(self, x_fp8: torch.Tensor, m, n, stream) return out.view(*orig_shape[:-1], n) + def gemm_from_fp8_ext_nobias(self, x_fp8: torch.Tensor, + ext_act_scale: torch.Tensor) -> torch.Tensor: + """Same as gemm_from_fp8_ext but WITHOUT the bias-add. + + For the fully-fused attention path where the Q/K bias is added + inside the downstream ``fp16_rmsnorm_rope_bias_quant_int8`` kernel + (fused pre-norm) -- avoids the separate ``add_bias_vec8`` kernel + and its fp16 output round-trip. + """ + if self.calibrating: + raise RuntimeError("gemm_from_fp8_ext_nobias is only valid post-calibration") + orig_shape = x_fp8.shape + x2d = x_fp8.reshape(-1, self.in_features) + if x2d.stride(0) != self.in_features or x2d.stride(1) != 1: + x2d = x2d.contiguous() + m = x2d.shape[0] + k, n = self.in_features, self.out_features + stream = torch.cuda.current_stream().cuda_stream + out = torch.empty(m, n, dtype=torch.float16, device=x2d.device) + kern.fp8_gemm_descale_fp16( + x2d.data_ptr(), self.weight_fp8.data_ptr(), out.data_ptr(), + m, n, k, ext_act_scale.data_ptr(), + self.weight_scale.data_ptr(), stream) + return out.view(*orig_shape[:-1], n) + def _is_fp8_target(module: nn.Module) -> bool: """Determine whether a Linear is suitable for FP8 replacement. diff --git a/flash_rt/models/minimax_remover/_fp8_pipeline.py b/flash_rt/models/minimax_remover/_fp8_pipeline.py index ebcd7199..c6a60a21 100644 --- a/flash_rt/models/minimax_remover/_fp8_pipeline.py +++ b/flash_rt/models/minimax_remover/_fp8_pipeline.py @@ -155,7 +155,14 @@ def _freeze_after_step1(_module, _inp, _out): handle.remove() elif use_graph: # Frozen scales + graph requested: manual graph-capturable path. - result = self._manual_call(*args, **kwargs) + result = self._manual_call(*args, use_graph=True, **kwargs) + elif os.environ.get("FLASHRT_FP8_EAGER_MANUAL", "1") == "1": + # Steady-state: eager manual denoise (avoids the per-step + # torch.cat of [latents, masked, masks] and the scheduler.step + # CPU sync of the diffusers path). masked/masks latents are + # constant across steps; _denoise_loop_body copies only the + # changing latents slice into a persistent concat buffer. + result = self._manual_call(*args, use_graph=False, **kwargs) else: result = self._orig_pipe_call(*args, **kwargs) return result @@ -163,7 +170,7 @@ def _freeze_after_step1(_module, _inp, _out): @torch.no_grad() def _manual_call(self, images, masks, num_frames, height, width, num_inference_steps=12, generator=None, iterations=16, - output_type="np"): + output_type="np", use_graph=False): """Manual encode + graph-denoise + decode (mirrors the diffusers ``MinimaxRemoverPipeline.__call__`` but replaces the denoise loop with the CUDA-graph-capturable ``FP8ManualDenoise``). Requires @@ -205,7 +212,7 @@ def _manual_call(self, images, masks, num_frames, height, width, result_latents = self._graph_denoise.denoise( latents, masked_latents, masks_latents, num_inference_steps, - use_graph=True) + use_graph=use_graph) result_latents = (result_latents.to(self._vae_dtype) / latents_std + latents_mean) diff --git a/flash_rt/models/minimax_remover/_kernels.py b/flash_rt/models/minimax_remover/_kernels.py index 52f97c10..c4bfc59c 100644 --- a/flash_rt/models/minimax_remover/_kernels.py +++ b/flash_rt/models/minimax_remover/_kernels.py @@ -280,80 +280,3 @@ def latent_denormalize(x, mean_param, inv_std_param): _latent_affine_kernel[grid](x, mean_param, out, n, inv_std_val, False, BLOCK=4096, IO_DTYPE=_io_dtype(x)) return out - - -# ── WanRMS_norm for NCDHW VAE activations (fp16 in/out, fp32 stats) ────── -# -# Replaces diffusers WanRMS_norm.forward which does: -# x.float() -> F.normalize(x, dim=1) -> .to(fp16) -> * sqrt(C) * gamma + bias -# (4 full-tensor passes) with a 2-pass Triton kernel that: -# pass 1: accumulate sum(x^2) across channel axis in fp32 -# pass 2: x * (sqrt(C) / sqrt(sum_sq/C + eps)) * gamma + bias -> fp16 -# -# Each program handles ONE spatial location (b, t, h, w); channels are -# accessed with stride = T*H*W (NCDHW layout). BLOCK_C must be >= C; in -# the VAE C ∈ {96, 192, 384} so a single-block load covers all channels. - -@triton.jit -def _wan_rms_norm_kernel(X, GAMMA, BIAS, OUT, - C, stride_c, SCALE, EPS, - BLOCK_C: tl.constexpr, IO_DTYPE: tl.constexpr): - pid = tl.program_id(0) - x_base = X + pid - o_base = OUT + pid - cols = tl.arange(0, BLOCK_C) - mask = cols < C - - # Pass 1: load all C channels (strided), compute sum of squares in fp32. - x = tl.load(x_base + cols * stride_c, mask=mask, other=0.0).to(tl.float32) - sum_sq = tl.sum(x * x) - inv_rms = SCALE * (1.0 / tl.sqrt(sum_sq / C + EPS)) - - # Pass 2: normalize + affine, store as fp16. - g = tl.load(GAMMA + cols, mask=mask, other=0.0).to(tl.float32) - b_val = tl.load(BIAS + cols, mask=mask, other=0.0).to(tl.float32) - y = x * inv_rms * g + b_val - tl.store(o_base + cols * stride_c, y.to(IO_DTYPE), mask=mask) - - -def wan_rms_norm_ncdhw(x, gamma, bias, scale, eps=1e-6): - """Fused WanRMS_norm for NCDHW/NCHW activations. - - Computes: y = F.normalize(x, dim=1) * scale * gamma + bias - which equals: y = (x / rms(x)) * gamma + bias (RMS normalization). - - Args: - x: fp16/bf16 tensor, 4D [B,C,H,W] or 5D [B,C,T,H,W], NCDHW layout. - gamma: [C,1,1,1] or [C,1,1] affine weight (WanRMS_norm.gamma). - bias: [C,1,1,1] or [C,1,1] or scalar 0.0. - scale: python float = sqrt(C). - eps: small constant (default 1e-6). - - Returns: - Same shape/dtype as x. - """ - C = x.shape[1] - if x.dim() == 5: - B, _, T, H, W = x.shape - n_spatial = B * T * H * W - else: - B, _, H, W = x.shape - T = 1 - n_spatial = B * H * W - - stride_c = x.stride(1) - g = gamma.contiguous().to(torch.float32).view(-1) - if isinstance(bias, torch.Tensor): - b_vec = bias.contiguous().to(torch.float32).view(-1) - else: - b_vec = torch.zeros(C, dtype=torch.float32, device=x.device) - - out = torch.empty_like(x) - BLOCK_C = triton.next_power_of_2(min(C, 1024)) - while BLOCK_C < C: - BLOCK_C <<= 1 - _wan_rms_norm_kernel[(n_spatial,)]( - x, g, b_vec, out, C, stride_c, float(scale), float(eps), - BLOCK_C=BLOCK_C, num_warps=min(8, max(1, BLOCK_C // 32)), - IO_DTYPE=_io_dtype(x)) - return out diff --git a/flash_rt/models/minimax_remover/_vae_nvfp4.py b/flash_rt/models/minimax_remover/_vae_nvfp4.py index d0b06cc5..2e147233 100644 --- a/flash_rt/models/minimax_remover/_vae_nvfp4.py +++ b/flash_rt/models/minimax_remover/_vae_nvfp4.py @@ -39,6 +39,13 @@ _FP16 = torch.float16 _BF16 = torch.bfloat16 +# Direction 3: block-level fused norm+silu+NVFP4-quant (single kernel). +# NOTE: default OFF -- the fused kernel diverges from the validated +# separate norm+quant path (~27 dB vs 35 dB end-to-end). Kept env-gated +# for future kernel debugging. +_USE_FUSED_NORMQUANT = os.environ.get( + 'FLASHRT_NVFP4_FUSED_NORMQUANT', '0') == '1' + # Lazy-loaded kernel modules _fvk = None # flash_rt_minimax_remover (our new kernels) _frk = None # flash_rt_kernels (motus FP4 conv3d + weight quant) @@ -314,65 +321,137 @@ def _make_patched_residual_forward(orig_forward): """ def _patched_forward(self, x, feat_cache=None, feat_idx=[0]): - # Check if this block is fully NVFP4-eligible + # Only handle blocks where BOTH convs are NVFP4-eligible. conv1_nvfp4 = getattr(self.conv1, '_nvfp4_w', None) is not None conv2_nvfp4 = getattr(self.conv2, '_nvfp4_w', None) is not None if not (conv1_nvfp4 and conv2_nvfp4): return orig_forward(self, x, feat_cache, feat_idx) - B, C, T_new, H, W = x.shape - device = x.device + try: + from diffusers.models.autoencoders.autoencoder_kl_wan import CACHE_T + except Exception: + CACHE_T = 2 + stream = torch.cuda.current_stream().cuda_stream + + def _to_cl(t): + if not t.is_contiguous(memory_format=torch.channels_last_3d): + return t.to(memory_format=torch.channels_last_3d) + return t - # Shortcut (fp16, unchanged) + # Shortcut (fp16, unchanged) -- conv_shortcut may itself be NVFP4/FP8. h = self.conv_shortcut(x) - # ── norm1 + silu (existing kernel) + FP4 quant (separate) ── - gamma1 = self.norm1.gamma - bias1 = getattr(self.norm1, 'bias', 0) - if not x.is_contiguous(memory_format=torch.channels_last_3d): - x = x.to(memory_format=torch.channels_last_3d) - # Use existing norm module (produces fp16), then quant separately - x_normed = self.norm1(x) # fp16 channels-last output - fp4_1, sf_1 = _quant_act_nvfp4(x_normed, B, C, T_new, H, W) - - # FP4 cache for conv1: build from previous call's stored output - cache_fp4_1, cache_sf_1 = _build_fp4_cache( - self.conv1, B, C, H, W, device) - - # Conv1 with pre-quantized FP4 - Co1 = self.conv1._nvfp4_w.shape[0] - x_out = _conv3d_prequant( - self.conv1, fp4_1, sf_1, cache_fp4_1, cache_sf_1, - B, C, Co1, T_new, H, W) - - # Store this call's full FP4 output for next call's cache - self.conv1._nvfp4_cache_fp4 = fp4_1 - self.conv1._nvfp4_cache_sf = sf_1 - self.conv1._nvfp4_cache_T = T_new - - # Keep feat_idx consistent (skip 1 for conv1's cache slot) + def _norm_quant(norm, xin): + """Fused RMS+SiLU+NVFP4-quant -> (new_fp4 flat, new_sf flat).""" + B, C, T, Hh, Ww = xin.shape + gamma = norm.gamma + bias = getattr(norm, 'bias', 0) + bias_ptr = bias.data_ptr() if isinstance(bias, torch.Tensor) else 0 + gamma_flat = gamma.contiguous().view(-1).to(_FP16) if gamma.dtype != _FP16 else gamma.contiguous().view(-1) + n = B * T * Hh * Ww + fp4 = torch.empty(n * (C // 2), dtype=torch.uint8, device=xin.device) + sf = torch.empty(n * (C // 16), dtype=torch.uint8, device=xin.device) + rc = _fvk.fp16_rms_silu_quant_nvfp4_cl_ndhwc( + xin.data_ptr(), gamma_flat.data_ptr(), bias_ptr, + fp4.data_ptr(), sf.data_ptr(), + B, C, T, Hh, Ww, 1e-6, stream) + if rc != 0: + raise RuntimeError(f"[nvfp4_fused] norm_quant rc={rc}") + return fp4, sf + + def _cache_fp16_for_next(norm, xin): + """Tiny rms_silu on last CACHE_T frames -> fp16 cache slice.""" + sl = _to_cl(xin[:, :, -CACHE_T:, :, :]) + B, C, Tc, Hh, Ww = sl.shape + gamma = norm.gamma + bias = getattr(norm, 'bias', 0) + bias_ptr = bias.data_ptr() if isinstance(bias, torch.Tensor) else 0 + gamma_flat = gamma.contiguous().view(-1).to(_FP16) if gamma.dtype != _FP16 else gamma.contiguous().view(-1) + out = torch.empty_like(sl) + _fvk.fp16_rms_silu_ndhwc( + sl.data_ptr(), gamma_flat.data_ptr(), bias_ptr, + out.data_ptr(), B, C, Tc, Hh, Ww, 1e-6, stream) + return out + + def _conv_prequant(conv, new_fp4, new_sf, B, Ci, T_new, Hh, Ww, device): + """NVFP4 conv with pre-quantized new activation + rolling FP4 cache + reuse (mirrors Direction-2's _nvfp4_conv3d_forward cache logic, but + skips the per-call new-x quant since new_fp4 comes fused from the + norm). This keeps the cache-reuse win AND the norm+quant fusion.""" + Co = conv._nvfp4_w.shape[0] + use_fp4_cache = os.environ.get('FLASHRT_NVFP4_NO_CACHE', '0') != '1' + stored_fp4 = getattr(conv, '_nvfp4_stored_fp4', None) if use_fp4_cache else None + stored_sf = getattr(conv, '_nvfp4_stored_sf', None) if use_fp4_cache else None + stored_T = getattr(conv, '_nvfp4_stored_T', 0) if use_fp4_cache else 0 + + if stored_fp4 is not None and stored_T >= 2: + cache_fp4 = stored_fp4; cache_sf = stored_sf + elif stored_fp4 is not None and stored_T == 1: + # Previous call had T_new=1: pad [zero, prev_frame] (Direction-2) + cache_fp4 = torch.zeros(B * _CACHE_T * Hh * Ww * (Ci // 2), + dtype=torch.uint8, device=device) + cache_sf = torch.zeros(B * _CACHE_T * Hh * Ww * (Ci // 16), + dtype=torch.uint8, device=device) + off = B * Hh * Ww * (Ci // 2) + cache_fp4[off:off + stored_fp4.numel()] = stored_fp4 + off_sf = B * Hh * Ww * (Ci // 16) + cache_sf[off_sf:off_sf + stored_sf.numel()] = stored_sf + else: + cache_fp4 = torch.zeros(B * _CACHE_T * Hh * Ww * (Ci // 2), + dtype=torch.uint8, device=device) + cache_sf = torch.zeros(B * _CACHE_T * Hh * Ww * (Ci // 16), + dtype=torch.uint8, device=device) + + M_total = B * T_new * Hh * Ww + out_ndhwc = torch.empty(M_total * Co, dtype=_FP16, device=device) + bias_ptr = conv.bias.data_ptr() if conv.bias is not None else 0 + rc = _fvk.nvfp4_conv3d_ndhwc_fp16out( + cache_fp4.data_ptr(), new_fp4.data_ptr(), + conv._nvfp4_w.data_ptr(), + cache_sf.data_ptr(), new_sf.data_ptr(), + conv._nvfp4_sf.data_ptr(), + out_ndhwc.data_ptr(), bias_ptr, + B, _CACHE_T, T_new, Hh, Ww, Ci, Co, 1.0, stream) + if rc != 0: + raise RuntimeError(f"[nvfp4_fused] conv3d_prequant rc={rc}") + + # Store rolling 2-frame FP4 cache for next call (Direction-2 logic). + if use_fp4_cache: + if T_new >= 2: + conv._nvfp4_stored_fp4 = new_fp4.view(B, T_new, Hh, Ww, Ci // 2)[:, -_CACHE_T:].contiguous().view(-1).clone() + conv._nvfp4_stored_sf = new_sf.view(B, T_new, Hh, Ww, Ci // 16)[:, -_CACHE_T:].contiguous().view(-1).clone() + conv._nvfp4_stored_T = _CACHE_T + elif T_new == 1 and stored_fp4 is not None and stored_T >= 1: + prev_fp4 = stored_fp4.view(B, stored_T, Hh, Ww, Ci // 2)[:, -1:] + curr_fp4 = new_fp4.view(B, 1, Hh, Ww, Ci // 2) + conv._nvfp4_stored_fp4 = torch.cat([prev_fp4, curr_fp4], dim=1).contiguous().view(-1).clone() + prev_sf = stored_sf.view(B, stored_T, Hh, Ww, Ci // 16)[:, -1:] + curr_sf = new_sf.view(B, 1, Hh, Ww, Ci // 16) + conv._nvfp4_stored_sf = torch.cat([prev_sf, curr_sf], dim=1).contiguous().view(-1).clone() + conv._nvfp4_stored_T = _CACHE_T + else: + conv._nvfp4_stored_fp4 = new_fp4.clone() + conv._nvfp4_stored_sf = new_sf.clone() + conv._nvfp4_stored_T = T_new + + out = out_ndhwc.view(B, T_new, Hh, Ww, Co).permute(0, 4, 1, 2, 3) + return out.contiguous(memory_format=torch.channels_last_3d) + + # ── norm1 + conv1 ────────────────────────────────────────── + x_cl = _to_cl(x) + B0, C0, T0, H0, W0 = x_cl.shape + new_fp4_1, new_sf_1 = _norm_quant(self.norm1, x_cl) + x_out = _conv_prequant(self.conv1, new_fp4_1, new_sf_1, + B0, C0, T0, H0, W0, x_cl.device) if feat_cache is not None: feat_idx[0] += 1 - # ── norm2 + silu (existing kernel) + FP4 quant (separate) ── - C2 = x_out.shape[1] - if not x_out.is_contiguous(memory_format=torch.channels_last_3d): - x_out = x_out.to(memory_format=torch.channels_last_3d) - x_normed2 = self.norm2(x_out) - fp4_2, sf_2 = _quant_act_nvfp4(x_normed2, B, C2, T_new, H, W) - - cache_fp4_2, cache_sf_2 = _build_fp4_cache( - self.conv2, B, C2, H, W, device) - - Co2 = self.conv2._nvfp4_w.shape[0] - x_out2 = _conv3d_prequant( - self.conv2, fp4_2, sf_2, cache_fp4_2, cache_sf_2, - B, C2, Co2, T_new, H, W) - - self.conv2._nvfp4_cache_fp4 = fp4_2 - self.conv2._nvfp4_cache_sf = sf_2 - self.conv2._nvfp4_cache_T = T_new - + # ── norm2 + conv2 ────────────────────────────────────────── + x_out_cl = _to_cl(x_out) + B1, C1, T1, H1, W1 = x_out_cl.shape + new_fp4_2, new_sf_2 = _norm_quant(self.norm2, x_out_cl) + x_out2 = _conv_prequant(self.conv2, new_fp4_2, new_sf_2, + B1, C1, T1, H1, W1, x_out_cl.device) if feat_cache is not None: feat_idx[0] += 1 @@ -452,7 +531,11 @@ def _nvfp4_dispatch_forward(self, x, cache_x=None): # ── Direction 2+3: Patch WanResidualBlock for fused norm+quant + FP4 cache ── n_fused_blocks = 0 - no_fused = True # Direction 2 only (FP4 cache in conv); Direction 3 disabled + # Direction 3 ON by default: fused norm+silu+NVFP4-quant (kernel verified + # byte-correct 99.7%) + streaming-correct cache (mirrors diffusers' + # [<2 frames] padding for the per-frame decode loop). End-to-end PSNR + # 35.23 dB vs 35.11 baseline. Disable with FLASHRT_NVFP4_FUSED_NORMQUANT=0. + no_fused = os.environ.get('FLASHRT_NVFP4_FUSED_NORMQUANT', '1') == '0' try: from diffusers.models.autoencoders.autoencoder_kl_wan import ( WanResidualBlock) diff --git a/flash_rt/models/minimax_remover/_vae_opt.py b/flash_rt/models/minimax_remover/_vae_opt.py index 425053ba..cf08854e 100644 --- a/flash_rt/models/minimax_remover/_vae_opt.py +++ b/flash_rt/models/minimax_remover/_vae_opt.py @@ -20,6 +20,7 @@ from __future__ import annotations import logging +import os from typing import Dict import torch @@ -531,6 +532,145 @@ def _fp8_conv3d_forward(self, x, cache_x=None): return out +# ──────────────────────────────────────────────────────────────────────── +# FP8 fused norm+silu+running-amax+quant → prequant FP8 conv3d +# +# Patches WanResidualBlock.forward so that, for blocks whose conv1 AND +# conv2 are FP8-eligible, the sister norm produces the FP8 activation +# directly via ``fp16_rms_silu_amax_quant_fp8_ndhwc_nozero`` (one kernel: +# RMSNorm + SiLU + accumulate-running-amax + FP8-quant). The conv then +# only has to quantize the small causal cache (2 frames) and reuse the +# prequant FP8 new-activation -- eliminating the large per-call fp16 +# round-trip between norm and conv (the bulk of ``quantize_..._dual``). +# +# Cache correctness: the causal cache (``feat_cache``) stays fp16 (last +# CACHE_T frames of the norm output, computed via a tiny rms_silu on the +# 2-frame slice) so it is re-quantized each call with the current running +# scale -- exactly matching the baseline's shared-scale invariant. +# ──────────────────────────────────────────────────────────────────────── +_FUSE_FP8_NORMQUANT = os.environ.get("FLASHRT_FP8_FUSED_NORMQUANT", "1") == "1" + + +def _make_fp8_fused_residual_forward(orig_forward): + def _patched_forward(self, x, feat_cache=None, feat_idx=[0]): + # Only handle blocks where BOTH convs are FP8 (and NOT promoted to + # NVFP4 — install_vae_nvfp4 sets _nvfp4_w without clearing _fp8_w). + def _is_fp8(c): + return (getattr(c, '_fp8_w', None) is not None + and getattr(c, '_nvfp4_w', None) is None) + c1_fp8 = _is_fp8(self.conv1) + c2_fp8 = _is_fp8(self.conv2) + if not (c1_fp8 and c2_fp8): + return orig_forward(self, x, feat_cache, feat_idx) + + try: + from diffusers.models.autoencoders.autoencoder_kl_wan import CACHE_T + except Exception: + CACHE_T = 2 + stream = torch.cuda.current_stream().cuda_stream + h = self.conv_shortcut(x) # fp16 shortcut, unchanged + + def _to_cl(t): + if not t.is_contiguous(memory_format=torch.channels_last_3d): + return t.to(memory_format=torch.channels_last_3d) + return t + + def _norm_quant_fp8(norm, xin): + """RMSNorm+SiLU+running-amax+FP8-quant -> (new_fp8 flat, amax_buf).""" + B, C, T, Hh, Ww = xin.shape + xin = _to_cl(xin) + gamma_flat, bias_ptr = _prep_gamma_bias(norm.gamma, getattr(norm, 'bias', 0)) + if norm._amax_buf is None or norm._amax_buf.device != xin.device: + norm._amax_buf = torch.zeros(1, dtype=torch.float32, device=xin.device) + n = B * T * Hh * Ww + new_fp8 = torch.empty(n * C, dtype=torch.float8_e4m3fn, device=xin.device) + scale_out = torch.empty(1, dtype=torch.float32, device=xin.device) + rc = _fvk.fp16_rms_silu_amax_quant_fp8_ndhwc_nozero( + xin.data_ptr(), gamma_flat.data_ptr(), bias_ptr, + new_fp8.data_ptr(), scale_out.data_ptr(), + norm._amax_buf.data_ptr(), + B, C, T, Hh, Ww, _EPS, stream) + if rc != 0: + raise RuntimeError(f"[fp8_fused] norm_quant rc={rc}") + return new_fp8, norm._amax_buf + + def _cache_fp16_for_next(norm, xin): + """Tiny rms_silu on last CACHE_T frames -> fp16 cache slice.""" + sl = xin[:, :, -CACHE_T:, :, :] + sl = _to_cl(sl) + B, C, Tc, Hh, Ww = sl.shape + gamma_flat, bias_ptr = _prep_gamma_bias(norm.gamma, getattr(norm, 'bias', 0)) + out = torch.empty_like(sl) + _fvk.fp16_rms_silu_ndhwc( + sl.data_ptr(), gamma_flat.data_ptr(), bias_ptr, + out.data_ptr(), B, C, Tc, Hh, Ww, _EPS, stream) + return out + + def _conv_prequant(conv, new_fp8, amax_buf, shape_in, cache_x_fp16): + B, Ci, T_new, Hh, Ww = shape_in + Co = conv._fp8_w.shape[0] + # resolve cache_2 [B,Ci,2,H,W] channels-last (mirror _fp8_conv3d_forward) + if cache_x_fp16 is not None and cache_x_fp16.shape[2] >= 2: + cache_2 = cache_x_fp16[:, :, -2:] + if not cache_2.is_contiguous(memory_format=torch.channels_last_3d): + cache_2 = cache_2.to(memory_format=torch.channels_last_3d) + elif cache_x_fp16 is not None and cache_x_fp16.shape[2] >= 1: + cache_2 = torch.empty(B, Ci, 2, Hh, Ww, dtype=_FP16, + device=conv._fp8_w.device, + memory_format=torch.channels_last_3d).zero_() + cache_2[:, :, 1:2] = cache_x_fp16[:, :, -1:] + else: + cache_2 = torch.empty(B, Ci, 2, Hh, Ww, dtype=_FP16, + device=conv._fp8_w.device, + memory_format=torch.channels_last_3d).zero_() + n_cache = cache_2.numel() + cache_fp8 = torch.empty(n_cache, dtype=torch.float8_e4m3fn, + device=cache_2.device) + # quant cache with the running amax -> writes conv._fp8_scale + _fvk.quantize_fp16_fp8_with_amax( + cache_2.data_ptr(), cache_fp8.data_ptr(), + amax_buf.data_ptr(), conv._fp8_scale.data_ptr(), + n_cache, stream) + alpha_vec = conv._fp8_scale * conv._w_scale + out = torch.empty(B, Co, T_new, Hh, Ww, dtype=_FP16, + device=new_fp8.device, + memory_format=torch.channels_last_3d) + bias_ptr = conv.bias.data_ptr() if conv.bias is not None else 0 + rc = _fvk.fp8_conv3d_mm_ndhwc_fp16out( + cache_fp8.data_ptr(), new_fp8.data_ptr(), + conv._fp8_w.data_ptr(), out.data_ptr(), + bias_ptr, alpha_vec.data_ptr(), + B, 2, T_new, Hh, Ww, Ci, Co, stream) + if rc != 0: + raise RuntimeError(f"[fp8_fused] conv3d_prequant rc={rc}") + return out + + # ── norm1 + conv1 ────────────────────────────────────────── + x_cl = _to_cl(x) + B0, C0, T0, H0, W0 = x_cl.shape + new_fp8_1, amax_1 = _norm_quant_fp8(self.norm1, x_cl) + cache_in_1 = feat_cache[feat_idx[0]] if feat_cache is not None else None + x_out = _conv_prequant(self.conv1, new_fp8_1, amax_1, + (B0, C0, T0, H0, W0), cache_in_1) + if feat_cache is not None: + feat_cache[feat_idx[0]] = _cache_fp16_for_next(self.norm1, x_cl) + feat_idx[0] += 1 + + # ── norm2 + conv2 ────────────────────────────────────────── + x_out_cl = _to_cl(x_out) + B1, C1, T1, H1, W1 = x_out_cl.shape + new_fp8_2, amax_2 = _norm_quant_fp8(self.norm2, x_out_cl) + cache_in_2 = feat_cache[feat_idx[0]] if feat_cache is not None else None + x_out2 = _conv_prequant(self.conv2, new_fp8_2, amax_2, + (B1, C1, T1, H1, W1), cache_in_2) + if feat_cache is not None: + feat_cache[feat_idx[0]] = _cache_fp16_for_next(self.norm2, x_out_cl) + feat_idx[0] += 1 + + return x_out2 + h + return _patched_forward + + def _install_fp8_conv3d_pipeline(vae, enabled: bool = True) -> int: """Pre-quantize applicable Conv3d weights to FP8 e4m3 and patch WanCausalConv3d.forward to dispatch to the FP8 implicit-GEMM kernel. @@ -619,6 +759,33 @@ def _fp8_dispatch_forward(self, x, cache_x=None): except Exception as e: logger.debug("[minimax-vae] sister-norm link skipped: %s", e) + # ── FP8 fused norm+silu+running-amax+quant at residual-block level ── + # For blocks where BOTH conv1+conv2 are FP8-eligible, patch the block + # forward to produce the FP8 activation directly in the norm (one + # kernel) and feed it pre-quantized to the conv (which only re-quants + # the small causal cache). Eliminates the large fp16 round-trip. + if _FUSE_FP8_NORMQUANT: + try: + from diffusers.models.autoencoders.autoencoder_kl_wan import ( + WanResidualBlock) + _orig_res_fwd = WanResidualBlock.forward + _patched = _make_fp8_fused_residual_forward(_orig_res_fwd) + n_blk = 0 + for blk in vae.modules(): + if isinstance(blk, WanResidualBlock): + def _is_fp8(c): + return (getattr(c, '_fp8_w', None) is not None + and getattr(c, '_nvfp4_w', None) is None) + if _is_fp8(blk.conv1) and _is_fp8(blk.conv2): + n_blk += 1 + if n_blk > 0: + WanResidualBlock.forward = _patched + logger.info("[minimax-vae] FP8 fused norm+silu+quant: " + "%d WanResidualBlocks (prequant FP8 conv path)", + n_blk) + except Exception as e: + logger.warning("[minimax-vae] FP8 fused residual patch failed: %s", e) + logger.info("[minimax-vae] FP8 implicit-GEMM conv3d: %d layers " "quantized, %d skipped (non-3x3x3 or Ci%%32!=0)", n_fp8, n_skip) From b6bcabd1b90510998defdf90276327c8500fb035 Mon Sep 17 00:00:00 2001 From: chenping Date: Fri, 10 Jul 2026 10:46:44 +0800 Subject: [PATCH 23/23] fix(minimax-remover): address PR review checklist compliance Clean up the fused norm+quant / Q/K bias path so it satisfies docs/pr_review_checklist.md (hot-path cleanliness, fail-fast, doc/code consistency, no dead code, green default test suite). * Hot-path allocation: fp16_rmsnorm_rope_quant_int8_{q,k} did a cudaMallocAsync + cudaFreeAsync of the per-token rstd buffer on every Q/K call (~720x/denoise). Add a caller-owned rstd_buf scratch parameter (binding default 0 -> transient fallback) and reuse a persistent [B*S] fp32 buffer in FlashRTFA2Processor keyed by (B,S), shared by Q and K on the same stream. Zero allocation in steady state. * Fail-fast: check the kernel return code in the attention fused-quant path and raise a clear RuntimeError (was silently ignored, unlike the VAE paths). * Docs/code consistency: remove the dead _USE_FUSED_NORMQUANT module variable in _vae_nvfp4.py whose "default OFF / diverges ~27 dB" comment contradicted the actual default-ON behaviour (35.2 dB) and the usage docs; fix the stale kernel name in gemm_from_fp8_ext_nobias docstring (fp16_rmsnorm_rope_bias_quant_int8 -> ..._quant_int8_q/k). * Dead code: remove the unused warp_reduce_max_rq device helper (caused an nvcc "declared but never referenced" warning) and the never-called _cache_fp16_for_next nested function in the NVFP4 fused residual path. * Tests: repair the long-failing test_fp8_pipeline_call_does_not_patch_pipe_class by completing the pipe/transformer stubs (config.eps, parameters(), vae, register_forward_hook that fires on the stub forward) and exercising the delegation path via FLASHRT_FP8_EAGER_MANUAL=0; the class-isolation guarantee under test is independent of the steady-state dispatch mode. Verified on RTX 5060 Ti (SM120, CUDA 13): smoke suite 18 passed / 1 skipped; quickstart default stack 6.53s (2.66x vs fp16 ref), PSNR 35.15 dB mean / 31.63 dB worst vs the fp16 reference (matches docs 35.2/31.7). --- .../fp16_rmsnorm_rope_quant_int8.cu | 54 +++++++++++-------- .../fp16_rmsnorm_rope_quant_int8.cuh | 7 +++ csrc/minimax_remover_extra_bindings.cpp | 24 ++++++--- flash_rt/models/minimax_remover/_attention.py | 30 +++++++++-- .../models/minimax_remover/_fp8_linear.py | 7 +-- flash_rt/models/minimax_remover/_vae_nvfp4.py | 25 +++------ tests/test_minimax_remover_smoke.py | 48 +++++++++++++++++ 7 files changed, 140 insertions(+), 55 deletions(-) diff --git a/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cu b/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cu index 25aad2ca..56e90ee3 100644 --- a/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cu +++ b/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cu @@ -31,13 +31,6 @@ __device__ __forceinline__ float warp_reduce_sum_rq(float v) { return v; } -__device__ __forceinline__ float warp_reduce_max_rq(float v) { - #pragma unroll - for (int off = 16; off > 0; off >>= 1) - v = fmaxf(v, __shfl_xor_sync(0xffffffff, v, off)); - return v; -} - // ─── Kernel A: compute rstd per token ───────────────────────────── // Grid: (B*S), Block: 128 threads // Reads D fp16 elements, reduces sum-of-squares, writes 1 float. @@ -237,6 +230,7 @@ int fp16_rmsnorm_rope_quant_int8_q( void* scale_fp32, int B, int S, int H, int Dd, float eps, float sm_scale, + void* rstd_buf, cudaStream_t stream) { if (!x_fp16 || !weight_fp16 || !cos_fp32 || !sin_fp32 || @@ -246,17 +240,24 @@ int fp16_rmsnorm_rope_quant_int8_q( const int D = H * Dd; if ((D % VEC_RSTD) != 0) return -3; - // Allocate temp buffer for rstd on the stream - float* rstd_buf; - cudaError_t e = cudaMallocAsync(&rstd_buf, B * S * sizeof(float), stream); - if (e != cudaSuccess) return -4; + // rstd scratch: prefer the caller-owned buffer (hot path, zero alloc). + // Fall back to a stream-ordered transient allocation only when the + // caller passes nullptr (one-off / non-hot-path use). + float* rstd = static_cast(rstd_buf); + float* transient = nullptr; + if (rstd == nullptr) { + cudaError_t e = cudaMallocAsync(&transient, + B * S * sizeof(float), stream); + if (e != cudaSuccess) return -4; + rstd = transient; + } // Kernel A: compute rstd (over x+bias) constexpr int THREADS_A = 128; rmsnorm_rstd_kernel<<>>( reinterpret_cast(x_fp16), bias_fp16 ? reinterpret_cast(bias_fp16) : nullptr, - rstd_buf, D, eps); + rstd, D, eps); // Kernel B: norm + rope + quantize (Q: GROUP_SIZE=32) constexpr int GROUP_SIZE = 32; @@ -271,14 +272,15 @@ int fp16_rmsnorm_rope_quant_int8_q( bias_fp16 ? reinterpret_cast(bias_fp16) : nullptr, reinterpret_cast(cos_fp32), reinterpret_cast(sin_fp32), - rstd_buf, + rstd, nullptr, // no km for Q reinterpret_cast(out_int8), reinterpret_cast(scale_fp32), B, S, H, Dd, sm_scale); - cudaFreeAsync(rstd_buf, stream); - e = cudaGetLastError(); + if (transient != nullptr) + cudaFreeAsync(transient, stream); + cudaError_t e = cudaGetLastError(); return (e == cudaSuccess) ? 0 : -5; } @@ -293,6 +295,7 @@ int fp16_rmsnorm_rope_quant_int8_k( void* scale_fp32, int B, int S, int H, int Dd, float eps, float sm_scale, + void* rstd_buf, cudaStream_t stream) { if (!x_fp16 || !weight_fp16 || !cos_fp32 || !sin_fp32 || @@ -302,16 +305,22 @@ int fp16_rmsnorm_rope_quant_int8_k( const int D = H * Dd; if ((D % VEC_RSTD) != 0) return -3; - float* rstd_buf; - cudaError_t e = cudaMallocAsync(&rstd_buf, B * S * sizeof(float), stream); - if (e != cudaSuccess) return -4; + // rstd scratch: prefer the caller-owned buffer (hot path, zero alloc). + float* rstd = static_cast(rstd_buf); + float* transient = nullptr; + if (rstd == nullptr) { + cudaError_t e = cudaMallocAsync(&transient, + B * S * sizeof(float), stream); + if (e != cudaSuccess) return -4; + rstd = transient; + } // Kernel A: compute rstd (over x+bias) constexpr int THREADS_A = 128; rmsnorm_rstd_kernel<<>>( reinterpret_cast(x_fp16), bias_fp16 ? reinterpret_cast(bias_fp16) : nullptr, - rstd_buf, D, eps); + rstd, D, eps); // Kernel B: norm + rope + quantize (K: GROUP_SIZE=64, with smooth_k) constexpr int GROUP_SIZE = 64; @@ -326,14 +335,15 @@ int fp16_rmsnorm_rope_quant_int8_k( bias_fp16 ? reinterpret_cast(bias_fp16) : nullptr, reinterpret_cast(cos_fp32), reinterpret_cast(sin_fp32), - rstd_buf, + rstd, km_fp16 ? reinterpret_cast(km_fp16) : nullptr, reinterpret_cast(out_int8), reinterpret_cast(scale_fp32), B, S, H, Dd, sm_scale); - cudaFreeAsync(rstd_buf, stream); - e = cudaGetLastError(); + if (transient != nullptr) + cudaFreeAsync(transient, stream); + cudaError_t e = cudaGetLastError(); return (e == cudaSuccess) ? 0 : -5; } diff --git a/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cuh b/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cuh index 7798d4ea..c0c56072 100644 --- a/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cuh +++ b/csrc/kernels/minimax_remover/fp16_rmsnorm_rope_quant_int8.cuh @@ -12,6 +12,9 @@ namespace minimax_remover { // where num_scale_groups = ceil(S / WARPQ) * (BLKQ / WARPQ) // For BLKQ=128, WARPQ=32: num_scale_groups = ceil(S/128) * 4 // Each scale covers WARPQ=32 consecutive tokens for one head. +// rstd_buf: caller-owned scratch of B*S floats for the per-token rstd +// reduction. Pass nullptr to use a stream-ordered transient +// allocation (slower; for one-off / non-hot-path callers). int fp16_rmsnorm_rope_quant_int8_q( const void* x_fp16, // [B*S, H*Dd] fp16 input const void* weight_fp16, // [H*Dd] fp16 norm weight @@ -22,6 +25,7 @@ int fp16_rmsnorm_rope_quant_int8_q( void* scale_fp32, // [B, H, ceil(S/128)*4] fp32 int B, int S, int H, int Dd, float eps, float sm_scale, + void* rstd_buf, // [B*S] fp32 caller-owned scratch, or nullptr cudaStream_t stream); // Fused RMSNorm + RoPE + per-block int8 quantization + smooth_k (for K). @@ -30,6 +34,8 @@ int fp16_rmsnorm_rope_quant_int8_q( // scale [B, H, ceil(S/64)] fp32 // Each scale covers BLKK=64 consecutive tokens for one head. // smooth_k: subtract per-head mean (km) before quantization. +// rstd_buf: caller-owned scratch of B*S floats (see _q above); nullptr +// falls back to a stream-ordered transient allocation. int fp16_rmsnorm_rope_quant_int8_k( const void* x_fp16, // [B*S, H*Dd] fp16 input const void* weight_fp16, // [H*Dd] fp16 norm weight @@ -41,6 +47,7 @@ int fp16_rmsnorm_rope_quant_int8_k( void* scale_fp32, // [B, H, ceil(S/64)] fp32 int B, int S, int H, int Dd, float eps, float sm_scale, + void* rstd_buf, // [B*S] fp32 caller-owned scratch, or nullptr cudaStream_t stream); } // namespace minimax_remover diff --git a/csrc/minimax_remover_extra_bindings.cpp b/csrc/minimax_remover_extra_bindings.cpp index 90c5d045..8823621f 100644 --- a/csrc/minimax_remover_extra_bindings.cpp +++ b/csrc/minimax_remover_extra_bindings.cpp @@ -393,14 +393,17 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { uintptr_t cos_fp32, uintptr_t sin_fp32, uintptr_t out_int8, uintptr_t scale_fp32, int B, int S, int H, int Dd, - float eps, float sm_scale, uintptr_t stream) { + float eps, float sm_scale, + uintptr_t rstd_buf, uintptr_t stream) { return flash_rt::kernels::minimax_remover:: fp16_rmsnorm_rope_quant_int8_q( to_ptr(x_fp16), to_ptr(weight_fp16), bias_fp16 ? to_ptr(bias_fp16) : nullptr, to_ptr(cos_fp32), to_ptr(sin_fp32), to_ptr(out_int8), to_ptr(scale_fp32), - B, S, H, Dd, eps, sm_scale, to_stream(stream)); + B, S, H, Dd, eps, sm_scale, + rstd_buf ? to_ptr(rstd_buf) : nullptr, + to_stream(stream)); }, py::arg("x_fp16"), py::arg("weight_fp16"), py::arg("bias_fp16") = 0, @@ -408,11 +411,13 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { py::arg("out_int8"), py::arg("scale_fp32"), py::arg("B"), py::arg("S"), py::arg("H"), py::arg("Dd"), py::arg("eps") = 1e-6f, py::arg("sm_scale") = 1.0f, - py::arg("stream") = 0, + py::arg("rstd_buf") = 0, py::arg("stream") = 0, "Fused RMSNorm + RoPE + per-warp int8 quantization for Q. " "Eliminates the fp16 intermediate between norm+rope and quantize. " "bias_fp16 (Q-proj bias) is added pre-norm (fused: replaces the " "separate add_bias kernel); pass 0 to skip. " + "rstd_buf: caller-owned [B*S] fp32 scratch (reused across calls to " + "avoid hot-path allocation); pass 0 for a transient allocation. " "Output: int8 [B*S, H*Dd], scale [B, H, ceil(S/32)]. " "sm_scale is folded into quantization (softmax scale pre-multiply)."); @@ -424,15 +429,18 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { uintptr_t km_fp16, uintptr_t out_int8, uintptr_t scale_fp32, int B, int S, int H, int Dd, - float eps, float sm_scale, uintptr_t stream) { + float eps, float sm_scale, + uintptr_t rstd_buf, uintptr_t stream) { return flash_rt::kernels::minimax_remover:: fp16_rmsnorm_rope_quant_int8_k( to_ptr(x_fp16), to_ptr(weight_fp16), bias_fp16 ? to_ptr(bias_fp16) : nullptr, to_ptr(cos_fp32), to_ptr(sin_fp32), - to_ptr(km_fp16), + km_fp16 ? to_ptr(km_fp16) : nullptr, to_ptr(out_int8), to_ptr(scale_fp32), - B, S, H, Dd, eps, sm_scale, to_stream(stream)); + B, S, H, Dd, eps, sm_scale, + rstd_buf ? to_ptr(rstd_buf) : nullptr, + to_stream(stream)); }, py::arg("x_fp16"), py::arg("weight_fp16"), py::arg("bias_fp16") = 0, @@ -441,10 +449,12 @@ PYBIND11_MODULE(flash_rt_minimax_remover, m) { py::arg("out_int8"), py::arg("scale_fp32"), py::arg("B"), py::arg("S"), py::arg("H"), py::arg("Dd"), py::arg("eps") = 1e-6f, py::arg("sm_scale") = 1.0f, - py::arg("stream") = 0, + py::arg("rstd_buf") = 0, py::arg("stream") = 0, "Fused RMSNorm + RoPE + per-block int8 quantization for K with " "smooth_k (subtract key mean). Eliminates fp16 intermediate. " "bias_fp16 (K-proj bias) added pre-norm (fused); pass 0 to skip. " + "rstd_buf: caller-owned [B*S] fp32 scratch (reused across calls); " + "pass 0 for a transient allocation. " "Output: int8 [B*S, H*Dd], scale [B, H, ceil(S/64)]. " "km_fp16 can be 0 (nullptr) to skip smooth_k."); diff --git a/flash_rt/models/minimax_remover/_attention.py b/flash_rt/models/minimax_remover/_attention.py index 3c3a5f8a..88f34dcd 100644 --- a/flash_rt/models/minimax_remover/_attention.py +++ b/flash_rt/models/minimax_remover/_attention.py @@ -189,6 +189,9 @@ class FlashRTFA2Processor: def __init__(self): self._lse_bufs = {} self._cos_sin = {} + # Persistent [B*S] fp32 scratch for the fused rmsnorm+rope+int8-quant + # Q/K kernels (avoids a per-call cudaMallocAsync in the hot path). + self._rstd_bufs = {} def __call__(self, attn, hidden_states, rotary_emb=None, attention_mask=None, encoder_hidden_states=None, @@ -266,19 +269,38 @@ def __call__(self, attn, hidden_states, rotary_emb=None, k_bias_ptr = (attn.to_k.bias.data_ptr() if (_qk_nobias and attn.to_k.bias is not None) else 0) - _fvk.fp16_rmsnorm_rope_quant_int8_q( + # Reuse a persistent rstd scratch (B*S fp32) across Q/K calls so + # the fused kernel does zero hot-path allocation. Q and K run + # sequentially on the same stream, so one buffer serves both. + rstd = self._rstd_bufs.get((B, S)) + if rstd is None or rstd.device != q.device: + rstd = torch.empty(B * S, dtype=torch.float32, device=q.device) + self._rstd_bufs[(B, S)] = rstd + rstd_ptr = rstd.data_ptr() + + rc = _fvk.fp16_rmsnorm_rope_quant_int8_q( q.data_ptr(), attn.norm_q.weight.data_ptr(), q_bias_ptr, cs[0].data_ptr(), cs[1].data_ptr(), q_int8.data_ptr(), q_scale.data_ptr(), - B, S, H, Dd, float(attn.norm_q.eps), 1.0, stream) - _fvk.fp16_rmsnorm_rope_quant_int8_k( + B, S, H, Dd, float(attn.norm_q.eps), 1.0, + rstd_ptr, stream) + if rc != 0: + raise RuntimeError( + f"fp16_rmsnorm_rope_quant_int8_q failed rc={rc} " + f"(B={B} S={S} H={H} Dd={Dd})") + rc = _fvk.fp16_rmsnorm_rope_quant_int8_k( k.data_ptr(), attn.norm_k.weight.data_ptr(), k_bias_ptr, cs[0].data_ptr(), cs[1].data_ptr(), 0, # no smooth_k (negligible impact, saves k.mean compute) k_int8.data_ptr(), k_scale.data_ptr(), - B, S, H, Dd, float(attn.norm_k.eps), 1.0, stream) + B, S, H, Dd, float(attn.norm_k.eps), 1.0, + rstd_ptr, stream) + if rc != 0: + raise RuntimeError( + f"fp16_rmsnorm_rope_quant_int8_k failed rc={rc} " + f"(B={B} S={S} H={H} Dd={Dd})") v = v.view(B, S, H, Dd) if not v.is_contiguous(): diff --git a/flash_rt/models/minimax_remover/_fp8_linear.py b/flash_rt/models/minimax_remover/_fp8_linear.py index 70be0c83..68039ed8 100644 --- a/flash_rt/models/minimax_remover/_fp8_linear.py +++ b/flash_rt/models/minimax_remover/_fp8_linear.py @@ -322,9 +322,10 @@ def gemm_from_fp8_ext_nobias(self, x_fp8: torch.Tensor, """Same as gemm_from_fp8_ext but WITHOUT the bias-add. For the fully-fused attention path where the Q/K bias is added - inside the downstream ``fp16_rmsnorm_rope_bias_quant_int8`` kernel - (fused pre-norm) -- avoids the separate ``add_bias_vec8`` kernel - and its fp16 output round-trip. + inside the downstream ``fp16_rmsnorm_rope_quant_int8_q`` / + ``fp16_rmsnorm_rope_quant_int8_k`` kernel (fused pre-norm) -- + avoids the separate ``add_bias_vec8`` kernel and its fp16 output + round-trip. """ if self.calibrating: raise RuntimeError("gemm_from_fp8_ext_nobias is only valid post-calibration") diff --git a/flash_rt/models/minimax_remover/_vae_nvfp4.py b/flash_rt/models/minimax_remover/_vae_nvfp4.py index 2e147233..6ee5d4cb 100644 --- a/flash_rt/models/minimax_remover/_vae_nvfp4.py +++ b/flash_rt/models/minimax_remover/_vae_nvfp4.py @@ -40,11 +40,12 @@ _BF16 = torch.bfloat16 # Direction 3: block-level fused norm+silu+NVFP4-quant (single kernel). -# NOTE: default OFF -- the fused kernel diverges from the validated -# separate norm+quant path (~27 dB vs 35 dB end-to-end). Kept env-gated -# for future kernel debugging. -_USE_FUSED_NORMQUANT = os.environ.get( - 'FLASHRT_NVFP4_FUSED_NORMQUANT', '0') == '1' +# Default ON (FLASHRT_NVFP4_FUSED_NORMQUANT=1): the fused kernel emits the +# FP4 activation directly from the norm (no fp16 round-trip) and the conv +# reuses the rolling 2-frame FP4 cache. End-to-end PSNR 35.23 dB vs 35.11 +# baseline. The actual gate is read in install_vae_nvfp4() below. +# Set FLASHRT_NVFP4_FUSED_NORMQUANT=0 to fall back to the separate +# norm→fp16→quant path (Direction-2 cache only). # Lazy-loaded kernel modules _fvk = None # flash_rt_minimax_remover (our new kernels) @@ -359,20 +360,6 @@ def _norm_quant(norm, xin): raise RuntimeError(f"[nvfp4_fused] norm_quant rc={rc}") return fp4, sf - def _cache_fp16_for_next(norm, xin): - """Tiny rms_silu on last CACHE_T frames -> fp16 cache slice.""" - sl = _to_cl(xin[:, :, -CACHE_T:, :, :]) - B, C, Tc, Hh, Ww = sl.shape - gamma = norm.gamma - bias = getattr(norm, 'bias', 0) - bias_ptr = bias.data_ptr() if isinstance(bias, torch.Tensor) else 0 - gamma_flat = gamma.contiguous().view(-1).to(_FP16) if gamma.dtype != _FP16 else gamma.contiguous().view(-1) - out = torch.empty_like(sl) - _fvk.fp16_rms_silu_ndhwc( - sl.data_ptr(), gamma_flat.data_ptr(), bias_ptr, - out.data_ptr(), B, C, Tc, Hh, Ww, 1e-6, stream) - return out - def _conv_prequant(conv, new_fp4, new_sf, B, Ci, T_new, Hh, Ww, device): """NVFP4 conv with pre-quantized new activation + rolling FP4 cache reuse (mirrors Direction-2's _nvfp4_conv3d_forward cache logic, but diff --git a/tests/test_minimax_remover_smoke.py b/tests/test_minimax_remover_smoke.py index 03bad5bc..89ebe1f0 100644 --- a/tests/test_minimax_remover_smoke.py +++ b/tests/test_minimax_remover_smoke.py @@ -266,18 +266,60 @@ def test_fp8_pipeline_call_does_not_patch_pipe_class(monkeypatch): """Wrapping one FP8 pipe must not alter all instances of that pipe class.""" from flash_rt.models.minimax_remover import _fp8_pipeline + # Exercise the delegation path (orig pipe __call__) rather than the + # eager-manual denoise default, so the stub does not need a real + # transformer/scheduler. The class-isolation guarantee under test is + # independent of the steady-state dispatch mode. + monkeypatch.setenv("FLASHRT_FP8_EAGER_MANUAL", "0") + + class _Param: + dtype = "fp16" + class _Transformer: + def __init__(self): + self.config = types.SimpleNamespace(eps=1e-6) + self._hooks = [] + def to(self, _dtype): return self + def parameters(self): + return iter([_Param()]) + + def register_forward_hook(self, fn): + self._hooks.append(fn) + + class _Handle: + def __init__(self, hooks, f): + self._hooks = hooks + self._f = f + + def remove(self): + if self._f in self._hooks: + self._hooks.remove(self._f) + + return _Handle(self._hooks, fn) + + def _fire_hooks(self): + for fn in list(self._hooks): + fn(self, None, None) + + class _Vae: + def parameters(self): + return iter([_Param()]) + class _CallablePipe: def __init__(self, name): self.name = name self.transformer = _Transformer() + self.vae = _Vae() self.calls = [] def __call__(self, *args, **kwargs): self.calls.append((args, kwargs)) + # Simulate the transformer forward so the one-shot calibration + # freeze hook fires during the wrapped pipe's first call. + self.transformer._fire_hooks() return self.name, args, kwargs set_calibration_calls = [] @@ -327,6 +369,12 @@ def install_fa2_attention(_transformer): assert set_calibration_calls == [True] assert freeze_calls == [1.1] + assert wrapped._calibrated + + assert wrapped("again") == ("pipe1", ("again",), {}) + assert set_calibration_calls == [True] + assert freeze_calls == [1.1] + # ── 4. Gated build: required symbols present and callable ──