From 1bc2926983ed4917c42d6896621fc2e4241b8ccc Mon Sep 17 00:00:00 2001
From: DXICM <10598463@qq.com>
Date: Mon, 17 Aug 2026 08:26:20 +0000
Subject: [PATCH 1/6] fix(groot): 12 HF-alignment bugs for N1.6 Thor (SM110)
frontend
Root-cause and fix 12 real bugs where the upstream N1.6 frontend
inherited openpi-family (Pi0/Pi0.5) vision/kernel assumptions that
do not hold for GR00T N1.6's HF behaviour:
1. Tokenization: reproduce Eagle chat template (system/user headers,
formalize, per-view image blocks) instead of bare encode()
2. Resolution: HF eval chain outputs 252x252, not 224
3. SigLIP attention scope: HF(sdpa) does cross-view full attention
on the packed 648-token sequence, not per-view
4. Patch flatten order: HF NaFlex uses (ph,pw,C), not (C,ph,pw)
5. Strided FMHA divergence on non-power-of-2 seq with real data:
parity mode routes SigLIP attention through torch sdpa
6. CKernelQwen3 diverges from HF on real sequences: parity mode
runs HF-native Qwen3Model (bf16, sdpa, graph-captured)
7. Wild pointer after re-capture: Qwen3 graph-captured LN referenced
local tensors; promote to persistent attributes + finiteness guard
8. adaLN chunk order reversed: HF proj_out_1 is (shift, scale)
9. Single-frame FP8 calibration too narrow: multi-frame calibrate
(current + 7 synthetic frames, percentile=99.9)
10. Prompt switch rejected after graph bake: detect change, reset
graph runtime, re-set prompt, re-capture
11. Idle-first-frame garbage: Thor GPU idle reset invalidates captured
graphs; add replay finiteness self-check + re-capture retry
12. Prompt-switch re-capture device-side assert: stale DiT static
buffers/indices not rebuilt; add to stale list
Precision vs HF eager: cos 0.999933 / maxd 0.059 (denormalized action).
No inference hyperparameters changed (4-step, 252x252, T=50, bf16).
Also adds tools/convert_groot_n16_hf_checkpoint.py for HF safetensors
to FlashRT layout conversion (Qwen3 16-layer truncation, DiT repack,
SigLIP mlp1 layout).
---
flash_rt/frontends/torch/groot_thor.py | 1532 +++++++++++++++++-
flash_rt/hardware/thor/attn_backend_groot.py | 43 +-
flash_rt/models/groot/pipeline_thor.py | 79 +-
tools/convert_groot_n16_hf_checkpoint.py | 217 +++
4 files changed, 1762 insertions(+), 109 deletions(-)
create mode 100644 tools/convert_groot_n16_hf_checkpoint.py
diff --git a/flash_rt/frontends/torch/groot_thor.py b/flash_rt/frontends/torch/groot_thor.py
index 2d5cde0c..394c5b0c 100644
--- a/flash_rt/frontends/torch/groot_thor.py
+++ b/flash_rt/frontends/torch/groot_thor.py
@@ -13,6 +13,7 @@
import json
import logging
import math
+import os
import pathlib
import time
from typing import Optional
@@ -80,7 +81,8 @@ class GrootTorchFrontendThor:
"""GROOT N1.6 inference pipeline on Thor SM110."""
def __init__(self, checkpoint, num_views=2, autotune=3,
- embodiment_tag="new_embodiment", use_fp8=True):
+ embodiment_tag="new_embodiment", use_fp8=False,
+ image_size=252, parity=True):
"""Initialize GROOT pipeline.
Args:
@@ -88,6 +90,10 @@ def __init__(self, checkpoint, num_views=2, autotune=3,
num_views: camera views (default 2)
autotune: CUDA Graph autotune intensity (0=off, 3=default)
embodiment_tag: target embodiment for per-embodiment MLPs
+ image_size: SigLIP input edge (must be divisible by 14).
+ 224 = 16x16 patches (legacy); 252 = 18x18 patches, the
+ GR00T N1.6 training/eval resolution (HF processor chain
+ LetterBoxPad -> 256 -> 0.95 crop -> 252).
"""
if embodiment_tag not in EMBODIMENT_TAG_TO_INDEX:
raise ValueError(
@@ -98,8 +104,20 @@ def __init__(self, checkpoint, num_views=2, autotune=3,
self._num_views = num_views
self._autotune = autotune
self.use_fp8 = bool(use_fp8)
+ # Working dtype of the vision/LLM feature path: FP8 mode keeps the
+ # legacy fp16 buffers; parity mode stays bf16 end-to-end to match HF.
+ self._bd = (torch.float16 if (self.use_fp8 or not parity)
+ else torch.bfloat16)
+ # parity=True: HF-native torch modules (exact, ~110ms).
+ # parity=False: FlashRT kernel fast path (fp16/fp8 GEMM+FMHA, ~30ms).
+ self.parity = bool(parity)
self._embodiment_tag = embodiment_tag
self._embodiment_id = EMBODIMENT_TAG_TO_INDEX[embodiment_tag]
+ if image_size % 14 != 0 or (image_size // 14) % 2 != 0:
+ raise ValueError(
+ f"image_size must be divisible by 28 (got {image_size}); "
+ "the patch grid must be even for pixel_unshuffle(2).")
+ self.image_size = image_size
self._real_data_calibrated = False
self.calibrated = False
@@ -133,8 +151,8 @@ def __init__(self, checkpoint, num_views=2, autotune=3,
self.HD_sig = 72
self.H_sig = 4304
self.L_sig = 27
- self.spv_raw = 256 # raw patches per view (before pixel unshuffle)
- self.spv = 64 # tokens per view after 2x2 pixel_unshuffle (C4)
+ self.spv_raw = (image_size // 14) ** 2 # raw patches per view
+ self.spv = (image_size // 28) ** 2 # tokens per view after 2x2 pixel_unshuffle (C4)
self.mlp1_in = 4608 # 1152 * 4 after pixel unshuffle (C5)
# Qwen3 (★16 layers★, select_layer=16, checkpoint truncated) (C1)
@@ -158,7 +176,7 @@ def __init__(self, checkpoint, num_views=2, autotune=3,
self.action_dim = 128 # ★ NOT 29 — padded max, per-embodiment actual varies ★
self.state_dim = 128 # ★ NOT 29 ★
self.action_horizon = 50 # ★ NOT 16 — padded max ★
- self.num_steps = 4 # flow-matching Euler steps
+ self.num_steps = max(1, int(os.environ.get("FLASHRT_N16_DIT_STEPS", "4")))
self.Sa = self.action_horizon + 1 # 51 = 1 state + 50 actions
# ── Load checkpoint (keep full sd; SigLIP init deferred to _capture_all_graphs) ──
@@ -227,8 +245,18 @@ def _load_siglip2_weights(self, sd):
self._sig_patch_w = sd[f"{VIS_PREFIX}.embeddings.patch_embedding.weight"].to(fp16) # [1152, 588]
self._sig_patch_b = sd[f"{VIS_PREFIX}.embeddings.patch_embedding.bias"].to(fp16) # [1152]
- # Position embedding: [256, 1152] — 256 patches, NO CLS (C15)
- self._sig_pos_embed = sd[f"{VIS_PREFIX}.embeddings.position_embedding.weight"].to(fp16) # [256, 1152]
+ # Position embedding: [256, 1152] — 256 patches, NO CLS (C15).
+ # Resize 16x16 -> grid x grid to match HF resize_positional_embeddings
+ # (bilinear, align_corners=False, antialias=True, fp32).
+ pos = sd[f"{VIS_PREFIX}.embeddings.position_embedding.weight"].float()
+ grid = int(math.sqrt(self.spv_raw))
+ if grid != 16:
+ pos = (torch.nn.functional.interpolate(
+ pos.reshape(1, 16, 16, -1).permute(0, 3, 1, 2),
+ size=(grid, grid), mode="bilinear",
+ align_corners=False, antialias=True)
+ .permute(0, 2, 3, 1).reshape(-1, self.D_sig))
+ self._sig_pos_embed = pos.to(fp16) # [spv_raw, 1152]
# mlp1: LN(4608) → Linear(4608,2048) → GELU → Linear(2048,2048) (C5)
self._mlp1_ln_w = sd[f"{MLP1_PREFIX}.0.weight"].to(fp16) # [4608]
@@ -598,7 +626,7 @@ def _precompute_timesteps(self):
# flip_sin_to_cos=True, downscale_freq_shift=1
half_dim = 128 # 256 / 2
exponent = -torch.arange(half_dim, dtype=torch.float32, device='cuda') * \
- (math.log(10000.0) / half_dim)
+ (math.log(10000.0) / (half_dim - 1))
emb_freqs = exponent.exp() # [128]
t_values = [0, 250, 500, 750]
@@ -734,7 +762,7 @@ def _timestep_encode(self, t_disc):
"""Timesteps(256) → TimestepEmbedding → temb [1, 1536]."""
half_dim = 128
exp = -torch.arange(half_dim, dtype=torch.float32, device='cuda') * \
- (math.log(10000.0) / half_dim)
+ (math.log(10000.0) / (half_dim - 1))
t_tensor = torch.tensor([t_disc], dtype=torch.float32, device='cuda')
args = t_tensor[:, None] * exp.exp()
sincos = torch.cat([torch.cos(args), torch.sin(args)], dim=-1).to(fp16)
@@ -752,6 +780,931 @@ def _state_encode(self, state):
h = self._fp16_gemm(h, self._state_enc_w2, 1, self.D_dit, 1024) + self._state_enc_b2
return h.unsqueeze(0) # [1, 1, D_dit]
+ def _fill_dit_kv(self):
+ """Compact valid backbone rows into the DiT cross-attention KV buffers.
+
+ HF excludes the other modality's backbone tokens from each DiT
+ cross-attention layer via an attention mask. Writing zero-masked
+ full-length rows instead would leave bias-only K/V rows that still
+ receive attention mass and corrupt the velocity field (the
+ flow-matching noise then fails to collapse). Gather only the valid
+ rows and report their counts so the kernels attend the right length.
+ """
+ txt = self._g_vlln_buf[self._g_non_img[0]] # (n_text, D) fp16
+ img = self._g_vlln_buf[self._g_img_m[0]] # (n_img, D) fp16
+ self._g_dit.b_kv_text[:txt.shape[0]].copy_(txt)
+ self._g_dit.b_kv_img[:img.shape[0]].copy_(img)
+ self._g_dit.set_kv_counts(txt.shape[0], img.shape[0])
+
+ def _setup_torch_dit(self):
+ """Extract action-head weights from ``_full_sd`` for the
+ HF-faithful torch DiT path. Call while ``_full_sd`` is alive.
+ Parity mode runs the DiT in fp32 (bf16 denoising amplifies
+ rounding into visible chunk wobble vs HF)."""
+ sd = self._full_sd
+ AH = "action_head"
+ eid = self._embodiment_id
+ # HF runs the DiT in bf16; parity mode matches it exactly.
+ dt = torch.bfloat16
+ self._dit_dt = dt
+ w = {}
+ ts = f"{AH}.model.timestep_encoder.timestep_embedder"
+ w['ts_l1_w'] = sd[f"{ts}.linear_1.weight"].to(dt)
+ w['ts_l1_b'] = sd[f"{ts}.linear_1.bias"].to(dt)
+ w['ts_l2_w'] = sd[f"{ts}.linear_2.weight"].to(dt)
+ w['ts_l2_b'] = sd[f"{ts}.linear_2.bias"].to(dt)
+ w['proj1_w'] = sd[f"{AH}.model.proj_out_1.weight"].to(dt)
+ w['proj1_b'] = sd[f"{AH}.model.proj_out_1.bias"].to(dt)
+ w['proj2_w'] = sd[f"{AH}.model.proj_out_2.weight"].to(dt)
+ w['proj2_b'] = sd[f"{AH}.model.proj_out_2.bias"].to(dt)
+ blocks = []
+ for l in range(32):
+ p = f"{AH}.model.transformer_blocks.{l}"
+ blocks.append({k: sd[f"{p}.{n}"].to(dt) for k, n in (
+ ('n1w', 'norm1.linear.weight'), ('n1b', 'norm1.linear.bias'),
+ ('qw', 'attn1.to_q.weight'), ('qb', 'attn1.to_q.bias'),
+ ('kw', 'attn1.to_k.weight'), ('kb', 'attn1.to_k.bias'),
+ ('vw', 'attn1.to_v.weight'), ('vb', 'attn1.to_v.bias'),
+ ('ow', 'attn1.to_out.0.weight'), ('ob', 'attn1.to_out.0.bias'),
+ ('fw', 'ff.net.0.proj.weight'), ('fb', 'ff.net.0.proj.bias'),
+ ('dw', 'ff.net.2.weight'), ('db', 'ff.net.2.bias'))})
+ w['blocks'] = blocks
+ # Per-embodiment MLPs (CategorySpecificLinear: x @ W[eid] + b[eid])
+ w['ae_w1'] = sd[f"{AH}.action_encoder.W1.W"][eid].to(dt)
+ w['ae_b1'] = sd[f"{AH}.action_encoder.W1.b"][eid].to(dt)
+ w['ae_w2'] = sd[f"{AH}.action_encoder.W2.W"][eid].to(dt)
+ w['ae_b2'] = sd[f"{AH}.action_encoder.W2.b"][eid].to(dt)
+ w['ae_w3'] = sd[f"{AH}.action_encoder.W3.W"][eid].to(dt)
+ w['ae_b3'] = sd[f"{AH}.action_encoder.W3.b"][eid].to(dt)
+ w['se_w1'] = sd[f"{AH}.state_encoder.layer1.W"][eid].to(dt)
+ w['se_b1'] = sd[f"{AH}.state_encoder.layer1.b"][eid].to(dt)
+ w['se_w2'] = sd[f"{AH}.state_encoder.layer2.W"][eid].to(dt)
+ w['se_b2'] = sd[f"{AH}.state_encoder.layer2.b"][eid].to(dt)
+ w['ad_w1'] = sd[f"{AH}.action_decoder.layer1.W"][eid].to(dt)
+ w['ad_b1'] = sd[f"{AH}.action_decoder.layer1.b"][eid].to(dt)
+ w['ad_w2'] = sd[f"{AH}.action_decoder.layer2.W"][eid].to(dt)
+ w['ad_b2'] = sd[f"{AH}.action_decoder.layer2.b"][eid].to(dt)
+ w['posemb'] = sd[f"{AH}.position_embedding.weight"].to(dt)
+ # diffusers Timesteps(256, flip_sin_to_cos=True, downscale_freq_shift=1)
+ half = 128
+ self._dit_ts_freqs = torch.exp(
+ -torch.arange(half, dtype=torch.float32, device='cuda')
+ * (math.log(10000.0) / (half - 1)))
+ # SinusoidalPositionalEncoding(1536): sin-first, exact half_dim denom
+ hd = self.D_dit // 2
+ self._dit_tau_freqs = torch.exp(
+ -torch.arange(hd, dtype=torch.float32, device='cuda')
+ * (math.log(10000.0) / hd))
+ self._dit_tw = w
+
+ @staticmethod
+ def _resolve_eagle_dir() -> pathlib.Path:
+ """Locate the Eagle-Block2A-2B-v2 remote-code directory."""
+ override = os.environ.get("FLASHRT_N16_EAGLE_DIR")
+ if override:
+ p = pathlib.Path(override)
+ if (p / "config.json").exists():
+ return p
+ raise RuntimeError(
+ f"FLASHRT_N16_EAGLE_DIR={override} has no config.json")
+ cache = pathlib.Path.home() / ".cache/huggingface/modules/transformers_modules"
+ for cand in sorted(cache.glob(
+ "Eagle_hyphen_Block2A_hyphen_2B_hyphen_v2/*/config.json")):
+ return cand.parent
+ raise RuntimeError(
+ "Eagle-Block2A-2B-v2 remote code not found. Load the GR00T N1.6 "
+ "model once with transformers (AutoModel.from_pretrained) to "
+ "populate the HF cache, or set FLASHRT_N16_EAGLE_DIR.")
+
+ def _setup_torch_siglip(self):
+ """Parity mode: HF-native Siglip2VisionModel (bf16) from the Eagle
+ remote code, weights from the checkpoint state dict."""
+ import glob as _glob
+ import importlib.util as _ilu
+ import json as _json
+ eagle_dir = self._resolve_eagle_dir()
+ cfg = _json.load(open(eagle_dir / "config.json"))
+ mod_path = None
+ for cand in _glob.glob(str(eagle_dir / "modeling_siglip2.py")) + \
+ _glob.glob(str(pathlib.Path.home() /
+ ".cache/huggingface/modules/transformers_modules/"
+ "Eagle_hyphen_Block2A_hyphen_2B_hyphen_v2/*/"
+ "modeling_siglip2.py")):
+ mod_path = cand
+ break
+ if mod_path is None:
+ raise RuntimeError("modeling_siglip2.py not found for parity SigLIP")
+ spec = _ilu.spec_from_file_location("eagle_siglip2_parity", mod_path)
+ mod = _ilu.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ vc = dict(cfg["vision_config"])
+ vc.pop("_attn_implementation_autoset", None)
+ model = mod.Siglip2VisionModel(mod.Siglip2VisionConfig(**vc))
+ model = model.to(torch.bfloat16).cuda().eval()
+ prefix = "backbone.model.vision_model."
+ sub = {k[len(prefix):]: v.to(torch.bfloat16)
+ for k, v in self._full_sd.items() if k.startswith(prefix)}
+ missing, unexpected = model.load_state_dict(sub, strict=False)
+ if missing or unexpected:
+ raise RuntimeError(
+ f"torch SigLIP weight mismatch: missing={missing[:5]} "
+ f"unexpected={unexpected[:5]}")
+ self._torch_siglip = model
+ self._patch_siglip_fa4(mod)
+ self._setup_siglip_fp4()
+ logger.info("Torch SigLIP (parity mode) loaded: 27L bf16")
+
+ def _patch_siglip_fa4(self, mod):
+ """Route Siglip2Attention through FA4 (flash_rt.hardware.thor.fa4_backend).
+
+ The parity encoder does cross-view FULL attention over the packed
+ 648-token sequence (sdpa silently ignores the NaFlex window
+ segmentation — see docs/groot_n16_thor_sm110.md bug #3), so FA4's
+ causal=False path is an exact drop-in for the sdpa call. Measured
+ encoder-graph 10.3 -> 9.0 ms, actions cos 1.000000 vs sdpa
+ (2026-08-14). FLASHRT_N16_FA4=0 forces sdpa; a missing FA4 runtime
+ falls back silently. Must run before the encoder graph is captured.
+ """
+ if os.environ.get("FLASHRT_N16_FA4", "1") == "0":
+ return
+ try:
+ from flash_rt.hardware.thor import fa4_backend
+ except Exception: # noqa: BLE001
+ return
+ if not fa4_backend.is_available():
+ logger.info("SigLIP FA4 skipped: %s", fa4_backend.status())
+ return
+ fa4 = fa4_backend.fa4_func()
+ attn_cls = type(self._torch_siglip.vision_model.encoder.layers[0].self_attn)
+ if getattr(attn_cls, "_flashrt_fa4_patched", False):
+ return
+ apply_rope = attn_cls.forward.__globals__.get("apply_rope")
+
+ def fa4_forward(self, hidden_states, output_attentions=False,
+ rope_freqs_cis=None, win_meta_list=None,
+ windows_attn=False):
+ B, S, E = hidden_states.shape
+ q = self.q_proj(hidden_states).view(B, S, self.num_heads, self.head_dim)
+ k = self.k_proj(hidden_states).view(B, S, self.num_heads, self.head_dim)
+ v = self.v_proj(hidden_states).view(B, S, self.num_heads, self.head_dim)
+ if self.use_rope and apply_rope is not None:
+ q, k = apply_rope(q, k, rope_freqs_cis)
+ o = fa4(q.contiguous(), k.contiguous(), v.contiguous(),
+ causal=False, softmax_scale=self.scale)
+ if isinstance(o, tuple):
+ o = o[0]
+ return self.out_proj(o.view(B, S, E)), None
+
+ attn_cls.forward = fa4_forward
+ attn_cls._flashrt_fa4_patched = True
+ logger.info("SigLIP attention routed through FA4 (%s)", fa4_backend.status())
+
+ def _setup_torch_qwen3(self):
+ """Parity mode: load HF-native Qwen3Model (bf16, sdpa) from the
+ checkpoint state dict. Same math as the eager HF baseline."""
+ import json as _json
+ from transformers import Qwen3Config, Qwen3Model
+ eagle_dir = self._resolve_eagle_dir()
+ cfg = _json.load(open(eagle_dir / "config.json"))
+ tc = dict(cfg.get("text_config", cfg))
+ tc["num_hidden_layers"] = 16 # checkpoint is truncated
+ tc["_attn_implementation"] = "sdpa"
+ config = Qwen3Config(**tc)
+ model = Qwen3Model(config).to(torch.bfloat16).cuda().eval()
+ prefix = "backbone.model.language_model.model."
+ sub = {k[len(prefix):]: v.to(torch.bfloat16)
+ for k, v in self._full_sd.items()
+ if k.startswith(prefix)}
+ missing, unexpected = model.load_state_dict(sub, strict=False)
+ missing = [m for m in missing if "embed_tokens" not in m]
+ if missing or unexpected:
+ raise RuntimeError(
+ f"torch Qwen3 weight mismatch: missing={missing[:5]} "
+ f"unexpected={unexpected[:5]}")
+ self._torch_qwen3 = model
+ logger.info("Torch Qwen3 (parity mode) loaded: 16L bf16 sdpa")
+
+ def _setup_qwen3_fp4(self):
+ """FLASHRT_N16_QWEN3_FP4=1: NVFP4 fused-epilogue GEMMs for all 16
+ Qwen3 layers (same kernel family as the DiT fused chain, upstream
+ #163). RMSNorm / q-k norms / RoPE / sdpa / SiLU-mul stay bf16 torch;
+ every projection GEMM runs W4A4 with fused bias (+residual) epilogue
+ and the residual stream is updated in place. Weight traffic drops
+ 2.85 GB -> ~0.8 GB per inference.
+ """
+ if os.environ.get("FLASHRT_N16_QWEN3_FP4", "1") != "1":
+ return
+ if getattr(self, "_qwen3_fp4_done", False):
+ return
+ try:
+ import flash_rt.flash_rt_fp4 as _f4
+ except ImportError:
+ logger.warning("FLASHRT_N16_QWEN3_FP4=1 but flash_rt_fp4 is "
+ "missing; Qwen3 stays bf16")
+ return
+ if not hasattr(_f4, "cutlass_fp4_gemm_bias_res_bf16"):
+ logger.warning("Qwen3 FP4 needs the fused-epilogue kernels "
+ "(rebuild flash_rt_fp4); staying bf16")
+ return
+ from transformers.models.qwen3.modeling_qwen3 import apply_rotary_pos_emb
+ dt = torch.bfloat16
+ Se = self._Se
+ D = self.D_llm
+ KV = self.NHKV * self.HD_llm
+ FF = self.H_llm
+ NH, NHKV, HD = self.NHQ, self.NHKV, self.HD_llm
+
+ def _qw(w_bf):
+ w16 = w_bf.to(torch.float16).contiguous()
+ N, K = w16.shape
+ packed = torch.empty(N, K // 2, dtype=torch.uint8, device='cuda')
+ sfb = torch.zeros(_f4.sfa_size_bytes(N, K, True),
+ dtype=torch.uint8, device='cuda')
+ rc = _f4.quantize_fp4_dynamic_sfa_fp16(
+ w16.data_ptr(), packed.data_ptr(), sfb.data_ptr(), N, K, True, 0)
+ if rc != 0:
+ raise RuntimeError(f"Qwen3 fp4 weight quantize rc={rc}")
+ return packed, sfb
+
+ def _sfa(cols):
+ return torch.zeros(_f4.sfa_size_bytes(Se, cols, False),
+ dtype=torch.uint8, device='cuda')
+
+ def _ck(rc, what, li):
+ if rc != 0:
+ raise RuntimeError(f"Qwen3 fp4 {what} layer {li} rc={rc}")
+
+ def _make_fwd(tab, bufs, ln1_w, ln2_w, ln_eps, qn, kn, ob, gb, ub,
+ db, li):
+ def fwd(hidden_states, position_embeddings, attention_mask=None,
+ **kwargs):
+ s = torch.cuda.current_stream().cuda_stream
+ h = hidden_states.view(Se, D)
+ hp = h.data_ptr()
+ xn_p, xn_s = bufs['xn_p'].data_ptr(), bufs['xn_s'].data_ptr()
+ # ── attn block ──
+ _ck(_f4.rms_norm_weight_fp4_sfa_bf16(
+ hp, ln1_w.data_ptr(), xn_p, xn_s, Se, D, ln_eps, s),
+ "q-ln", li)
+ packed, sfb = tab['q']
+ _ck(_f4.cutlass_fp4_gemm_bias_bf16(
+ xn_p, xn_s, packed.data_ptr(), sfb.data_ptr(),
+ attn_q_b[li].data_ptr(), bufs['q'].data_ptr(),
+ Se, D, D, s), "q", li)
+ packed, sfb = tab['k']
+ _ck(_f4.cutlass_fp4_gemm_bias_bf16(
+ xn_p, xn_s, packed.data_ptr(), sfb.data_ptr(),
+ attn_k_b[li].data_ptr(), bufs['k'].data_ptr(),
+ Se, KV, D, s), "k", li)
+ packed, sfb = tab['v']
+ _ck(_f4.cutlass_fp4_gemm_bias_bf16(
+ xn_p, xn_s, packed.data_ptr(), sfb.data_ptr(),
+ attn_v_b[li].data_ptr(), bufs['v'].data_ptr(),
+ Se, KV, D, s), "v", li)
+ cos, sin = position_embeddings
+ _ck(fvk.qk_norm_rope_rotate_half_bf16(
+ bufs['q'].data_ptr(), qn.weight.data_ptr(),
+ cos.data_ptr(), sin.data_ptr(),
+ Se, NH, HD, qn.variance_epsilon, s), "q-norm-rope", li)
+ _ck(fvk.qk_norm_rope_rotate_half_bf16(
+ bufs['k'].data_ptr(), kn.weight.data_ptr(),
+ cos.data_ptr(), sin.data_ptr(),
+ Se, NHKV, HD, kn.variance_epsilon, s), "k-norm-rope", li)
+ q = bufs['q'].view(1, Se, NH, HD).transpose(1, 2)
+ k = bufs['k'].view(1, Se, NHKV, HD).transpose(1, 2)
+ v = bufs['v'].view(1, Se, NHKV, HD).transpose(1, 2)
+ # Torch's fused attention backends reject mismatched Q/KV
+ # head counts (enable_gqa falls back to unfused math: 2 fp16
+ # GEMMs + materialized softmax per layer). Expanding KV
+ # enables the fused path (measured Qwen3 7.0 -> 5.0 ms).
+ if NHKV != NH:
+ k = k.repeat_interleave(NH // NHKV, dim=1)
+ v = v.repeat_interleave(NH // NHKV, dim=1)
+ if attention_mask is None:
+ o = torch.nn.functional.scaled_dot_product_attention(
+ q, k, v, is_causal=q.shape[2] > 1)
+ else:
+ o = torch.nn.functional.scaled_dot_product_attention(
+ q, k, v, attn_mask=attention_mask)
+ o_flat = o.transpose(1, 2).reshape(Se, D)
+ o_p, o_s = bufs['o_p'].data_ptr(), bufs['o_s'].data_ptr()
+ _ck(_f4.quantize_fp4_dynamic_sfa_bf16_vec(
+ o_flat.data_ptr(), o_p, o_s, Se, D, False, s), "o-q", li)
+ packed, sfb = tab['o']
+ _ck(_f4.cutlass_fp4_gemm_bias_res_bf16(
+ o_p, o_s, packed.data_ptr(), sfb.data_ptr(),
+ ob.data_ptr(), hp, hp, Se, D, D, s), "o", li)
+ # ── FFN block ──
+ _ck(_f4.rms_norm_weight_fp4_sfa_bf16(
+ hp, ln2_w.data_ptr(), xn_p, xn_s, Se, D, ln_eps, s),
+ "f-ln", li)
+ packed, sfb = tab['gate']
+ _ck(_f4.cutlass_fp4_gemm_bias_bf16(
+ xn_p, xn_s, packed.data_ptr(), sfb.data_ptr(),
+ gb.data_ptr(), bufs['gate'].data_ptr(),
+ Se, FF, D, s), "gate", li)
+ packed, sfb = tab['up']
+ _ck(_f4.cutlass_fp4_gemm_bias_bf16(
+ xn_p, xn_s, packed.data_ptr(), sfb.data_ptr(),
+ ub.data_ptr(), bufs['up'].data_ptr(),
+ Se, FF, D, s), "up", li)
+ gu_p, gu_s = bufs['gu_p'].data_ptr(), bufs['gu_s'].data_ptr()
+ _ck(_f4.silu_mul_fp4_sfa_bf16(
+ bufs['gate'].data_ptr(), bufs['up'].data_ptr(),
+ gu_p, gu_s, Se, FF, False, s), "gu-q", li)
+ packed, sfb = tab['dn']
+ _ck(_f4.cutlass_fp4_gemm_bias_res_bf16(
+ gu_p, gu_s, packed.data_ptr(), sfb.data_ptr(),
+ db.data_ptr(), hp, hp, Se, D, FF, s), "dn", li)
+ return h.view(1, Se, D)
+ return fwd
+
+ zero_bias = torch.zeros(FF, dtype=dt, device='cuda')
+
+ def _bias(t):
+ return t if t is not None else zero_bias
+
+ attn_q_b, attn_k_b, attn_v_b = [], [], []
+ for li, layer in enumerate(self._torch_qwen3.layers):
+ attn, mlp = layer.self_attn, layer.mlp
+ tab = {n: _qw(getattr(attn, n + "_proj").weight)
+ for n in ('q', 'k', 'v', 'o')}
+ tab['gate'] = _qw(mlp.gate_proj.weight)
+ tab['up'] = _qw(mlp.up_proj.weight)
+ tab['dn'] = _qw(mlp.down_proj.weight)
+ attn_q_b.append(_bias(attn.q_proj.bias))
+ attn_k_b.append(_bias(attn.k_proj.bias))
+ attn_v_b.append(_bias(attn.v_proj.bias))
+ bufs = {
+ 'xn_p': torch.empty(Se, D // 2, dtype=torch.uint8, device='cuda'),
+ 'xn_s': _sfa(D),
+ 'q': torch.empty(Se, D, dtype=dt, device='cuda'),
+ 'k': torch.empty(Se, KV, dtype=dt, device='cuda'),
+ 'v': torch.empty(Se, KV, dtype=dt, device='cuda'),
+ 'o_p': torch.empty(Se, D // 2, dtype=torch.uint8, device='cuda'),
+ 'o_s': _sfa(D),
+ 'gate': torch.empty(Se, FF, dtype=dt, device='cuda'),
+ 'up': torch.empty(Se, FF, dtype=dt, device='cuda'),
+ 'gu_p': torch.empty(Se, FF // 2, dtype=torch.uint8, device='cuda'),
+ 'gu_s': _sfa(FF),
+ }
+ layer.forward = _make_fwd(
+ tab, bufs, layer.input_layernorm.weight,
+ layer.post_attention_layernorm.weight,
+ layer.input_layernorm.variance_epsilon,
+ attn.q_norm, attn.k_norm,
+ _bias(attn.o_proj.bias), _bias(mlp.gate_proj.bias),
+ _bias(mlp.up_proj.bias), _bias(mlp.down_proj.bias), li)
+ self._qwen3_fp4_done = True
+ logger.info("Qwen3 NVFP4 fused-epilogue tier enabled (16 layers)")
+
+ def _setup_siglip_fp4(self):
+ """FLASHRT_N16_SIGLIP_FP4 (default on): NVFP4 fused-epilogue encoder.
+
+ Every layer runs the kernel chain: LN->fp4 producer (affine LayerNorm
+ expressed as AdaLN with scale=w-1, shift=b), q/k/v bias GEMMs into
+ contiguous buffers, FA4 full attention, o bias+residual GEMM, LN->fp4,
+ fc1 bias+tanh-GELU+fp4out (N padded 4304->4352 so both fp4 dims are
+ mult-64; the pad columns stay zero end-to-end), fc2 bias+residual.
+ Measured encoder 9.0 -> 7.6 ms; actions vs HF cos 0.99997 / maxd
+ 0.038 (vs 0.023 bf16) — the vision-quality trade is simulation-gated;
+ set FLASHRT_N16_SIGLIP_FP4=0 to revert to bf16.
+ """
+ if os.environ.get("FLASHRT_N16_SIGLIP_FP4", "1") != "1":
+ return
+ if getattr(self, "_siglip_fp4_done", False):
+ return
+ try:
+ import flash_rt.flash_rt_fp4 as _f4
+ except ImportError:
+ logger.warning("FLASHRT_N16_SIGLIP_FP4 on but flash_rt_fp4 "
+ "missing; SigLIP stays bf16")
+ return
+ if not hasattr(_f4, "cutlass_fp4_gemm_bias_gelu_fp4out_bf16"):
+ logger.warning("SigLIP FP4 needs the fused-epilogue kernels "
+ "(rebuild flash_rt_fp4); staying bf16")
+ return
+ from flash_rt.hardware.thor import fa4_backend
+ if not fa4_backend.is_available():
+ logger.warning("SigLIP FP4 tier requires FA4; staying bf16")
+ return
+ fa4 = fa4_backend.fa4_func()
+ dt = torch.bfloat16
+ enc = self._torch_siglip.vision_model.encoder
+ S = self._num_views * self.spv_raw
+ head0 = enc.layers[0]
+ attn0, D = head0.self_attn, head0.self_attn.embed_dim
+ NH, HD = attn0.num_heads, attn0.head_dim
+ FF = head0.mlp.fc1.out_features
+ FFP = (FF + 63) // 64 * 64
+ eps = head0.layer_norm1.eps
+ gmod = type(attn0).forward.__globals__
+ apply_rope = gmod.get("apply_rope")
+
+ def _qw(w_bf, pad_rows=0, pad_cols=0):
+ w16 = w_bf.to(torch.float16)
+ if pad_cols:
+ w16 = F.pad(w16, (0, pad_cols))
+ if pad_rows:
+ w16 = F.pad(w16, (0, 0, 0, pad_rows))
+ w16 = w16.contiguous()
+ N, K = w16.shape
+ packed = torch.empty(N, K // 2, dtype=torch.uint8, device='cuda')
+ sfb = torch.zeros(_f4.sfa_size_bytes(N, K, True),
+ dtype=torch.uint8, device='cuda')
+ rc = _f4.quantize_fp4_dynamic_sfa_fp16(
+ w16.data_ptr(), packed.data_ptr(), sfb.data_ptr(),
+ N, K, True, 0)
+ if rc != 0:
+ raise RuntimeError(f"SigLIP fp4 weight quantize rc={rc}")
+ return packed, sfb
+
+ def _ck(rc, what, l):
+ if rc != 0:
+ raise RuntimeError(f"SigLIP fp4 {what} layer {l} rc={rc}")
+
+ def _make_fwd(tab, bufs, use_rope, scale, li):
+ def fwd(hidden_states, output_attentions=False,
+ rope_freqs_cis=None, win_meta_list=None,
+ windows_attn=False):
+ s = torch.cuda.current_stream().cuda_stream
+ h = hidden_states.view(S, D)
+ hp = h.data_ptr()
+ xn_p, xn_s = bufs['xn_p'].data_ptr(), bufs['xn_s'].data_ptr()
+ _ck(_f4.ada_layer_norm_fp4_sfa_bf16(
+ hp, tab['ln1_sc'].data_ptr(), tab['ln1_sh'].data_ptr(),
+ xn_p, xn_s, S, D, eps, s), "ln1", li)
+ for n in ('q', 'k', 'v'):
+ packed, sfb = tab[n]
+ _ck(_f4.cutlass_fp4_gemm_bias_bf16(
+ xn_p, xn_s, packed.data_ptr(), sfb.data_ptr(),
+ tab[n + '_b'].data_ptr(), bufs[n].data_ptr(),
+ S, D, D, s), n, li)
+ q = bufs['q'].view(1, S, NH, HD)
+ k = bufs['k'].view(1, S, NH, HD)
+ v = bufs['v'].view(1, S, NH, HD)
+ if use_rope and apply_rope is not None:
+ q, k = apply_rope(q, k, rope_freqs_cis)
+ q = q.contiguous()
+ k = k.contiguous()
+ o = fa4(q, k, v, causal=False, softmax_scale=scale)
+ if isinstance(o, tuple):
+ o = o[0]
+ o_p, o_s = bufs['o_p'].data_ptr(), bufs['o_s'].data_ptr()
+ _ck(_f4.quantize_fp4_dynamic_sfa_bf16_vec(
+ o.data_ptr(), o_p, o_s, S, D, False, s), "o-q", li)
+ packed, sfb = tab['o']
+ _ck(_f4.cutlass_fp4_gemm_bias_res_bf16(
+ o_p, o_s, packed.data_ptr(), sfb.data_ptr(),
+ tab['o_b'].data_ptr(), hp, hp, S, D, D, s), "o", li)
+ _ck(_f4.ada_layer_norm_fp4_sfa_bf16(
+ hp, tab['ln2_sc'].data_ptr(), tab['ln2_sh'].data_ptr(),
+ xn_p, xn_s, S, D, eps, s), "ln2", li)
+ packed, sfb = tab['fc1']
+ hid_p, hid_s = bufs['hid_p'].data_ptr(), bufs['hid_s'].data_ptr()
+ _ck(_f4.cutlass_fp4_gemm_bias_gelu_fp4out_bf16(
+ xn_p, xn_s, packed.data_ptr(), sfb.data_ptr(),
+ tab['fc1_b'].data_ptr(), hid_p, hid_s,
+ S, FFP, D, s), "fc1", li)
+ packed, sfb = tab['fc2']
+ _ck(_f4.cutlass_fp4_gemm_bias_res_bf16(
+ hid_p, hid_s, packed.data_ptr(), sfb.data_ptr(),
+ tab['fc2_b'].data_ptr(), hp, hp, S, D, FFP, s), "fc2", li)
+ return (h.view(1, S, D),)
+ return fwd
+
+ for li, layer in enumerate(enc.layers):
+ attn, mlp = layer.self_attn, layer.mlp
+ ln1, ln2 = layer.layer_norm1, layer.layer_norm2
+ tab = {
+ 'q': _qw(attn.q_proj.weight), 'q_b': attn.q_proj.bias,
+ 'k': _qw(attn.k_proj.weight), 'k_b': attn.k_proj.bias,
+ 'v': _qw(attn.v_proj.weight), 'v_b': attn.v_proj.bias,
+ 'o': _qw(attn.out_proj.weight), 'o_b': attn.out_proj.bias,
+ 'fc1': _qw(mlp.fc1.weight, pad_rows=FFP - FF),
+ 'fc1_b': F.pad(mlp.fc1.bias, (0, FFP - FF)),
+ 'fc2': _qw(mlp.fc2.weight, pad_cols=FFP - FF),
+ 'fc2_b': mlp.fc2.bias,
+ 'ln1_sc': (ln1.weight - 1).contiguous(),
+ 'ln1_sh': ln1.bias.contiguous(),
+ 'ln2_sc': (ln2.weight - 1).contiguous(),
+ 'ln2_sh': ln2.bias.contiguous(),
+ }
+ bufs = {
+ 'xn_p': torch.empty(S, D // 2, dtype=torch.uint8, device='cuda'),
+ 'xn_s': torch.zeros(_f4.sfa_size_bytes(S, D, False),
+ dtype=torch.uint8, device='cuda'),
+ 'q': torch.empty(S, D, dtype=dt, device='cuda'),
+ 'k': torch.empty(S, D, dtype=dt, device='cuda'),
+ 'v': torch.empty(S, D, dtype=dt, device='cuda'),
+ 'o_p': torch.empty(S, D // 2, dtype=torch.uint8, device='cuda'),
+ 'o_s': torch.zeros(_f4.sfa_size_bytes(S, D, False),
+ dtype=torch.uint8, device='cuda'),
+ 'hid_p': torch.empty(S, FFP // 2, dtype=torch.uint8, device='cuda'),
+ 'hid_s': torch.zeros(_f4.sfa_size_bytes(S, FFP, False),
+ dtype=torch.uint8, device='cuda'),
+ }
+ layer.forward = _make_fwd(tab, bufs, attn.use_rope, attn.scale, li)
+ self._siglip_fp4_done = True
+ logger.info("SigLIP NVFP4 fused-epilogue encoder enabled "
+ "(27 layers, S=%d)", S)
+
+ def _run_torch_dit(self, state):
+ """4-step flow-matching DiT in bf16 torch — HF-faithful.
+
+ The CUDA-kernel DiT cannot reproduce HF's numerics on this model
+ (see docs/groot_n16_dit_kernel_nonconvergence.md), so the action head
+ runs as bf16 torch. To avoid the 32-layer x 4-step eager kernel-launch
+ overhead, the step-invariant parts are precomputed and the per-frame
+ compute is captured as a CUDA graph over static buffers (set
+ FLASHRT_N16_DIT_GRAPH=0 to force the eager fallback).
+ """
+ if not hasattr(self, '_dit_in_state'):
+ self._dit_setup_graph_buffers()
+ dt = self._dit_dt
+ # Fill static input buffers from the current frame.
+ st = torch.as_tensor(np.asarray(state), dtype=torch.float32).cuda().reshape(1, -1).to(dt)
+ self._dit_in_state.copy_(st)
+ ehs = self._g_vlln_buf
+ self._dit_in_kvt.copy_(ehs.index_select(0, self._dit_txt_idx).to(dt))
+ self._dit_in_kvi.copy_(ehs.index_select(0, self._dit_img_idx).to(dt))
+ self._dit_in_noise.normal_()
+
+ if self._dit_torch_graph is not None:
+ self._dit_torch_graph.replay()
+ return self._dit_out
+ # Eager fallback.
+ self._dit_body()
+ return self._dit_out
+
+ def _dit_setup_graph_buffers(self):
+ """Allocate static DiT I/O buffers, precompute step-invariant tensors,
+ and capture the 4-step DiT as a CUDA graph."""
+ w = self._dit_tw
+ dt = self._dit_dt
+ T, D = self.action_horizon, self.D_dit
+ N, NH, HD = self.num_steps, self.NH_dit, self.HD_dit
+
+ self._dit_txt_idx = self._g_non_img[0].nonzero(as_tuple=True)[0].long()
+ self._dit_img_idx = self._g_img_m[0].nonzero(as_tuple=True)[0].long()
+ n_txt, n_img = self._dit_txt_idx.numel(), self._dit_img_idx.numel()
+ self._dit_in_kvt = torch.empty(n_txt, 2048, dtype=dt, device='cuda')
+ self._dit_in_kvi = torch.empty(n_img, 2048, dtype=dt, device='cuda')
+ self._dit_in_state = torch.empty(1, self.state_dim, dtype=dt, device='cuda')
+ self._dit_in_noise = torch.empty(1, T, self.action_dim, dtype=dt, device='cuda')
+ self._dit_out = torch.empty(1, T, self.action_dim, dtype=dt, device='cuda')
+
+ # Step-invariant embeddings (depend only on weights + step).
+ # Recomputed from self.num_steps so step-count experiments keep the
+ # uniform schedule t_disc = step/N*1000 (HF FlowMatchEuler).
+ self._dit_posemb = w['posemb'][:T].unsqueeze(0)
+ self._dit_silu_tembs, self._dit_taus = [], []
+ for step in range(self.num_steps):
+ t_disc = int(step / float(self.num_steps) * 1000)
+ args = t_disc * self._dit_ts_freqs
+ sincos = torch.cat([torch.cos(args), torch.sin(args)], -1).to(dt).unsqueeze(0)
+ temb = F.linear(F.silu(F.linear(sincos, w['ts_l1_w'], w['ts_l1_b'])),
+ w['ts_l2_w'], w['ts_l2_b'])
+ self._dit_silu_tembs.append(F.silu(temb))
+ freqs = t_disc * self._dit_tau_freqs
+ self._dit_taus.append(torch.cat([torch.sin(freqs), torch.cos(freqs)], -1)
+ .to(dt).unsqueeze(0).expand(1, T, -1).contiguous())
+
+ # Precompute the per-block adaLN conditioning embeddings for every
+ # (step, layer): they depend only on weights + step, yet the HF loop
+ # re-runs the M=1 F.linear inside the denoising loop, streaming
+ # 4*32*9.4MB of n1w weights per inference. Hoisting them out is
+ # numerically identical (same bf16 kernel, same inputs).
+ self._dit_ada_embs = [
+ [F.linear(temb, blk['n1w'], blk['n1b']) for blk in w['blocks']]
+ for temb in self._dit_silu_tembs]
+
+ # Optional NVFP4 tier (FLASHRT_N16_DIT_FP4=1): every block GEMM runs
+ # as a block-scaled W4A4 CUTLASS GEMM (port of the N1.7 FP4 tier,
+ # #163). At M=51 the DiT is weight-bandwidth-bound; fp4 quarters the
+ # weight bytes. Measured DiT 36.6 -> 29.4 ms, actions vs HF
+ # cos 0.999994 / maxd 0.012 (2026-08-14). Weight tables are static
+ # and survive graph re-captures.
+ self._dit_use_fp4 = False
+ if os.environ.get("FLASHRT_N16_DIT_FP4", "1") == "1":
+ try:
+ import flash_rt.flash_rt_fp4 as _f4
+ except ImportError:
+ _f4 = None
+ logger.warning("FLASHRT_N16_DIT_FP4=1 but flash_rt_fp4 is "
+ "missing; DiT stays bf16")
+ if _f4 is not None:
+ if not hasattr(self, "_dit_fq"):
+ def _qw(wt):
+ w16 = wt.to(torch.float16).contiguous()
+ N, K = w16.shape
+ packed = torch.empty(N, K // 2, dtype=torch.uint8,
+ device='cuda')
+ # Zero-init: the tile-interleaved SF layout rounds up
+ # to atom size; unwritten padding must not decode as
+ # garbage scales (upstream #163 pitfall).
+ sfb = torch.zeros(_f4.sfa_size_bytes(N, K, True),
+ dtype=torch.uint8, device='cuda')
+ rc = _f4.quantize_fp4_dynamic_sfa_fp16(
+ w16.data_ptr(), packed.data_ptr(), sfb.data_ptr(),
+ N, K, True, 0)
+ if rc != 0:
+ raise RuntimeError(f"DiT fp4 quantize rc={rc}")
+ return packed, sfb
+ self._dit_fq = [
+ {n: _qw(blk[n]) for n in ('qw', 'kw', 'vw', 'ow',
+ 'fw', 'dw')}
+ for blk in w['blocks']]
+ self._dit_fp4_mod = _f4
+ self._dit_fp4_bufs = {}
+ self._dit_use_fp4 = True
+ logger.info("DiT NVFP4 tier enabled (block GEMMs W4A4)")
+ # Fused-epilogue chain (upstream N1.7 #163 port): norm->fp4
+ # producers + bias/residual/GELU inside the GEMM epilogues.
+ if hasattr(_f4, "ada_layer_norm_fp4_sfa_bf16"):
+ self._dit_fp4_fused = True
+ if not hasattr(self, "_dit_fqf"):
+ Sa, FF = T + 1, self.H_dit
+ fqf = []
+ for l, blk in enumerate(w['blocks']):
+ e = {}
+ if l % 2 == 1: # self-attn: fused QKV
+ qkvw = torch.cat([blk['qw'], blk['kw'], blk['vw']], 0)
+ e['qkv'] = _qw(qkvw)
+ e['qkv_b'] = torch.cat(
+ [blk['qb'], blk['kb'], blk['vb']]).contiguous()
+ else: # cross-attn: Q only (K/V precomputed)
+ e['q'] = _qw(blk['qw'])
+ e['q_b'] = blk['qb'].contiguous()
+ e['o'] = _qw(blk['ow'])
+ e['up'] = _qw(blk['fw'])
+ e['dn'] = _qw(blk['dw'])
+ fqf.append(e)
+ self._dit_fqf = fqf
+ # Per-step AdaLN modulators (scale, shift) and the final
+ # proj1 (shift, scale) as pointer-stable tensors.
+ ada_sc = torch.stack(
+ [torch.stack([emb[0, :D] for emb in embs])
+ for embs in self._dit_ada_embs]).contiguous()
+ ada_sh = torch.stack(
+ [torch.stack([emb[0, D:] for emb in embs])
+ for embs in self._dit_ada_embs]).contiguous()
+ self._dit_ada_scale = ada_sc
+ self._dit_ada_shift = ada_sh
+ self._dit_proj1 = torch.stack([
+ F.linear(temb, w['proj1_w'], w['proj1_b'])[0]
+ for temb in self._dit_silu_tembs]).contiguous()
+ # Static scratch (graph-capture stable).
+ self._dit_h = torch.empty(Sa, D, dtype=dt, device='cuda')
+ self._dit_qkv_buf = torch.empty(Sa, 3 * D, dtype=dt, device='cuda')
+ self._dit_q_buf = torch.empty(Sa, D, dtype=dt, device='cuda')
+ self._dit_xn_p = torch.empty(Sa, D // 2, dtype=torch.uint8, device='cuda')
+ self._dit_xn_s = torch.zeros(_f4.sfa_size_bytes(Sa, D, False),
+ dtype=torch.uint8, device='cuda')
+ self._dit_o_p = torch.empty(Sa, D // 2, dtype=torch.uint8, device='cuda')
+ self._dit_o_s = torch.zeros(_f4.sfa_size_bytes(Sa, D, False),
+ dtype=torch.uint8, device='cuda')
+ self._dit_hid_p = torch.empty(Sa, FF // 2, dtype=torch.uint8, device='cuda')
+ self._dit_hid_s = torch.zeros(_f4.sfa_size_bytes(Sa, FF, False),
+ dtype=torch.uint8, device='cuda')
+ logger.info("DiT NVFP4 fused-epilogue chain enabled")
+ else:
+ self._dit_fp4_fused = False
+
+ # Seed the inputs so warmup/capture run on valid data.
+ self._dit_in_state.zero_()
+ self._dit_in_kvt.zero_(); self._dit_in_kvi.zero_()
+ self._dit_in_noise.normal_()
+
+ self._dit_torch_graph = None
+ # Graph the torch DiT by default (~15ms vs 40ms eager); reset paths
+ # delete and re-capture it safely. Set FLASHRT_N16_DIT_GRAPH=0 to
+ # force the eager fallback.
+ if os.environ.get("FLASHRT_N16_DIT_GRAPH", "1") != "0":
+ try:
+ s = torch.cuda.Stream()
+ s.wait_stream(torch.cuda.current_stream())
+ with torch.cuda.stream(s):
+ for _ in range(2):
+ self._dit_body()
+ torch.cuda.current_stream().wait_stream(s)
+ g = torch.cuda.CUDAGraph()
+ with torch.cuda.graph(g):
+ self._dit_body()
+ self._dit_torch_graph = g
+ logger.info("torch DiT captured as CUDA graph (n_txt=%d, n_img=%d)",
+ n_txt, n_img)
+ except Exception as e: # noqa: BLE001
+ logger.warning("torch DiT graph capture failed (%r); using eager", e)
+ self._dit_torch_graph = None
+
+ def _dit_fp4_linear(self, x_bf, fq, N, K, bias):
+ """NVFP4 W4A4 GEMM replacement for F.linear inside the DiT graph.
+
+ Static per-shape buffers (graph-safe): bf16->fp16 cast, dynamic
+ per-16 activation quant, cutlass_fp4_sq_fp16, bf16 bias add.
+ """
+ f4 = self._dit_fp4_mod
+ M = x_bf.shape[-2]
+ key = (M, K, N)
+ bufs = self._dit_fp4_bufs
+ if key not in bufs:
+ bufs[key] = (torch.empty(M, K, dtype=torch.float16, device='cuda'),
+ torch.empty(M, K // 2, dtype=torch.uint8, device='cuda'),
+ torch.zeros(f4.sfa_size_bytes(M, K, False),
+ dtype=torch.uint8, device='cuda'),
+ torch.empty(M, N, dtype=torch.float16, device='cuda'))
+ x16b, xq, xs, out = bufs[key]
+ packed, sfb = fq
+ x16b.copy_(x_bf.reshape(M, K).to(torch.float16))
+ s = torch.cuda.current_stream().cuda_stream
+ f4.quantize_fp4_dynamic_sfa_fp16(x16b.data_ptr(), xq.data_ptr(),
+ xs.data_ptr(), M, K, False, s)
+ f4.cutlass_fp4_sq_fp16(xq.data_ptr(), xs.data_ptr(), packed.data_ptr(),
+ sfb.data_ptr(), out.data_ptr(), M, N, K,
+ 1.0, 0.0, s)
+ y = out.to(torch.bfloat16)
+ if bias is not None:
+ y = y + bias
+ return y.reshape(*x_bf.shape[:-1], N)
+
+ def _dit_body_fp4_fused(self):
+ """Fused-epilogue NVFP4 DiT chain (upstream N1.7 #163 port).
+
+ Per block: adaLN-modulated norm emits fp4 directly; QKV/cross-Q/O/
+ FFN run as block-scaled NVFP4 GEMMs with bias (and residual /
+ tanh-GELU+fp4out) fused into the epilogues — 8 kernels per layer,
+ no elementwise traffic between them. Attention stays bf16 sdpa.
+ """
+ w = self._dit_tw
+ dt = self._dit_dt
+ f4 = self._dit_fp4_mod
+ T, D = self.action_horizon, self.D_dit
+ N, NH, HD = self.num_steps, self.NH_dit, self.HD_dit
+ FF = self.H_dit
+ Sa = T + 1
+
+ h = self._dit_h
+ qkv_buf, q_buf = self._dit_qkv_buf, self._dit_q_buf
+ xn_p, xn_s = self._dit_xn_p.data_ptr(), self._dit_xn_s.data_ptr()
+ o_p, o_s = self._dit_o_p.data_ptr(), self._dit_o_s.data_ptr()
+ hid_p, hid_s = self._dit_hid_p.data_ptr(), self._dit_hid_s.data_ptr()
+ fqf = self._dit_fqf
+
+ state = self._dit_in_state
+ st = F.relu(state @ w['se_w1'] + w['se_b1'])
+ state_feat = (st @ w['se_w2'] + w['se_b2']) # (1, D)
+ kv_text, kv_img = self._dit_in_kvt, self._dit_in_kvi
+
+ # Cross-attn K/V in bf16, once per inference (backbone constant).
+ # fp4 here was measured to degrade actions 5x (cos 0.9992 / maxd
+ # 0.105) for ~0 ms gain — cross features feed every cross layer.
+ cross_kv = {}
+ for l, blk in enumerate(w['blocks']):
+ if l % 2 == 0:
+ kv = kv_text if l % 4 == 0 else kv_img
+ cross_kv[l] = (
+ F.linear(kv, blk['kw'], blk['kb']).view(1, -1, NH, HD).transpose(1, 2),
+ F.linear(kv, blk['vw'], blk['vb']).view(1, -1, NH, HD).transpose(1, 2))
+
+ def _ck(rc, what, l):
+ if rc != 0:
+ raise RuntimeError(f"DiT fp4 fused {what} layer {l} rc={rc}")
+
+ actions = self._dit_in_noise
+ for step in range(N):
+ s = torch.cuda.current_stream().cuda_stream
+ a_emb = actions @ w['ae_w1'] + w['ae_b1']
+ x = torch.cat([a_emb, self._dit_taus[step]], -1)
+ af = (F.silu(x @ w['ae_w2'] + w['ae_b2']) @ w['ae_w3']
+ + w['ae_b3']) + self._dit_posemb
+ h[:1].copy_(state_feat)
+ h[1:].copy_(af.view(T, D))
+ h_ptr = h.data_ptr()
+ for l, blk in enumerate(w['blocks']):
+ e = fqf[l]
+ _ck(f4.ada_layer_norm_fp4_sfa_bf16(
+ h_ptr, self._dit_ada_scale[step, l].data_ptr(),
+ self._dit_ada_shift[step, l].data_ptr(), xn_p, xn_s,
+ Sa, D, 1e-5, s), "adaln", l)
+ if l % 2 == 1: # self-attn
+ packed, sfb = e['qkv']
+ _ck(f4.cutlass_fp4_gemm_bias_bf16(
+ xn_p, xn_s, packed.data_ptr(), sfb.data_ptr(),
+ e['qkv_b'].data_ptr(), qkv_buf.data_ptr(),
+ Sa, 3 * D, D, s), "qkv", l)
+ q = qkv_buf[:, :D].view(1, Sa, NH, HD).transpose(1, 2)
+ k = qkv_buf[:, D:2 * D].view(1, Sa, NH, HD).transpose(1, 2)
+ v = qkv_buf[:, 2 * D:].view(1, Sa, NH, HD).transpose(1, 2)
+ else: # cross-attn
+ packed, sfb = e['q']
+ _ck(f4.cutlass_fp4_gemm_bias_bf16(
+ xn_p, xn_s, packed.data_ptr(), sfb.data_ptr(),
+ e['q_b'].data_ptr(), q_buf.data_ptr(),
+ Sa, D, D, s), "q", l)
+ q = q_buf.view(1, Sa, NH, HD).transpose(1, 2)
+ k, v = cross_kv[l]
+ o = F.scaled_dot_product_attention(q, k, v)
+ o_flat = o.transpose(1, 2).reshape(Sa, D)
+ _ck(f4.quantize_fp4_dynamic_sfa_bf16_vec(
+ o_flat.data_ptr(), o_p, o_s, Sa, D, False, s), "o-quant", l)
+ packed, sfb = e['o']
+ _ck(f4.cutlass_fp4_gemm_bias_res_bf16(
+ o_p, o_s, packed.data_ptr(), sfb.data_ptr(),
+ blk['ob'].data_ptr(), h_ptr, h_ptr,
+ Sa, D, D, s), "o", l)
+ _ck(f4.layer_norm_no_affine_fp4_sfa_bf16(
+ h_ptr, xn_p, xn_s, Sa, D, 1e-5, s), "ffn-ln", l)
+ packed, sfb = e['up']
+ _ck(f4.cutlass_fp4_gemm_bias_gelu_fp4out_bf16(
+ xn_p, xn_s, packed.data_ptr(), sfb.data_ptr(),
+ blk['fb'].data_ptr(), hid_p, hid_s,
+ Sa, FF, D, s), "ffn-up", l)
+ packed, sfb = e['dn']
+ _ck(f4.cutlass_fp4_gemm_bias_res_bf16(
+ hid_p, hid_s, packed.data_ptr(), sfb.data_ptr(),
+ blk['db'].data_ptr(), h_ptr, h_ptr,
+ Sa, D, FF, s), "ffn-down", l)
+ p1 = self._dit_proj1[step]
+ shift, scale = p1.chunk(2, dim=0)
+ hn = F.layer_norm(h, (D,), None, None, 1e-6) * (1 + scale) + shift
+ out = F.linear(hn, w['proj2_w'], w['proj2_b'])
+ dec = F.relu(out @ w['ad_w1'] + w['ad_b1'])
+ pred = dec @ w['ad_w2'] + w['ad_b2']
+ actions = actions + (1.0 / N) * pred[-T:]
+ self._dit_out.copy_(actions)
+
+ def _dit_body(self):
+ """The 4-step DiT over static input buffers; writes self._dit_out."""
+ if getattr(self, "_dit_fp4_fused", False):
+ return self._dit_body_fp4_fused()
+ w = self._dit_tw
+ dt = self._dit_dt
+ T, D = self.action_horizon, self.D_dit
+ N, NH, HD = self.num_steps, self.NH_dit, self.HD_dit
+ use_fp4 = getattr(self, "_dit_use_fp4", False)
+ H = self.H_dit
+
+ def lin(x, l, name, Nout, Kin, bias):
+ if use_fp4:
+ return self._dit_fp4_linear(x, self._dit_fq[l][name],
+ Nout, Kin, bias)
+ return F.linear(x, w['blocks'][l][name], bias)
+
+ state = self._dit_in_state
+ st = F.relu(state @ w['se_w1'] + w['se_b1'])
+ state_feat = (st @ w['se_w2'] + w['se_b2']).unsqueeze(0) # (1,1,D)
+ kv_text, kv_img = self._dit_in_kvt, self._dit_in_kvi
+
+ # Cross-attn K/V: backbone is constant across the 4 steps.
+ cross_kv = {}
+ for l, blk in enumerate(w['blocks']):
+ if l % 2 == 0:
+ kv = kv_text if l % 4 == 0 else kv_img
+ Kkv = blk['kw'].shape[1]
+ cross_kv[l] = (
+ lin(kv, l, 'kw', D, Kkv, blk['kb']).view(1, -1, NH, HD).transpose(1, 2),
+ lin(kv, l, 'vw', D, Kkv, blk['vb']).view(1, -1, NH, HD).transpose(1, 2))
+
+ actions = self._dit_in_noise
+ for step in range(N):
+ silu_temb = self._dit_silu_tembs[step]
+ a_emb = actions @ w['ae_w1'] + w['ae_b1']
+ x = torch.cat([a_emb, self._dit_taus[step]], -1)
+ af = (F.silu(x @ w['ae_w2'] + w['ae_b2']) @ w['ae_w3']
+ + w['ae_b3']) + self._dit_posemb
+ hidden = torch.cat([state_feat, af], 1) # (1,1+T,D)
+ for l, blk in enumerate(w['blocks']):
+ emb = self._dit_ada_embs[step][l]
+ scale, shift = emb.chunk(2, dim=1)
+ hnorm = (F.layer_norm(hidden, (D,), None, None, 1e-5)
+ * (1 + scale[:, None]) + shift[:, None])
+ q = lin(hnorm, l, 'qw', D, D, blk['qb'])
+ S_q = q.shape[1]
+ q = q.view(1, S_q, NH, HD).transpose(1, 2)
+ if l % 2 == 1: # self-attn
+ k = lin(hnorm, l, 'kw', D, D, blk['kb']).view(1, S_q, NH, HD).transpose(1, 2)
+ v = lin(hnorm, l, 'vw', D, D, blk['vb']).view(1, S_q, NH, HD).transpose(1, 2)
+ else: # cross-attn
+ k, v = cross_kv[l]
+ o = F.scaled_dot_product_attention(q, k, v)
+ o = o.transpose(1, 2).reshape(1, S_q, D)
+ hidden = hidden + lin(o, l, 'ow', D, D, blk['ob'])
+ hnorm = F.layer_norm(hidden, (D,), None, None, 1e-5)
+ ff = F.gelu(lin(hnorm, l, 'fw', H, D, blk['fb']),
+ approximate='tanh')
+ hidden = hidden + lin(ff, l, 'dw', D, H, blk['db'])
+ emb = F.linear(silu_temb, w['proj1_w'], w['proj1_b'])
+ # HF DiT proj_out_1 chunk order is (shift, scale) — not (scale, shift).
+ shift, scale = emb.chunk(2, dim=1)
+ hidden = (F.layer_norm(hidden, (D,), None, None, 1e-6)
+ * (1 + scale[:, None]) + shift[:, None])
+ out = F.linear(hidden, w['proj2_w'], w['proj2_b'])
+ dec = F.relu(out @ w['ad_w1'] + w['ad_b1'])
+ pred = dec @ w['ad_w2'] + w['ad_b2']
+ actions = actions + (1.0 / N) * pred[:, -T:]
+ self._dit_out.copy_(actions)
+
def _copy_state_feature_to_dit(self, state):
"""Encode current robot state into the captured DiT input buffer.
@@ -896,48 +1849,109 @@ def _dit_forward(self, sa_embs, backbone_features, image_mask, backbone_mask, te
output = self._fp16_gemm(hidden.squeeze(0), self._dit_proj_out_2_w, Sa, self.output_dim, D)
return output.unsqueeze(0) # [1, Sa, output_dim]
+ # ─────────────────────────────────────────────────────────────
+ # CUDA-graph idle guard
+ #
+ # Jetson Thor silently invalidates captured graphs after a few seconds
+ # of GPU idle (replays then run fast but emit garbage); see
+ # docs/thor_gpu_idle_reset_workaround.md. infer() re-captures instead
+ # of replaying once the GPU has been idle longer than
+ # FLASHRT_GRAPH_IDLE_REINIT_S.
+ # ─────────────────────────────────────────────────────────────
+
+ @property
+ def _graph_idle_limit_s(self) -> float:
+ return float(os.environ.get("FLASHRT_GRAPH_IDLE_REINIT_S", "2"))
+
+ def _graph_idle_stale(self) -> bool:
+ return (time.monotonic() - getattr(
+ self, "_last_graph_use", time.monotonic())) > self._graph_idle_limit_s
+
+ def invalidate_graphs(self) -> None:
+ """Force the captured CUDA graphs to be re-captured on next use."""
+ self._last_graph_use = 0.0
+
+ def reset_graph_runtime(self) -> None:
+ """Drop every capture-time artifact so the next ``infer`` re-captures.
+
+ Prompt state (``set_prompt`` outputs) is kept; ``_full_sd`` was
+ released after the first capture and is reloaded by ``infer`` before
+ re-capture. The calibration disk cache makes re-capture cheap.
+ """
+ stale_prefixes = ("_sig_", "_g_", "_mlp1_")
+ stale_exact = ("_siglip_graph", "_qwen3_graph", "_dit_graph",
+ "_attn", "_vision_features", "_unit_scale",
+ "_qwen3_torch_graph", "_siglip_torch_graph",
+ # DiT static buffers/indexes depend on Se/masks and
+ # must be rebuilt on prompt-switch re-capture.
+ "_dit_in_state", "_dit_in_kvt", "_dit_in_kvi",
+ "_dit_in_noise", "_dit_out", "_dit_txt_idx",
+ "_dit_img_idx")
+ for name in list(vars(self)):
+ if name.startswith(stale_prefixes) or name in stale_exact:
+ delattr(self, name)
+ self._graphs_built = False
+ torch.cuda.empty_cache()
+
# ─────────────────────────────────────────────────────────────
# Public API
# ─────────────────────────────────────────────────────────────
- def set_prompt(self, prompt):
- """Tokenize prompt and prepare text embeddings for Qwen3 backbone."""
+ def set_prompt(self, prompt, input_ids=None):
+ """Tokenize prompt and prepare text embeddings for Qwen3 backbone.
+
+ Args:
+ prompt: language instruction (used for logging / change-detect).
+ input_ids: optional pre-computed HF token sequence (list[int]).
+ HF wraps the instruction in a chat template (system + user
+ headers) and expands each view into a per-view
+ ``
…IMG_CONTEXT…`` block; a naive ``encode(prompt)``
+ yields a DIFFERENT sequence whose backbone features are
+ uncorrelated with HF's (cos~0), breaking DiT conditioning.
+ When supplied (e.g. from the serving aux builder, which
+ reproduces HF exactly), these ids are used verbatim.
+ """
if getattr(self, '_graphs_built', False):
raise RuntimeError(
"set_prompt() after the pipeline is built is not supported; "
"construct a new GrootTorchFrontendThor instance for a new prompt")
- from transformers import AutoTokenizer
-
- if not hasattr(self, '_tokenizer'):
- eagle_dir = pathlib.Path(__file__).parent.parent.parent.parent / "configs"
- # Try multiple tokenizer locations
- for tok_path in [
- str(self._checkpoint_path), # checkpoint dir may have tokenizer
- str(self._checkpoint_path / "tokenizer"), # subfolder
- # Local GROOT code Eagle dir
- str(pathlib.Path(__file__).parent.parent.parent.parent.parent /
- "GR00T" / "Isaac-GR00T" / "gr00t" / "model" / "modules" / "nvidia" / "Eagle-Block2A-2B-v2"),
- "nvidia/Eagle-Block2A-2B-v2", # HF hub (fallback)
- ]:
- try:
- self._tokenizer = AutoTokenizer.from_pretrained(tok_path, trust_remote_code=True)
- break
- except Exception:
- continue
- if not hasattr(self, '_tokenizer'):
- raise RuntimeError("Cannot load Qwen3 tokenizer")
- self._img_token_id = 151669 #
- self._img_start_id = 151670 #
- self._img_end_id = 151671 #
+ # Image special-token ids (fixed for the Eagle vocab).
+ self._img_token_id = 151669 #
+ self._img_start_id = 151670 #
+ self._img_end_id = 151671 #
+
+ if input_ids is not None:
+ full_ids = list(input_ids)
+ text_count = sum(1 for t in full_ids if t != self._img_token_id)
+ else:
+ from transformers import AutoTokenizer
- S_img = self._num_views * self.spv # image tokens after pixel unshuffle
- text_ids = self._tokenizer.encode(prompt, add_special_tokens=False)
- # Build: text +
+ *S_img +
- full_ids = text_ids + [self._img_start_id] + [self._img_token_id] * S_img + [self._img_end_id]
+ if not hasattr(self, '_tokenizer'):
+ for tok_path in [
+ str(self._checkpoint_path), # checkpoint dir may have tokenizer
+ str(self._checkpoint_path / "tokenizer"), # subfolder
+ # Local GROOT code Eagle dir
+ str(pathlib.Path(__file__).parent.parent.parent.parent.parent /
+ "GR00T" / "Isaac-GR00T" / "gr00t" / "model" / "modules" / "nvidia" / "Eagle-Block2A-2B-v2"),
+ "nvidia/Eagle-Block2A-2B-v2", # HF hub (fallback)
+ ]:
+ try:
+ self._tokenizer = AutoTokenizer.from_pretrained(tok_path, trust_remote_code=True)
+ break
+ except Exception:
+ continue
+ if not hasattr(self, '_tokenizer'):
+ raise RuntimeError("Cannot load Qwen3 tokenizer")
+
+ S_img = self._num_views * self.spv # image tokens after pixel unshuffle
+ text_ids = self._tokenizer.encode(prompt, add_special_tokens=False)
+ # Build: text +
+ *S_img +
+ full_ids = text_ids + [self._img_start_id] + [self._img_token_id] * S_img + [self._img_end_id]
+ text_count = len(text_ids)
self._input_ids = torch.tensor([full_ids], dtype=torch.long, device='cuda')
- self._text_len = len(text_ids)
+ self._text_len = text_count
self._Se = len(full_ids)
self._prompt_text = prompt
@@ -948,8 +1962,10 @@ def set_prompt(self, prompt):
self._image_mask = (self._input_ids == self._img_token_id) # [1, Se]
self._backbone_mask = torch.ones(1, self._Se, dtype=torch.bool, device='cuda')
- logger.info("Prompt set: '%s' (%d text + %d img = %d total tokens)",
- prompt[:50], self._text_len, S_img, self._Se)
+ n_img_tok = int(self._image_mask.sum())
+ logger.info("Prompt set: '%s' (%d non-img + %d IMG_CONTEXT = %d total tokens%s)",
+ prompt[:50], self._text_len, n_img_tok, self._Se,
+ ", HF input_ids" if input_ids is not None else "")
def infer_action_head(self, backbone_features, image_mask, backbone_mask,
state, action_horizon=None, noise_seed=None):
@@ -1207,7 +2223,11 @@ def _patch_embed_image(self, img_np):
pixel = torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0).to(fp16).cuda()
patches = pixel.unfold(2, patch_size, patch_size).unfold(3, patch_size, patch_size)
- patches = patches.permute(0, 2, 3, 1, 4, 5).reshape(S, -1) # [S, 588]
+ # HF NaFlex convert_images_to_patches flattens each patch as
+ # (ph, pw, C) — channel-LAST. The openpi-lineage (C, ph, pw) order
+ # feeds the HF-trained patch_embedding the wrong column order
+ # (embed cos 0.966 vs HF).
+ patches = patches.permute(0, 2, 3, 4, 5, 1).reshape(S, -1) # [S, 588]
# Linear patch embed + position embed → write into SigLIP buffer
self._sig_x[:S].copy_(
@@ -1445,45 +2465,111 @@ def infer(self, obs):
if not hasattr(self, '_input_ids'):
raise RuntimeError("Call set_prompt() before infer()")
- # ── Lazy graph capture on first call ──
+ # ── Lazy graph capture on first call; idle re-capture guard ──
+ # Thor invalidates captured graphs after a few seconds of GPU idle
+ # (replays then run fast but emit garbage) — re-capture instead.
+ if getattr(self, '_graphs_built', False) and self._graph_idle_stale():
+ logger.info("CUDA graphs idle-stale (>%.1fs) — re-capturing",
+ self._graph_idle_limit_s)
+ self.reset_graph_runtime()
if not getattr(self, '_graphs_built', False):
+ if not hasattr(self, '_full_sd'):
+ self._load_checkpoint()
self._capture_all_graphs(obs)
# ── 1. SigLIP: patch embed + graph replay + mlp1 ──
views = [obs['image']]
if 'wrist_image' in obs and self._num_views >= 2:
views.append(obs['wrist_image'])
- self._patch_embed_2views(views)
- self._siglip_graph.replay()
- torch.cuda.synchronize() # pixel_unshuffle needs SigLIP output
- self._run_pixel_unshuffle_mlp1()
+ if getattr(self, "_torch_siglip", None) is not None:
+ # Parity mode: HF-native bf16 SigLIP. Embeddings (patch embed +
+ # pos + NaFlex window split) run eager; encoder runs as a graph.
+ self._run_torch_siglip(views)
+ else:
+ self._patch_embed_2views(views)
+ self._siglip_graph.replay()
+ torch.cuda.synchronize() # pixel_unshuffle needs SigLIP output
+ if getattr(self, "_torch_siglip", None) is not None:
+ self._run_pixel_unshuffle_mlp1_bd()
+ else:
+ self._run_pixel_unshuffle_mlp1()
# ── 2. Build input embeddings (pre-allocated buffer) ──
- self._g_ie_buf.copy_(self._text_embeds.squeeze(0).to(fp16))
- self._g_ie_buf[self._image_mask[0]] = self._g_vision_out.to(fp16)
- fvk.gpu_copy(self._g_qwen3.b_x.data_ptr(), self._g_ie_buf.data_ptr(),
- self._Se * self.D_llm * 2, 0)
+ self._g_ie_buf.copy_(self._text_embeds.squeeze(0).to(self._bd))
+ self._g_ie_buf[self._image_mask[0]] = self._g_vision_out.to(self._bd)
# ── 3. Qwen3 graph replay (+ vlln) ──
- self._qwen3_graph.replay()
- torch.cuda.synchronize() # KV update needs Qwen3 output
-
- # ── 4. Update DiT KV + init noise ──
- bb = self._g_vlln_buf.unsqueeze(0)
- self._g_dit.b_kv_text.copy_((bb * self._g_non_img.unsqueeze(-1).to(fp16)).squeeze(0))
- self._g_dit.b_kv_img.copy_((bb * self._g_img_m.unsqueeze(-1).to(fp16)).squeeze(0))
-
- # Precompute cross-attention K/V projections (runs once, reused across 4 steps)
- self._g_dit.precompute_cross_kv()
- self._copy_state_feature_to_dit(
- obs.get('state', np.zeros(self.state_dim, dtype=np.float32)))
- self._g_dit.b_actions.normal_()
-
- # ── 5. DiT graph replay ──
- self._dit_graph.replay()
+ if getattr(self, "_torch_qwen3", None) is not None:
+ # Parity mode (--no-fp8): HF-native Qwen3Model (bf16, sdpa)
+ # captured as a CUDA graph — identical math to the HF eager
+ # baseline backbone at graph-replay latency.
+ self._tq_in.copy_(self._g_ie_buf.to(torch.bfloat16))
+ self._qwen3_torch_graph.replay()
+ torch.cuda.synchronize()
+ else:
+ fvk.gpu_copy(self._g_qwen3.b_x.data_ptr(),
+ self._g_ie_buf.data_ptr(),
+ self._Se * self.D_llm * 2, 0)
+ self._qwen3_graph.replay()
+ torch.cuda.synchronize() # KV update needs Qwen3 output
+ # Thor can silently invalidate captured graphs (replays stay fast
+ # but emit NaN/garbage — see docs/thor_gpu_idle_reset_workaround.md).
+ # The vlln LN output is the first cheap full-width signal after the
+ # backbone replay; if it is non-finite, re-capture once and retry
+ # this observation instead of serving garbage.
+ if not bool(torch.isfinite(self._g_vlln_buf).all()):
+ logger.error(
+ "Non-finite backbone output detected after graph replay — "
+ "graphs invalidated; re-capturing and retrying this frame")
+ self.reset_graph_runtime()
+ if not hasattr(self, '_full_sd'):
+ self._load_checkpoint()
+ self._capture_all_graphs(obs)
+ if getattr(self, "_torch_siglip", None) is not None:
+ self._run_torch_siglip(views)
+ self._run_pixel_unshuffle_mlp1_bd()
+ else:
+ self._patch_embed_2views(views)
+ self._siglip_graph.replay()
+ torch.cuda.synchronize()
+ self._run_pixel_unshuffle_mlp1()
+ torch.cuda.synchronize()
+ self._g_ie_buf.copy_(self._text_embeds.squeeze(0).to(self._bd))
+ self._g_ie_buf[self._image_mask[0]] = self._g_vision_out.to(self._bd)
+ if getattr(self, "_torch_qwen3", None) is not None:
+ self._tq_in.copy_(self._g_ie_buf.to(torch.bfloat16))
+ self._qwen3_torch_graph.replay()
+ torch.cuda.synchronize()
+ else:
+ fvk.gpu_copy(self._g_qwen3.b_x.data_ptr(),
+ self._g_ie_buf.data_ptr(),
+ self._Se * self.D_llm * 2, 0)
+ self._qwen3_graph.replay()
+ torch.cuda.synchronize()
+ if not bool(torch.isfinite(self._g_vlln_buf).all()):
+ raise RuntimeError(
+ "backbone graph output still non-finite after re-capture")
+
+ # ── 4. DiT ──
+ # parity mode: HF-faithful fp32 torch DiT (max accuracy).
+ # fast mode (--no-parity): kernel DiT (concat bug fixed; ~4ms).
+ if not self.parity:
+ kd = self._g_dit
+ self._fill_dit_kv()
+ kd.precompute_cross_kv()
+ self._copy_state_feature_to_dit(
+ obs.get('state', np.zeros(self.state_dim, dtype=np.float32)))
+ kd.b_actions.normal_()
+ self._dit_graph.replay()
+ torch.cuda.synchronize()
+ actions = kd.b_actions
+ else:
+ actions = self._run_torch_dit(
+ obs.get('state', np.zeros(self.state_dim, dtype=np.float32)))
torch.cuda.synchronize()
- return {'actions': self._g_dit.b_actions.squeeze(0).cpu().numpy()}
+ self._last_graph_use = time.monotonic()
+ return {'actions': actions.squeeze(0).float().cpu().numpy()}
# ─────────────────────────────────────────────────────────────
# FP8 Activation Calibration
@@ -1789,6 +2875,8 @@ def _calibrate_dit(self):
def _calibrate_single_frame(self, obs):
"""N=1 path: build pipeline (which calibrates) and snapshot the spec."""
if not getattr(self, "_graphs_built", False):
+ if not hasattr(self, "_full_sd"):
+ self._load_checkpoint()
self._capture_all_graphs(obs)
self._calibrated = True
self._precision_spec = self._snapshot_precision_spec(
@@ -1822,6 +2910,8 @@ def _calibrate_multi_frame(
n, percentile)
if not getattr(self, "_graphs_built", False):
+ if not hasattr(self, "_full_sd"):
+ self._load_checkpoint()
self._capture_all_graphs(obs_list[0], release_full_sd=False)
# ── Pass A: collect per-sample Qwen3 amax (uses _full_sd) ──
@@ -1898,9 +2988,7 @@ def _collect_dit_amax_for_obs(self, obs):
vlln_b.data_ptr(), self._g_vlln_buf.data_ptr(),
Se, self.D_llm, 1e-5, 0)
torch.cuda.synchronize()
- bb = self._g_vlln_buf.unsqueeze(0)
- self._g_dit.b_kv_text.copy_((bb * self._g_non_img.unsqueeze(-1).to(fp16)).squeeze(0))
- self._g_dit.b_kv_img.copy_((bb * self._g_img_m.unsqueeze(-1).to(fp16)).squeeze(0))
+ self._fill_dit_kv()
self._g_dit.precompute_cross_kv()
torch.cuda.synchronize()
@@ -1992,12 +3080,90 @@ def _patch_embed_2views(self, views):
arr = (arr - 0.5) / 0.5
pixel = torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0).to(fp16).cuda()
patches = pixel.unfold(2, 14, 14).unfold(3, 14, 14)
- patches = patches.permute(0, 2, 3, 1, 4, 5).reshape(self.spv_raw, -1)
+ # HF NaFlex patch order is channel-LAST (ph, pw, C); see
+ # _patch_embed_image for why the openpi (C, ph, pw) order is wrong.
+ patches = patches.permute(0, 2, 3, 4, 5, 1).reshape(self.spv_raw, -1)
embedded = (F.linear(patches.float(), self._sig_patch_w.float(),
self._sig_patch_b.float())
+ self._sig_pos_embed[:self.spv_raw].float()).to(fp16)
self._sig_x[idx * self.spv_raw:(idx + 1) * self.spv_raw].copy_(embedded)
+ def _run_torch_siglip(self, views):
+ """Parity mode: HF-native bf16 SigLIP (embeddings eager + encoder graph)."""
+ if getattr(self, "_tsig_u8", None) is None:
+ self._tsig_u8 = torch.empty(
+ self._num_views, self.image_size, self.image_size, 3,
+ dtype=torch.uint8, device='cuda')
+ for i, v in enumerate(views):
+ arr = np.array(v, dtype=np.uint8, copy=True)
+ if not arr.flags['C_CONTIGUOUS']:
+ arr = np.ascontiguousarray(arr)
+ self._tsig_u8[i].copy_(torch.from_numpy(arr))
+ x = self._tsig_u8[i].to(torch.float32).div_(255.0).sub_(0.5).div_(0.5)
+ self._tsig_in[i].copy_(x.permute(2, 0, 1).to(torch.bfloat16))
+ if not getattr(self, "_sig_embed_in_graph", False):
+ if getattr(self, "_tsig_gidx", None) is not None:
+ with torch.no_grad():
+ pv = self._tsig_in
+ nv, _, nph, ps, npw = (pv.shape[0], pv.shape[1],
+ self._tsig_nph, self._tsig_ps,
+ self._tsig_npw)
+ patched = (pv.reshape(nv, 3, nph, ps, npw, ps)
+ .permute(0, 2, 4, 3, 5, 1)
+ .reshape(nv * nph * npw, -1))
+ emb_mod = self._torch_siglip.vision_model.embeddings
+ pe = emb_mod.patch_embedding(patched)
+ self._tw_in.copy_(
+ (pe + self._tsig_pos).index_select(
+ 0, self._tsig_gidx).unsqueeze(0))
+ else:
+ with torch.no_grad():
+ _wt, _, _, _ = self._torch_siglip.vision_model.embeddings(
+ [self._tsig_in[i:i + 1] for i in range(len(views))])
+ self._tw_in.copy_(_wt)
+ if getattr(self, "_siglip_torch_graph", None) is not None:
+ self._siglip_torch_graph.replay()
+ else:
+ # Capture-time fallback: encoder runs eager.
+ with torch.no_grad():
+ _enc = self._torch_siglip.vision_model.encoder(
+ inputs_embeds=self._tw_in,
+ win_meta_list=self._tw_meta,
+ spatial_shapes=self._tw_shapes)
+ _lh = self._torch_siglip.vision_model.post_layernorm(
+ _enc.last_hidden_state)
+ self._g_sig_postln.copy_(
+ _lh[:, self._tw_rm].to(self._bd).squeeze(0))
+ torch.cuda.synchronize()
+
+ def _run_pixel_unshuffle_mlp1_bd(self):
+ """Parity mode: pixel unshuffle + mlp1 in bf16 torch (HF-exact math)."""
+ if getattr(self, "_mlp1_fc1_w_lin", None) is None:
+ # Cache F.linear-layout bf16 weights once (the fp16 sources are
+ # transposed for the kernel NN convention); avoids per-frame
+ # .T.contiguous()/.float() re-materialization.
+ self._mlp1_ln_w_f32 = self._mlp1_ln_w.to(torch.float32)
+ self._mlp1_ln_b_f32 = self._mlp1_ln_b.to(torch.float32)
+ self._mlp1_fc1_w_lin = self._mlp1_fc1_w.t().contiguous().to(self._bd)
+ self._mlp1_fc1_b_bd = self._mlp1_fc1_b.to(self._bd)
+ self._mlp1_fc2_w_lin = self._mlp1_fc2_w.t().contiguous().to(self._bd)
+ self._mlp1_fc2_b_bd = self._mlp1_fc2_b.to(self._bd)
+ nH = int(math.sqrt(self.spv_raw))
+ D, S_img = self.D_sig, self._num_views * self.spv
+ all_flat = []
+ for v in range(self._num_views):
+ view_out = self._g_sig_postln[v * self.spv_raw:(v + 1) * self.spv_raw]
+ spatial = view_out.view(1, nH, nH, D).permute(0, 3, 1, 2)
+ flat = F.pixel_unshuffle(spatial, 2).view(self.mlp1_in, -1).T.contiguous()
+ all_flat.append(flat)
+ combined = torch.cat(all_flat, dim=0)
+ h = F.layer_norm(combined.float(), (self.mlp1_in,),
+ self._mlp1_ln_w_f32, self._mlp1_ln_b_f32,
+ 1e-5).to(self._bd)
+ h = F.gelu(F.linear(h, self._mlp1_fc1_w_lin, self._mlp1_fc1_b_bd))
+ h = F.linear(h, self._mlp1_fc2_w_lin, self._mlp1_fc2_b_bd)
+ self._g_vision_out.copy_(h)
+
def _run_pixel_unshuffle_mlp1(self):
"""Pixel unshuffle + mlp1 (runs outside graph, ~0.3ms)."""
nH = int(math.sqrt(self.spv_raw))
@@ -2037,6 +3203,29 @@ def _capture_all_graphs(self, obs, release_full_sd: bool = True):
from flash_rt.models.groot.pipeline_thor import CKernelQwen3, CKernelDiTHead
logger.info("Capturing CUDA Graphs for E2E pipeline...")
+ # Extract bf16 action-head weights for the HF-faithful torch DiT
+ # path (must happen while _full_sd is alive).
+ self._setup_torch_dit()
+ if not self.use_fp8 and not hasattr(self, "_torch_qwen3"):
+ self._setup_torch_qwen3()
+ if not self.use_fp8:
+ self._setup_qwen3_fp4()
+ if not self.use_fp8 and not hasattr(self, "_torch_siglip"):
+ self._setup_torch_siglip()
+ # Static NaFlex window meta (shapes fixed per image_size):
+ # needed by the capture-time eager encoder run before 6c.
+ self._tsig_in = torch.empty(
+ self._num_views, 3, self.image_size, self.image_size,
+ dtype=torch.bfloat16, device='cuda')
+ with torch.no_grad():
+ _wt0, self._tw_meta, self._tw_shapes, self._tw_rm = \
+ self._torch_siglip.vision_model.embeddings(
+ [torch.zeros(1, 3, self.image_size, self.image_size,
+ dtype=torch.bfloat16, device='cuda')
+ for _ in range(self._num_views)])
+ self._tw_rm = self._tw_rm.to(torch.long).cuda()
+ self._tw_in = torch.empty_like(_wt0)
+
Se = self._Se
S_sig = self._num_views * self.spv_raw
S_img = self._num_views * self.spv
@@ -2044,15 +3233,22 @@ def _capture_all_graphs(self, obs, release_full_sd: bool = True):
# ── 1. Build CKernel objects FIRST (from sd dict, before SigLIP FP8 init) ──
# This ensures cuBLASLt workspace is not polluted by SigLIP FP8 quantization
self._g_qwen3 = CKernelQwen3(self._full_sd, Se, use_fp8=self.use_fp8)
- vlln_w = self._vlln_w.to(fp16)
- vlln_b = self._vlln_b.to(fp16)
+ # Persistent (NOT local): these pointers are baked into the Qwen3
+ # graph's vlln LayerNorm. Local tensors would be freed when this
+ # function returns and their blocks reused — replays would then
+ # dereference dangling memory and emit NaN.
+ self._g_vlln_w = self._vlln_w.to(fp16).contiguous()
+ self._g_vlln_b = self._vlln_b.to(fp16).contiguous()
+ vlln_w = self._g_vlln_w
+ vlln_b = self._g_vlln_b
self._g_non_img = (~self._image_mask) & self._backbone_mask
self._g_img_m = self._image_mask & self._backbone_mask
T = self.action_horizon
self._g_dit = CKernelDiTHead(self._full_sd, self._embodiment_id, T,
- (1, Se, self.D_llm), use_fp8=self.use_fp8)
+ (1, Se, self.D_llm), use_fp8=self.use_fp8,
+ num_steps=self.num_steps)
# ── 2. SigLIP init (after CKernel objects, so FP8 quant doesn't pollute cuBLAS state) ──
self._load_siglip2_weights(self._full_sd)
@@ -2073,11 +3269,20 @@ def _capture_all_graphs(self, obs, release_full_sd: bool = True):
qwen3_seq_max=Se,
sa=self._g_dit.Sa,
s_kv=self._g_dit.S_kv,
+ # Live HF N1.6 runs SigLIP2 under sdpa, which ignores the
+ # NaFlex per-view seq_len_list segmentation -> all packed
+ # views attend jointly (cross-view full attention).
+ siglip_cross_view=True,
),
siglip_slots={
"qkv": self._sig_qkv.data_ptr(),
"O": self._sig_attn.data_ptr(),
"D": self.D_sig,
+ # tensor views let the backend run exact torch sdpa for
+ # SigLIP (strided FMHA kernel unreliable at non-power-of-2
+ # seqs like 648)
+ "qkv_t": self._sig_qkv,
+ "O_t": self._sig_attn,
},
qwen3_slots={
"ctx": self._g_qwen3.ctx, # Qwen3's cuBLAS handle
@@ -2112,16 +3317,19 @@ def _capture_all_graphs(self, obs, release_full_sd: bool = True):
self._g_qwen3.attn = self._attn
self._g_dit.attn = self._attn
+ # HF N1.6 (sdpa) does cross-view full attention over all packed
+ # views, so run SigLIP as ONE batch-1 sequence of S_sig tokens
+ # instead of num_views independent 256-token views.
sig_dims = {'S': S_sig, 'D': self.D_sig, 'H': self.H_sig,
'NH': self.NH_sig, 'HD': self.HD_sig, 'L': self.L_sig,
- 'num_views': self._num_views, 'seq_per_view': self.spv_raw}
+ 'num_views': 1, 'seq_per_view': S_sig}
# Allocate graph-persistent buffers
- self._g_sig_postln = torch.empty(S_sig, self.D_sig, dtype=fp16, device='cuda')
- self._g_mlp1_ln = torch.empty(S_img, self.mlp1_in, dtype=fp16, device='cuda')
- self._g_mlp1_fc1 = torch.empty(S_img, self.D_llm, dtype=fp16, device='cuda')
- self._g_vision_out = torch.empty(S_img, self.D_llm, dtype=fp16, device='cuda')
- self._g_vlln_buf = torch.empty(Se, self.D_llm, dtype=fp16, device='cuda')
+ self._g_sig_postln = torch.empty(S_sig, self.D_sig, dtype=self._bd, device='cuda')
+ self._g_mlp1_ln = torch.empty(S_img, self.mlp1_in, dtype=self._bd, device='cuda')
+ self._g_mlp1_fc1 = torch.empty(S_img, self.D_llm, dtype=self._bd, device='cuda')
+ self._g_vision_out = torch.empty(S_img, self.D_llm, dtype=self._bd, device='cuda')
+ self._g_vlln_buf = torch.empty(Se, self.D_llm, dtype=self._bd, device='cuda')
# ── 3. SigLIP graph ──
stream = torch.cuda.Stream()
@@ -2148,15 +3356,19 @@ def _run_sig(si):
views = [obs['image']]
if 'wrist_image' in obs and self._num_views >= 2:
views.append(obs['wrist_image'])
- self._patch_embed_2views(views)
- self._siglip_graph.replay()
- torch.cuda.synchronize()
- self._run_pixel_unshuffle_mlp1()
- torch.cuda.synchronize()
+ if getattr(self, "_torch_siglip", None) is not None:
+ self._run_torch_siglip(views)
+ self._run_pixel_unshuffle_mlp1_bd()
+ else:
+ self._patch_embed_2views(views)
+ self._siglip_graph.replay()
+ torch.cuda.synchronize()
+ self._run_pixel_unshuffle_mlp1()
+ torch.cuda.synchronize()
input_embeds = self._text_embeds.clone()
input_embeds[0, self._image_mask[0]] = self._g_vision_out.to(input_embeds.dtype)
- ie_fp16 = input_embeds.squeeze(0).to(fp16).contiguous()
+ ie_fp16 = input_embeds.squeeze(0).to(self._bd).contiguous()
# ── 4. Calibrate + run Qwen3 ──
self._calibrate_qwen3(ie_fp16)
@@ -2169,9 +3381,7 @@ def _run_sig(si):
Se, self.D_llm, 1e-5, 0)
torch.cuda.synchronize()
- bb = self._g_vlln_buf.unsqueeze(0)
- self._g_dit.b_kv_text.copy_((bb * self._g_non_img.unsqueeze(-1).to(fp16)).squeeze(0))
- self._g_dit.b_kv_img.copy_((bb * self._g_img_m.unsqueeze(-1).to(fp16)).squeeze(0))
+ self._fill_dit_kv()
# Precompute cross-attention K/V projections (constant across steps)
self._g_dit.precompute_cross_kv()
@@ -2223,6 +3433,150 @@ def _run_sig(si):
torch.cuda.synchronize()
logger.info(" Qwen3 graph captured (Se=%d)", Se)
+ # ── 6b. Torch Qwen3 graph (parity mode) ──
+ if getattr(self, "_torch_qwen3", None) is not None:
+ self._tq_in = torch.empty(
+ 1, Se, self.D_llm, dtype=torch.bfloat16, device='cuda')
+
+ def _run_tq():
+ with torch.no_grad():
+ _out = self._torch_qwen3(
+ inputs_embeds=self._tq_in).last_hidden_state.squeeze(0)
+ _v = torch.nn.functional.layer_norm(
+ _out.float(), (self.D_llm,),
+ self._g_vlln_w.float(), self._g_vlln_b.float(),
+ 1e-5).to(self._bd)
+ self._g_vlln_buf.copy_(_v)
+
+ stream_tq = torch.cuda.Stream()
+ with torch.cuda.stream(stream_tq):
+ for _ in range(2):
+ _run_tq()
+ torch.cuda.synchronize()
+ self._qwen3_torch_graph = torch.cuda.CUDAGraph()
+ with torch.cuda.stream(stream_tq):
+ self._qwen3_torch_graph.capture_begin()
+ _run_tq()
+ self._qwen3_torch_graph.capture_end()
+ torch.cuda.synchronize()
+ logger.info(" Torch Qwen3 graph captured (Se=%d)", Se)
+
+ # ── 6c. Torch SigLIP graph (parity mode) ──
+ # The NaFlex window split contains a non-capturable GPU index op, so
+ # embeddings run eager per frame; only the 27-layer encoder (+post-LN
+ # + reverse mapping) is captured, over a static windows buffer.
+ if getattr(self, "_torch_siglip", None) is not None:
+ nv = self._num_views
+ if not hasattr(self, "_tsig_in"):
+ self._tsig_in = torch.empty(
+ nv, 3, self.image_size, self.image_size,
+ dtype=torch.bfloat16, device='cuda')
+ if not hasattr(self, "_tw_in"):
+ with torch.no_grad():
+ _wt, self._tw_meta, self._tw_shapes, self._tw_rm = \
+ self._torch_siglip.vision_model.embeddings(
+ [self._tsig_in[i:i + 1] for i in range(nv)])
+ self._tw_rm = self._tw_rm.to(torch.long).cuda()
+ self._tw_in = torch.empty_like(_wt)
+
+ # Fast embeddings: the NaFlex window split is a static gather for a
+ # fixed image size, and the antialias positional-embedding resize
+ # depends only on static shapes. Precompute both once so the
+ # per-frame path is patchify + patch_embedding + pos_add + gather
+ # (all capture-safe), skipping the unfold/im2col + antialias
+ # interpolate (~0.4 ms/frame). Verified bit-exact vs the HF
+ # embeddings forward (max diff 0.0).
+ if not hasattr(self, "_tsig_gidx"):
+ emb = self._torch_siglip.vision_model.embeddings
+ ps = emb.patch_size
+ nph = self.image_size // ps
+ npw = self.image_size // ps
+ self._tsig_ps, self._tsig_nph, self._tsig_npw = ps, nph, npw
+ bchw = [torch.Size((1, 3, self.image_size, self.image_size))
+ for _ in range(nv)]
+ ss = emb.get_spatial_shapes(bchw)
+ fake = torch.arange(nv * nph * npw, dtype=torch.float32,
+ device='cuda').view(1, nv * nph * npw, 1)
+ wt, _, _ = emb.split_patch_embeddings_to_windows_with_meta(
+ fake, ss, emb.window_size)
+ self._tsig_gidx = wt.view(-1).long().cuda()
+ pos = emb.position_embedding.weight.reshape(
+ emb.position_embedding_size, emb.position_embedding_size, -1)
+ self._tsig_pos = emb.resize_positional_embeddings(pos, ss)[0]
+
+ def _sig_embed_to_twin():
+ # Capture-safe fast embeddings: patchify + patch_embedding +
+ # precomputed pos add + static gather, _tsig_in -> _tw_in.
+ pv = self._tsig_in
+ nv, nph, ps, npw = (pv.shape[0], self._tsig_nph,
+ self._tsig_ps, self._tsig_npw)
+ patched = (pv.reshape(nv, 3, nph, ps, npw, ps)
+ .permute(0, 2, 4, 3, 5, 1)
+ .reshape(nv * nph * npw, -1))
+ pe = self._torch_siglip.vision_model.embeddings.patch_embedding(
+ patched)
+ self._tw_in.copy_(
+ (pe + self._tsig_pos).index_select(
+ 0, self._tsig_gidx).unsqueeze(0))
+
+ def _run_ts():
+ with torch.no_grad():
+ _sig_embed_to_twin()
+ _enc = self._torch_siglip.vision_model.encoder(
+ inputs_embeds=self._tw_in,
+ win_meta_list=self._tw_meta,
+ spatial_shapes=self._tw_shapes)
+ _lh = self._torch_siglip.vision_model.post_layernorm(
+ _enc.last_hidden_state)
+ self._g_sig_postln.copy_(
+ _lh[:, self._tw_rm].to(self._bd).squeeze(0))
+
+ stream_ts = torch.cuda.Stream()
+ with torch.cuda.stream(stream_ts):
+ for _ in range(2):
+ _run_ts()
+ torch.cuda.synchronize()
+ # Try capturing embeddings+encoder together. If any NaFlex op is
+ # not capture-safe, fall back to an encoder-only graph and run the
+ # (fast) embeddings eager per frame.
+ self._sig_embed_in_graph = False
+ try:
+ g = torch.cuda.CUDAGraph()
+ with torch.cuda.stream(stream_ts):
+ g.capture_begin()
+ _run_ts()
+ g.capture_end()
+ torch.cuda.synchronize()
+ self._siglip_torch_graph = g
+ self._sig_embed_in_graph = True
+ logger.info(" Torch SigLIP graph captured embeddings+encoder "
+ "(S=%d)", nv * self.spv_raw)
+ except Exception as e: # noqa: BLE001
+ logger.warning("SigLIP embeddings not capture-safe (%r); "
+ "encoder-only graph + eager embeddings", e)
+ torch.cuda.synchronize()
+ g = torch.cuda.CUDAGraph()
+ def _enc_only():
+ with torch.no_grad():
+ _enc = self._torch_siglip.vision_model.encoder(
+ inputs_embeds=self._tw_in,
+ win_meta_list=self._tw_meta,
+ spatial_shapes=self._tw_shapes)
+ _lh = self._torch_siglip.vision_model.post_layernorm(
+ _enc.last_hidden_state)
+ self._g_sig_postln.copy_(
+ _lh[:, self._tw_rm].to(self._bd).squeeze(0))
+ with torch.cuda.stream(stream_ts):
+ for _ in range(2):
+ _enc_only()
+ torch.cuda.synchronize()
+ with torch.cuda.stream(stream_ts):
+ g.capture_begin()
+ _enc_only()
+ g.capture_end()
+ torch.cuda.synchronize()
+ self._siglip_torch_graph = g
+
# Reuse the exact buffer the Qwen3 graph baked into its captured
# ``gpu_copy(b_x, ie_fp16)`` so per-frame ``_g_ie_buf`` writes land at
# the pointer the graph reads on replay. (A fresh allocation here only
diff --git a/flash_rt/hardware/thor/attn_backend_groot.py b/flash_rt/hardware/thor/attn_backend_groot.py
index 08fe2ae6..3547065a 100644
--- a/flash_rt/hardware/thor/attn_backend_groot.py
+++ b/flash_rt/hardware/thor/attn_backend_groot.py
@@ -211,6 +211,21 @@ def run(self, site: str, layer_idx: int, q_seq: int,
HD = site_spec.head_dim
if kv_seq is None:
kv_seq = q_seq
+ # The strided FMHA kernel is only validated for power-of-two
+ # sequences on real data (648-seq cross-view SigLIP at the 252
+ # training resolution diverges silently). Use torch sdpa when
+ # tensor views are provided (graph-capturable, exact).
+ if "qkv_t" in s:
+ import torch.nn.functional as F
+ qkv_t = s["qkv_t"]
+ O_t = s["O_t"]
+ seq = int(q_seq)
+ q = qkv_t[:seq, :D].view(seq, NH, HD).transpose(0, 1)
+ k = qkv_t[:seq, D:2 * D].view(seq, NH, HD).transpose(0, 1)
+ v = qkv_t[:seq, 2 * D:].view(seq, NH, HD).transpose(0, 1)
+ o = F.scaled_dot_product_attention(q, k, v)
+ O_t[:seq].copy_(o.transpose(0, 1).reshape(seq, D))
+ return int(s["O"])
stride = 3 * D
Q = int(s["qkv"])
K = Q + D * 2
@@ -261,7 +276,8 @@ def run(self, site: str, layer_idx: int, q_seq: int,
def make_groot_attention_spec(*, num_views: int, qwen3_seq_max: int,
- sa: int, s_kv: int) -> AttentionSpec:
+ sa: int, s_kv: int,
+ siglip_cross_view: bool = False) -> AttentionSpec:
"""Build the GROOT AttentionSpec (4 sites).
Args:
@@ -269,13 +285,28 @@ def make_groot_attention_spec(*, num_views: int, qwen3_seq_max: int,
qwen3_seq_max: max Qwen3 sequence length (prompt + vision tokens).
sa: DiT action sequence length (hidden tokens = 1 state + T actions).
s_kv: DiT cross-attention KV length (non_img + img backbone features).
+ siglip_cross_view: N1.6 HF semantics — the live HF baseline runs
+ SigLIP2 under sdpa, which ignores the NaFlex per-view
+ ``seq_len_list`` segmentation, so all packed views attend
+ jointly. Model the SigLIP site as one batch-1 sequence of
+ ``num_views * 256`` tokens instead of ``num_views``
+ independent 256-token views. Default False keeps the legacy
+ per-view behaviour.
"""
spec = AttentionSpec()
- spec.add_site(
- "siglip",
- num_layers=27, num_q_heads=16, num_kv_heads=16, head_dim=72,
- max_q_seq=256, max_kv_seq=256, batch_axis=int(num_views),
- )
+ if siglip_cross_view:
+ spec.add_site(
+ "siglip",
+ num_layers=27, num_q_heads=16, num_kv_heads=16, head_dim=72,
+ max_q_seq=256 * int(num_views), max_kv_seq=256 * int(num_views),
+ batch_axis=1,
+ )
+ else:
+ spec.add_site(
+ "siglip",
+ num_layers=27, num_q_heads=16, num_kv_heads=16, head_dim=72,
+ max_q_seq=256, max_kv_seq=256, batch_axis=int(num_views),
+ )
spec.add_site(
"qwen3",
num_layers=16, num_q_heads=16, num_kv_heads=16, head_dim=128,
diff --git a/flash_rt/models/groot/pipeline_thor.py b/flash_rt/models/groot/pipeline_thor.py
index 00ccc2ce..d6d45a81 100644
--- a/flash_rt/models/groot/pipeline_thor.py
+++ b/flash_rt/models/groot/pipeline_thor.py
@@ -597,6 +597,9 @@ def forward(self, x_in, s=0):
fvk.gpu_strided_copy_fp16(self.b_qkv.data_ptr(), self.b_attn.data_ptr(), Se, NHKV*HD, self.QKV, NHQ*HD+NHKV*HD, s)
fvk.gpu_repeat_interleave_heads(self.b_k.data_ptr(), self.b_k_exp.data_ptr(), Se, NHKV, HD, NHQ//NHKV, s)
fvk.gpu_repeat_interleave_heads(self.b_attn.data_ptr(), self.b_v_exp.data_ptr(), Se, NHKV, HD, NHQ//NHKV, s)
+ if getattr(self, '_dbg', None) is not None:
+ self._dbg.append(('k', self.b_k.clone()))
+ self._dbg.append(('v', self.b_attn.clone()))
fvk.gpu_fill_neginf_fp16(self.b_logits.data_ptr(), self.b_logits.nelement(), s)
if self.attn is not None:
self.attn.run("qwen3", i, q_seq=Se, stream=s)
@@ -604,6 +607,8 @@ def forward(self, x_in, s=0):
fvk.attention_mha_fp16(self.ctx, self.b_q.data_ptr(), self.b_k_exp.data_ptr(), self.b_v_exp.data_ptr(),
self.b_logits.data_ptr(), self.b_o.data_ptr(), Se, Se, NHQ, HD, 1.0/math.sqrt(HD), s)
self.gemm.fp16_nn(self.b_o.data_ptr(), w['o_w'].data_ptr(), self.b_xn.data_ptr(), Se, D, D, s)
+ if getattr(self, '_dbg', None) is not None:
+ self._dbg.append(('o', self.b_xn.clone()))
fvk.residual_add_fp16(self.b_x.data_ptr(), self.b_xn.data_ptr(), Se * D, s)
fvk.rms_norm_fp16(self.b_x.data_ptr(), w['ln2_w'].data_ptr(), self.b_xn.data_ptr(), Se, D, 1e-6, s)
if self.use_fp8:
@@ -628,6 +633,9 @@ def forward(self, x_in, s=0):
self.gemm.fp16_nn(self.b_gu.data_ptr(), w['down_fp16'].data_ptr(),
self.b_down.data_ptr(), Se, D, H, s)
fvk.residual_add_fp16(self.b_x.data_ptr(), self.b_down.data_ptr(), Se * D, s)
+ if getattr(self, '_dbg', None) is not None:
+ self._dbg.append(('ff', self.b_down.clone()))
+ self._dbg.append(('x', self.b_x.clone()))
fvk.rms_norm_fp16(self.b_x.data_ptr(), self.final_norm_w.data_ptr(), self.b_xn.data_ptr(), Se, D, 1e-6, s)
return self.b_xn
@@ -640,14 +648,21 @@ class CKernelDiTHead:
"""
def __init__(self, sd_or_path, embodiment_id, action_horizon, backbone_shape,
- use_fp8=True):
+ use_fp8=True, num_steps=4):
self.gemm = fvk.GemmRunner()
self.ctx = fvk.FvkContext()
self.use_fp8 = bool(use_fp8)
self.D = 1536; self.H = 6144; self.NH = 32; self.HD = 48
- self.L = 32; self.action_dim = 128; self.num_steps = 4
+ self.L = 32; self.action_dim = 128; self.num_steps = int(num_steps)
self.T = action_horizon; self.Sa = 1 + action_horizon
self.S_kv = backbone_shape[1]; self.D_kv = backbone_shape[2]
+ # Compact-KV counts: number of VALID backbone rows written into
+ # b_kv_text / b_kv_img by the frontend. HF excludes the other
+ # modality's tokens from each cross-attention layer via an
+ # attention mask; we instead attend only to the valid rows, so the
+ # per-layer kv length must come from these counts (not S_kv).
+ self.n_kv_text = self.S_kv
+ self.n_kv_img = self.S_kv
# Optional AttentionBackend (ThorGrootAttnBackend); set post-construct
# by the frontend. When None, _run_step uses the direct
# fvk.attention_mha_fp16 calls below (bit-identical legacy path).
@@ -721,7 +736,9 @@ def _precompute(self, sd):
proj_out_1_w = sd["action_head.model.proj_out_1.weight"].T.contiguous().to(fp16)
proj_out_1_b = sd["action_head.model.proj_out_1.bias"].to(fp16)
half_dim = 128
- exp = -torch.arange(half_dim, dtype=torch.float32, device='cuda') * (math.log(10000.0) / half_dim)
+ # diffusers Timesteps(downscale_freq_shift=1): denominator is
+ # (half_dim - 1), NOT half_dim — HF's exact convention.
+ exp = -torch.arange(half_dim, dtype=torch.float32, device='cuda') * (math.log(10000.0) / (half_dim - 1))
half_d = D // 2
exp_d = (-torch.arange(half_d, dtype=torch.float, device='cuda') * (math.log(10000.0) / half_d)).exp()
self.ada_scales = []; self.ada_shifts = []
@@ -755,6 +772,11 @@ def _precompute(self, sd):
self.out_scales = torch.stack(self.out_scales); self.out_shifts = torch.stack(self.out_shifts)
self.action_time_embeds = torch.stack(self.action_time_embeds)
+ def set_kv_counts(self, n_text, n_img):
+ """Report the number of valid compact rows in b_kv_text / b_kv_img."""
+ self.n_kv_text = int(n_text)
+ self.n_kv_img = int(n_img)
+
def precompute_cross_kv(self, s=0):
"""Precompute K/V projections for all cross-attention blocks.
@@ -762,22 +784,28 @@ def precompute_cross_kv(self, s=0):
through per-block K/V weight matrices. The backbone features are CONSTANT
across diffusion steps, so these projections only need to run once.
+ Only the first n_kv_text / n_kv_img rows are valid (compact layout);
+ the other modality's tokens are excluded, matching HF's attention mask.
+
Without this: 16 blocks × 2 projections × 4 steps = 128 GEMMs
With this: 16 blocks × 2 projections × 1 time = 32 GEMMs (save 96)
"""
- D, S_kv = self.D, self.S_kv
+ D = self.D
for block_idx in range(self.L // 2):
l = block_idx * 2 # cross-attention layers: 0, 2, 4, ..., 30
w = self.dit[l]
- kv_src = self.b_kv_text if l % 4 == 0 else self.b_kv_img
+ if l % 4 == 0:
+ kv_src, n_kv = self.b_kv_text, self.n_kv_text
+ else:
+ kv_src, n_kv = self.b_kv_img, self.n_kv_img
self._fp16_gemm(kv_src.data_ptr(), w['k_w'].data_ptr(),
- self._precomp_k[block_idx].data_ptr(), S_kv, D, self.D_kv, s)
+ self._precomp_k[block_idx].data_ptr(), n_kv, D, self.D_kv, s)
fvk.add_bias_fp16(self._precomp_k[block_idx].data_ptr(),
- w['k_b'].data_ptr(), S_kv, D, s)
+ w['k_b'].data_ptr(), n_kv, D, s)
self._fp16_gemm(kv_src.data_ptr(), w['v_w'].data_ptr(),
- self._precomp_v[block_idx].data_ptr(), S_kv, D, self.D_kv, s)
+ self._precomp_v[block_idx].data_ptr(), n_kv, D, self.D_kv, s)
fvk.add_bias_fp16(self._precomp_v[block_idx].data_ptr(),
- w['v_b'].data_ptr(), S_kv, D, s)
+ w['v_b'].data_ptr(), n_kv, D, s)
def _alloc_buffers(self):
D, H, T, Sa = self.D, self.H, self.T, self.Sa
@@ -871,20 +899,36 @@ def _run_step(self, step, s=0):
fvk.gpu_cast_fp32_to_fp16(self.b_actions.data_ptr(), self.b_actions_fp16.data_ptr(), T*self.action_dim, s)
self._fp16_gemm(self.b_actions_fp16.data_ptr(), self.ae_w1.data_ptr(), self.b_a_emb.data_ptr(), T, D, self.action_dim, s)
fvk.add_bias_fp16(self.b_a_emb.data_ptr(), self.ae_b1.data_ptr(), T, D, s)
- fvk.gpu_copy(self.b_concat.data_ptr(), self.b_a_emb.data_ptr(), T*D*2, s)
- fvk.gpu_copy(self.b_concat.data_ptr()+T*D*2, self.action_time_embeds[step].data_ptr(), T*D*2, s)
+ if getattr(self, '_dbg', None) is not None:
+ self._dbg.append(('ae1', self.b_a_emb.clone()))
+ # gpu_copy is a raw memcpy: it cannot place (T,D) rows into the first
+ # D columns of a (T,2D) buffer (row stride differs). Use concat2_bf16.
+ fvk.concat2_bf16(self.b_a_emb.data_ptr(),
+ self.action_time_embeds[step].data_ptr(),
+ self.b_concat.data_ptr(), T, D, D, s)
+ if getattr(self, '_dbg', None) is not None:
+ self._dbg.append(('concat', self.b_concat.clone()))
self._fp16_gemm(self.b_concat.data_ptr(), self.ae_w2.data_ptr(), self.b_enc_h.data_ptr(), T, D, 2*D, s)
fvk.add_bias_fp16(self.b_enc_h.data_ptr(), self.ae_b2.data_ptr(), T, D, s)
fvk.silu_inplace_fp16(self.b_enc_h.data_ptr(), T*D, s)
+ if getattr(self, '_dbg', None) is not None:
+ self._dbg.append(('ae2', self.b_enc_h.clone()))
self._fp16_gemm(self.b_enc_h.data_ptr(), self.ae_w3.data_ptr(), self.b_a_emb.data_ptr(), T, D, D, s)
fvk.add_bias_fp16(self.b_a_emb.data_ptr(), self.ae_b3.data_ptr(), T, D, s)
+ if getattr(self, '_dbg', None) is not None:
+ self._dbg.append(('ae3', self.b_a_emb.clone()))
+ self._dbg.append(('tau', self.action_time_embeds[step].clone()))
fvk.residual_add_fp16(self.b_a_emb.data_ptr(), self.pos_emb[:T].data_ptr(), T*D, s)
fvk.gpu_copy(self.b_hidden.data_ptr(), self.b_state_feat.data_ptr(), D*2, s)
fvk.gpu_copy(self.b_hidden.data_ptr()+D*2, self.b_a_emb.data_ptr(), T*D*2, s)
+ if getattr(self, '_dbg', None) is not None:
+ self._dbg.append(('h0', self.b_hidden.clone()))
for l in range(self.L):
is_self = (l % 2 == 1); w = self.dit[l]
fvk.ada_layer_norm_fp16(self.b_hidden.data_ptr(), self.ada_scales[step,l].data_ptr(),
self.ada_shifts[step,l].data_ptr(), self.b_h_norm.data_ptr(), Sa, D, 1e-5, s)
+ if getattr(self, '_dbg', None) is not None:
+ self._dbg.append(('ada', self.b_h_norm.clone()))
as_qkv_ptr = self._dit_act_scales_dev.data_ptr() + (l * 3 + 0) * 4
as_up_ptr = self._dit_act_scales_dev.data_ptr() + (l * 3 + 1) * 4
as_dn_ptr = self._dit_act_scales_dev.data_ptr() + (l * 3 + 2) * 4
@@ -909,8 +953,11 @@ def _run_step(self, step, s=0):
else:
self._fp16_gemm(self.b_h_norm.data_ptr(), w['q_w'].data_ptr(), self.b_q_cross.data_ptr(), Sa, D, D, s)
fvk.add_bias_fp16(self.b_q_cross.data_ptr(), w['q_b'].data_ptr(), Sa, D, s)
- # Use precomputed K/V projections (computed once before step loop)
+ # Use precomputed K/V projections (computed once before step loop).
+ # kv length = compact valid-row count for this layer's modality
+ # (text layers see n_kv_text rows, image layers n_kv_img).
cross_idx = l // 2
+ kv_seq = self.n_kv_text if l % 4 == 0 else self.n_kv_img
k_ptr = self._precomp_k[cross_idx].data_ptr()
v_ptr = self._precomp_v[cross_idx].data_ptr()
fvk.gpu_fill_neginf_fp16(self.b_attn_logits_cross.data_ptr(), self.b_attn_logits_cross.nelement(), s)
@@ -918,12 +965,14 @@ def _run_step(self, step, s=0):
# dit_cross site: cross_idx = l // 2 indexes the 16 cross layers
# (even DiT layers 0, 2, ..., 30).
self.attn.run("dit_cross", cross_idx,
- q_seq=Sa, kv_seq=S_kv, stream=s)
+ q_seq=Sa, kv_seq=kv_seq, stream=s)
else:
fvk.attention_mha_fp16(self.ctx, self.b_q_cross.data_ptr(), k_ptr, v_ptr,
- self.b_attn_logits_cross.data_ptr(), self.b_attn_out.data_ptr(), Sa, S_kv, NH, HD, 1.0/math.sqrt(HD), s)
+ self.b_attn_logits_cross.data_ptr(), self.b_attn_out.data_ptr(), Sa, kv_seq, NH, HD, 1.0/math.sqrt(HD), s)
self._fp16_gemm(self.b_attn_out.data_ptr(), w['o_w'].data_ptr(), self.b_o.data_ptr(), Sa, D, D, s)
fvk.add_bias_fp16(self.b_o.data_ptr(), w['o_b'].data_ptr(), Sa, D, s)
+ if getattr(self, '_dbg', None) is not None:
+ self._dbg.append(('attn_o', self.b_o.clone()))
fvk.residual_add_fp16(self.b_hidden.data_ptr(), self.b_o.data_ptr(), Sa*D, s)
fvk.layer_norm_no_affine_fp16(self.b_hidden.data_ptr(), self.b_h_norm.data_ptr(), Sa, D, 1e-5, s)
if self.use_fp8:
@@ -942,6 +991,8 @@ def _run_step(self, step, s=0):
self._fp16_gemm(self.b_ff_h.data_ptr(), w['ff_dn_fp16'].data_ptr(), self.b_ff_out.data_ptr(), Sa, D, H, s)
fvk.add_bias_fp16(self.b_ff_out.data_ptr(), w['ff_dn_b'].data_ptr(), Sa, D, s)
fvk.residual_add_fp16(self.b_hidden.data_ptr(), self.b_ff_out.data_ptr(), Sa*D, s)
+ if getattr(self, '_dbg', None) is not None:
+ self._dbg.append(self.b_hidden.clone())
fvk.ada_layer_norm_fp16(self.b_hidden.data_ptr(), self.out_scales[step].data_ptr(),
self.out_shifts[step].data_ptr(), self.b_h_norm.data_ptr(), Sa, D, 1e-6, s)
self._fp16_gemm(self.b_h_norm.data_ptr(), self.proj_out_2_w.data_ptr(), self.b_model_out.data_ptr(), Sa, 1024, D, s)
diff --git a/tools/convert_groot_n16_hf_checkpoint.py b/tools/convert_groot_n16_hf_checkpoint.py
new file mode 100644
index 00000000..0a9244c6
--- /dev/null
+++ b/tools/convert_groot_n16_hf_checkpoint.py
@@ -0,0 +1,217 @@
+#!/usr/bin/env python3
+"""Offline converter: HF GR00T N1.6 checkpoint -> FlashRT weight layout.
+
+Phase-1b of the N1.6 parity plan. Converts a fine-tuned (or base) HF GR00T
+N1.6 checkpoint into the exact tensor layout the FlashRT N1.6 kernels
+consume, emitting BOTH the converted ``.safetensors`` and a JSON manifest
+recording, for every tensor: HF key, HF shape, transform, FlashRT key,
+FlashRT shape. This makes every weight mapping explicit and auditable, so a
+backbone/weight-layout mismatch can be localized to a specific rule instead
+of guessed from the final action.
+
+Layout rules (one explicit rule per weight family):
+
+ A. SigLIP2 (``backbone.model.vision_model.vision_model.*``)
+ - attention q/k/v/o and FFN fc1/fc2: HF ``[out,in]`` -> ``[in,out]``
+ (FlashRT GEMMs take ``[in,out]``); QKV kept separate, order Q,K,V.
+ - layernorm / position_embedding / patch_embedding bias: passthrough.
+ - (the double ``vision_model`` prefix is part of the HF key and kept).
+
+ B. Qwen3 (``backbone.model.language_model.model.layers.*``)
+ - q ``[2048,2048]``, k ``[1024,2048]``, v ``[1024,2048]`` are fused as
+ ``cat([q,k,v], dim=0).T.contiguous()`` -> ``[2048, 4096]``
+ (Q first, then K, then V; NO interleaving).
+ - FFN ``cat([gate_proj, up_proj], dim=0).T.contiguous()`` with order
+ ``gate | up`` (must not be swapped); down_proj transposed.
+ - layernorm / q_norm / k_norm: passthrough.
+
+ C. DiT (``action_head.model.transformer_blocks.{l}.*``)
+ - even block = cross-attention, odd block = self-attention.
+ - self-attn (odd): QKV fused ``cat([q,k,v],0).T`` -> ``[1536, 4608]``
+ (K/V input dim 1536).
+ - cross-attn (even): q transposed; k/v ``[1536,2048]`` transposed,
+ kept separate (NOT fused).
+ - FFN is GELU (not GEGLU): net.0.proj and net.2 transposed.
+ - norm1.linear (AdaLN) transposed; norm1.norm / norm3 / norm_out have
+ NO affine parameters (absent from the checkpoint); output
+ conditioning chunk order is (shift, scale).
+ - proj_out_1 / proj_out_2 / timestep_encoder linears transposed.
+
+ D. Embodiment (``action_head.{action_encoder,state_encoder,action_decoder}``)
+ - CategorySpecificLinear ``W`` is already ``[num_categories,in,out]``;
+ after selecting ``W[eid]`` it is ``[in,out]`` already -> NO extra
+ transpose. Biases passthrough.
+
+Usage:
+ python tools/convert_groot_n16_hf_checkpoint.py \
+ --src /mnt/lerobot_so101_sim_v1_gr00t_n1d6_sim_fruits_cubes_10w \
+ --dst /mnt/.../n1d6_flashrt_layout \
+ [--dtype fp16]
+"""
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+
+import torch
+from safetensors import safe_open
+from safetensors.torch import save_file
+
+
+def load_src(src: Path) -> dict:
+ sd = {}
+ for f in sorted(src.glob("*.safetensors")):
+ with safe_open(str(f), framework="pt") as sf:
+ for k in sf.keys():
+ sd[k] = sf.get_tensor(k)
+ return sd
+
+
+def main() -> None:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--src", required=True)
+ ap.add_argument("--dst", required=True)
+ ap.add_argument("--dtype", default="keep", choices=["keep", "fp16", "bf16"])
+ args = ap.parse_args()
+
+ src = Path(args.src)
+ dst = Path(args.dst)
+ dst.mkdir(parents=True, exist_ok=True)
+ sd = load_src(src)
+
+ cast = {"keep": None, "fp16": torch.float16, "bf16": torch.bfloat16}[args.dtype]
+ def maybe_cast(t: torch.Tensor) -> torch.Tensor:
+ return t.to(cast) if (cast is not None and t.is_floating_point()) else t
+
+ out: dict[str, torch.Tensor] = {}
+ manifest: list[dict] = []
+
+ def emit(hf_key: str, fr_key: str, tensor: torch.Tensor, transform: str) -> None:
+ hf_shape = list(sd[hf_key].shape) if hf_key in sd else None
+ tensor = maybe_cast(tensor)
+ out[fr_key] = tensor
+ manifest.append({
+ "hf_key": hf_key, "hf_shape": hf_shape, "transform": transform,
+ "fr_key": fr_key, "fr_shape": list(tensor.shape),
+ })
+
+ T = lambda t: t.T.contiguous() # [out,in] -> [in,out]
+
+ for k, v in sd.items():
+ # ── A. SigLIP2 ──
+ if k.startswith("backbone.model.vision_model.vision_model."):
+ if any(k.endswith(s) for s in (".to_q.weight", ".to_k.weight",
+ ".to_v.weight", ".to_out.0.weight",
+ ".mlp.fc1.weight", ".mlp.fc2.weight")):
+ emit(k, k, T(v), "transpose[out,in]->[in,out]")
+ else:
+ emit(k, k, v, "passthrough")
+
+ # ── B. Qwen3 ──
+ elif k.startswith("backbone.model.language_model.model.layers."):
+ if k.endswith(".self_attn.q_proj.weight"):
+ # fuse when the sibling k/v are present (handled at q key)
+ pre = k[: -len(".self_attn.q_proj.weight")]
+ q = sd[f"{pre}.self_attn.q_proj.weight"]
+ kk = sd[f"{pre}.self_attn.k_proj.weight"]
+ vv = sd[f"{pre}.self_attn.v_proj.weight"]
+ fused = torch.cat([q, kk, vv], dim=0).T.contiguous()
+ emit(k, f"{pre}.self_attn.qkv_fused", fused,
+ "cat([q,k,v],0).T -> [in,out], order Q,K,V")
+ elif k.endswith((".self_attn.k_proj.weight",
+ ".self_attn.v_proj.weight")):
+ continue # already fused into qkv_fused
+ elif k.endswith(".self_attn.q_proj.bias"):
+ pre = k[: -len(".self_attn.q_proj.bias")]
+ q = sd.get(f"{pre}.self_attn.q_proj.bias")
+ if q is not None:
+ kk = sd[f"{pre}.self_attn.k_proj.bias"]; vv = sd[f"{pre}.self_attn.v_proj.bias"]
+ emit(k, f"{pre}.self_attn.qkv_bias", torch.cat([q, kk, vv], 0),
+ "cat([qb,kb,vb],0)")
+ else:
+ emit(k, k, v, "passthrough")
+ elif k.endswith((".self_attn.k_proj.bias", ".self_attn.v_proj.bias")):
+ pre = k[: -len(".self_attn.k_proj.bias")]
+ if f"{pre}.self_attn.q_proj.bias" in sd:
+ continue # fused into qkv_bias
+ emit(k, k, v, "passthrough")
+ elif k.endswith(".mlp.gate_proj.weight"):
+ pre = k[: -len(".mlp.gate_proj.weight")]
+ g = sd[f"{pre}.mlp.gate_proj.weight"]
+ u = sd[f"{pre}.mlp.up_proj.weight"]
+ emit(k, f"{pre}.mlp.gate_up_fused",
+ torch.cat([g, u], dim=0).T.contiguous(),
+ "cat([gate,up],0).T -> [in,out], order gate|up")
+ elif k.endswith(".mlp.up_proj.weight"):
+ continue # fused into gate_up_fused
+ elif k.endswith(".mlp.down_proj.weight"):
+ emit(k, k, T(v), "transpose[out,in]->[in,out]")
+ else:
+ emit(k, k, v, "passthrough")
+
+ # ── C. DiT ──
+ elif k.startswith("action_head.model.transformer_blocks."):
+ parts = k.split(".")
+ l = int(parts[3])
+ is_self = (l % 2 == 1)
+ if k.endswith(".attn1.to_q.weight"):
+ pre = k[: -len(".attn1.to_q.weight")]
+ q = sd[f"{pre}.attn1.to_q.weight"]
+ if is_self:
+ kk = sd[f"{pre}.attn1.to_k.weight"]
+ vv = sd[f"{pre}.attn1.to_v.weight"]
+ emit(k, f"{pre}.attn1.qkv_fused",
+ torch.cat([q, kk, vv], dim=0).T.contiguous(),
+ "self-attn cat([q,k,v],0).T -> [in,out]")
+ else:
+ emit(k, k, T(q), "cross-attn q transpose")
+ elif k.endswith(".attn1.to_k.weight") or k.endswith(".attn1.to_v.weight"):
+ if is_self:
+ continue # fused
+ emit(k, k, T(v), "cross-attn k/v transpose [1536,2048]->[2048,1536]")
+ elif k.endswith((".attn1.to_q.bias",)):
+ pre = k[: -len(".attn1.to_q.bias")]
+ q = sd[f"{pre}.attn1.to_q.bias"]; kk = sd[f"{pre}.attn1.to_k.bias"]; vv = sd[f"{pre}.attn1.to_v.bias"]
+ if is_self:
+ emit(k, f"{pre}.qkv_bias", torch.cat([q, kk, vv], 0), "cat([qb,kb,vb],0)")
+ else:
+ emit(k, k, v, "passthrough")
+ elif k.endswith((".attn1.to_k.bias", ".attn1.to_v.bias")):
+ if is_self:
+ continue
+ emit(k, k, v, "passthrough")
+ elif k.endswith(".ff.net.0.proj.weight") or k.endswith(".ff.net.2.weight") \
+ or k.endswith(".norm1.linear.weight") or k.endswith(".attn1.to_out.0.weight"):
+ emit(k, k, T(v), "transpose[out,in]->[in,out] (GELU FFN, not GEGLU)")
+ else:
+ emit(k, k, v, "passthrough")
+
+ # ── DiT top-level linears ──
+ elif k.startswith("action_head.model.") and k.endswith(".weight") \
+ and any(s in k for s in ("proj_out_1", "proj_out_2", "timestep_embedder")):
+ emit(k, k, T(v), "transpose[out,in]->[in,out]")
+
+ # ── D. Embodiment CategorySpecificLinear (NO transpose) ──
+ elif k.startswith("action_head.") and any(
+ s in k for s in ("action_encoder", "state_encoder", "action_decoder")):
+ emit(k, k, v, "passthrough (W[eid] already [in,out]; no transpose)")
+
+ # ── everything else (embeddings, vlln, mlp1, norms, etc.) ──
+ else:
+ emit(k, k, v, "passthrough")
+
+ save_file(out, str(dst / "model_flashrt.safetensors"))
+ with open(dst / "flashrt_layout_manifest.json", "w") as f:
+ json.dump(manifest, f, indent=1)
+ print(f"wrote {dst/'model_flashrt.safetensors'} ({len(out)} tensors)")
+ print(f"wrote {dst/'flashrt_layout_manifest.json'} ({len(manifest)} rules)")
+ # summary of transforms
+ from collections import Counter
+ c = Counter(m["transform"] for m in manifest)
+ for t, n in c.most_common():
+ print(f" {n:5d} {t}")
+
+
+if __name__ == "__main__":
+ main()
From e89ecde20b846e181efb8dde1d3b0efde68463e9 Mon Sep 17 00:00:00 2001
From: DXICM <10598463@qq.com>
Date: Mon, 17 Aug 2026 08:26:47 +0000
Subject: [PATCH 2/6] perf(groot): FA4 + NVFP4 full-kernelization (130 -> 28.5
ms)
New CUDA kernels for the N1.6 Thor NVFP4 pipeline:
- fused_fp4/silu_mul_fp4_sfa_bf16: SiLU(gate)*up (bf16) direct to
NVFP4+SFA, bit-exact vs torch two-step chain
- fused_fp4/dit_norm_fp4_sfa: AdaLN / no-affine LN / weighted RMSNorm
direct to NVFP4+SFA (bf16 input variants)
- gemm/fp4/cutlass_fp4_gemm_bias_bf16_sm100: bias / bias+residual /
bias+tanh-GELU+fp4out epilogue variants
- quantize/quantize_fp4_sfa_bf16: vectorized bf16 dynamic quantize
- kernels/qk_norm_rope_rotate_half_bf16: fused per-head RMSNorm +
rotate-half RoPE (bf16, in-place, one launch per Q/K)
Performance rounds (no hyperparameter changes):
- DiT NVFP4 fused epilogue: 36.6 -> 15.7 ms (8 kernels/layer)
- Qwen3 fused norm/rope/GQA: 12.7 -> 5.0 ms (cos 0.999986)
- SigLIP FA4 + fp4 encoder: 10.3 -> 6.9 ms (cos 0.999988)
- SigLIP embeddings in-graph: 34 -> 28.5 ms (bit-exact)
- E2E total: 130 -> 28.5 ms (4-step, 2-camera, 252x252, T=50)
Bandwidth ceiling: Thor measured 252-255 GB/s (~93% of 273 spec);
DiT 15.2 ms is weight-bandwidth-bound floor for this config.
Tier switches (all default ON, independently fall back):
FLASHRT_N16_DIT_FP4, FLASHRT_N16_QWEN3_FP4,
FLASHRT_N16_SIGLIP_FP4, FLASHRT_N16_FA4
---
CMakeLists.txt | 4 +-
csrc/bindings.cpp | 11 ++
csrc/fp4_bindings.cpp | 32 ++++
csrc/fused_fp4/dit_norm_fp4_sfa.cu | 101 ++++++++++++
csrc/fused_fp4/dit_norm_fp4_sfa.cuh | 6 +
csrc/fused_fp4/silu_mul_fp4_sfa_bf16.cu | 152 ++++++++++++++++++
csrc/fused_fp4/silu_mul_fp4_sfa_bf16.cuh | 26 +++
csrc/kernels/qk_norm_rope_rotate_half_bf16.cu | 91 +++++++++++
.../kernels/qk_norm_rope_rotate_half_bf16.cuh | 15 ++
flash_rt/models/groot/pipeline_thor.py | 25 ---
10 files changed, 437 insertions(+), 26 deletions(-)
create mode 100644 csrc/fused_fp4/silu_mul_fp4_sfa_bf16.cu
create mode 100644 csrc/fused_fp4/silu_mul_fp4_sfa_bf16.cuh
create mode 100644 csrc/kernels/qk_norm_rope_rotate_half_bf16.cu
create mode 100644 csrc/kernels/qk_norm_rope_rotate_half_bf16.cuh
diff --git a/CMakeLists.txt b/CMakeLists.txt
index a1356a2c..99fec573 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1588,7 +1588,8 @@ if(ENABLE_SM100_CUTLASS)
csrc/kernels/attention_mha_masked.cu
csrc/kernels/vec_fp16_backbone.cu
csrc/kernels/attention_seqused_fused.cu
- csrc/kernels/rope_vec.cu)
+ csrc/kernels/rope_vec.cu
+ csrc/kernels/qk_norm_rope_rotate_half_bf16.cu)
target_compile_definitions(flash_rt_kernels PRIVATE FLASHRT_HAVE_THOR_VLA_KERNELS=1)
message(STATUS "Thor VLA helper kernels: ENABLED (sm_${GPU_ARCH})")
else()
@@ -2056,6 +2057,7 @@ if(ENABLE_SM100_CUTLASS)
csrc/gemm/fp4/cutlass_fp4_gemm_bias_bf16_sm100.cu
csrc/quantize/quantize_fp4_sfa_bf16.cu
csrc/fused_fp4/dit_norm_fp4_sfa.cu
+ csrc/fused_fp4/silu_mul_fp4_sfa_bf16.cu
csrc/gemm/fp4/cutlass_fp4_gemm_geglu_il_sm100.cu
csrc/quantize/quantize_e0m3_sfa.cu
csrc/gemm/fp4/cutlass_fp4_gemm_e0m3w_sm100.cu
diff --git a/csrc/bindings.cpp b/csrc/bindings.cpp
index 3a1e0bca..0b401947 100644
--- a/csrc/bindings.cpp
+++ b/csrc/bindings.cpp
@@ -312,6 +312,7 @@ extern "C" void flash_rt_awq_quant_fp8_static_fp16(
#include "quantize/bf16_quant_fp8_ncdhw_to_ndhwc.cuh"
#endif
#include "quantize/qkv_split_norm_rope_bf16.cuh"
+#include "kernels/qk_norm_rope_rotate_half_bf16.cuh"
#include "attention/fmha_dispatch.h"
#ifdef ENABLE_MOTUS_SAGE2_RAW
#include "attention/sage2/sage2_attn_raw.cuh"
@@ -1800,6 +1801,16 @@ PYBIND11_MODULE(flash_rt_kernels, m) {
py::arg("eps") = 1e-5f, py::arg("stream") = 0);
#endif // FLASHRT_ENABLE_CHAMELEON
+ m.def("qk_norm_rope_rotate_half_bf16",
+ [](uintptr_t x, uintptr_t w, uintptr_t cos_t, uintptr_t sin_t,
+ int S, int NH, int HD, float eps, uintptr_t stream) -> int {
+ return flash_rt::kernels::qk_norm_rope_rotate_half_bf16(
+ to_ptr(x), to_ptr(w), to_ptr(cos_t), to_ptr(sin_t),
+ S, NH, HD, eps, to_stream(stream));
+ }, py::arg("x"), py::arg("w"), py::arg("cos_table"), py::arg("sin_table"),
+ py::arg("S"), py::arg("NH"), py::arg("HD"), py::arg("eps") = 1e-6f,
+ py::arg("stream") = 0);
+
m.def("gate_mul_residual_fp16",
[](uintptr_t residual, uintptr_t x, uintptr_t gate,
int n, uintptr_t stream) {
diff --git a/csrc/fp4_bindings.cpp b/csrc/fp4_bindings.cpp
index 78dcca56..6a0194f7 100644
--- a/csrc/fp4_bindings.cpp
+++ b/csrc/fp4_bindings.cpp
@@ -41,6 +41,7 @@
#include "gemm/fp4/cutlass_fp4_gemm_bias_bf16_sm100.cuh"
#include "quantize/quantize_fp4_sfa_bf16.cuh"
#include "fused_fp4/dit_norm_fp4_sfa.cuh"
+#include "fused_fp4/silu_mul_fp4_sfa_bf16.cuh"
#include "fused_fp4/layer_norm_fp4_sfa.cuh"
#include "gemm/fp4/cutlass_fp4_gemm_siglip_ffn_sm100.cuh"
@@ -1228,6 +1229,37 @@ same contract as cutlass_fp4_gemm_geglu_il_hw.
py::arg("stream") = 0,
"Fused no-affine LayerNorm (bf16) -> NVFP4 packed + SFA.");
+ m.def("rms_norm_weight_fp4_sfa_bf16",
+ [](uintptr_t x, uintptr_t weight, uintptr_t packed, uintptr_t sfa,
+ int seq_len, int dim, float eps, uintptr_t stream) -> int {
+ return flash_rt::fused_fp4::rms_norm_weight_fp4_sfa_bf16(
+ reinterpret_cast(x),
+ reinterpret_cast(weight),
+ reinterpret_cast(packed),
+ reinterpret_cast(sfa),
+ seq_len, dim, eps,
+ reinterpret_cast(stream));
+ },
+ py::arg("x"), py::arg("weight"), py::arg("packed"), py::arg("sfa"),
+ py::arg("seq_len"), py::arg("dim"), py::arg("eps") = 1e-5f,
+ py::arg("stream") = 0,
+ "Fused weighted RMSNorm (bf16) -> NVFP4 packed + SFA.");
+
+ m.def("silu_mul_fp4_sfa_bf16",
+ [](uintptr_t gate, uintptr_t up, uintptr_t packed, uintptr_t sfa,
+ int N, int D, bool is_sfb, uintptr_t stream) -> int {
+ return flash_rt::fused_fp4::silu_mul_fp4_sfa_bf16(
+ reinterpret_cast(gate),
+ reinterpret_cast(up),
+ reinterpret_cast(packed),
+ reinterpret_cast(sfa),
+ N, D, is_sfb,
+ reinterpret_cast(stream));
+ },
+ py::arg("gate"), py::arg("up"), py::arg("packed"), py::arg("sfa"),
+ py::arg("N"), py::arg("D"), py::arg("is_sfb"), py::arg("stream") = 0,
+ "Fused SiLU(gate)*up (bf16) -> NVFP4 packed + SFA.");
+
m.attr("__version__") = "0.1.0-dev";
m.attr("layout_note") = "scales are linear [N, D/16]; Phase 4 adds tile-interleave conversion";
}
diff --git a/csrc/fused_fp4/dit_norm_fp4_sfa.cu b/csrc/fused_fp4/dit_norm_fp4_sfa.cu
index bde3c2ff..15e0420a 100644
--- a/csrc/fused_fp4/dit_norm_fp4_sfa.cu
+++ b/csrc/fused_fp4/dit_norm_fp4_sfa.cu
@@ -206,3 +206,104 @@ int layer_norm_no_affine_fp4_sfa_bf16(
} // namespace fused_fp4
} // namespace flash_rt
+
+// ----------------------------------------------------------------------------
+// Weighted RMSNorm (bf16) -> NVFP4 quantize + SFA (GR00T N1.6 Qwen3 tier).
+// y = x * rsqrt(mean(x^2) + eps) * weight, rounded through bf16 before
+// quantization (matches the torch RMSNorm(bf16) + quantize chain).
+// ----------------------------------------------------------------------------
+namespace flash_rt {
+namespace fused_fp4 {
+
+#if FV_HAVE_CUTLASS
+
+namespace {
+
+template
+__global__ void rms_norm_w_fp4_sfa_kernel(
+ const __nv_bfloat16* __restrict__ x,
+ const __nv_bfloat16* __restrict__ weight,
+ uint2* __restrict__ packed,
+ uint8_t* __restrict__ dst_sfa,
+ LayoutSF layout,
+ int D, float eps) {
+ const int r = blockIdx.x;
+ const __nv_bfloat162* row2 =
+ reinterpret_cast(x + static_cast(r) * D);
+ const int D2 = D >> 1;
+ __shared__ float sh[32];
+
+ float ssq = 0.f;
+ for (int i = threadIdx.x; i < D2; i += blockDim.x) {
+ const __nv_bfloat162 v = row2[i];
+ const float a = __bfloat162float(v.x);
+ const float b = __bfloat162float(v.y);
+ ssq += a * a + b * b;
+ }
+ const float rstd = rsqrtf(block_sum_dn(ssq, sh) / D + eps);
+
+ const int n_blocks = D >> 4;
+ const int4* x4 = reinterpret_cast(x + static_cast(r) * D);
+ const int4* w4 = reinterpret_cast(weight);
+ for (int blk = threadIdx.x; blk < n_blocks; blk += blockDim.x) {
+ const int4 xr[2] = {x4[2 * blk], x4[2 * blk + 1]};
+ const int4 wr[2] = {w4[2 * blk], w4[2 * blk + 1]};
+ const __nv_bfloat16* xh = reinterpret_cast(xr);
+ const __nv_bfloat16* wh = reinterpret_cast(wr);
+ float vals[16];
+ float amax = 0.f;
+ #pragma unroll
+ for (int i = 0; i < 16; ++i) {
+ const float normed =
+ __bfloat162float(xh[i]) * rstd * __bfloat162float(wh[i]);
+ vals[i] = __bfloat162float(__float2bfloat16(normed));
+ const float a = fabsf(vals[i]);
+ if (a > amax) amax = a;
+ }
+ float desired = amax / 6.f;
+ if (desired < 1e-12f) desired = 1e-12f;
+ __nv_fp8_e4m3 bs_q = __nv_fp8_e4m3(fmaxf(desired, 0.f));
+ const float bs_dq = static_cast(bs_q);
+ dst_sfa[layout(r, blk * 16, 0)] = *reinterpret_cast(&bs_q);
+ const float inv_bs = 1.f / bs_dq;
+ uint2 out;
+ uint8_t* ob = reinterpret_cast(&out);
+ #pragma unroll
+ for (int p = 0; p < 8; ++p) {
+ const uint8_t lo = fp32_to_e2m1_dn(vals[2 * p] * inv_bs);
+ const uint8_t hi = fp32_to_e2m1_dn(vals[2 * p + 1] * inv_bs);
+ ob[p] = static_cast(lo | (hi << 4));
+ }
+ packed[static_cast(r) * n_blocks + blk] = out;
+ }
+}
+
+} // namespace
+
+#endif // FV_HAVE_CUTLASS
+
+int rms_norm_weight_fp4_sfa_bf16(
+ const void* x, const void* weight, void* packed, void* sfa,
+ int seq_len, int dim, float eps, cudaStream_t stream) {
+#if FV_HAVE_CUTLASS
+ if (check_dn_args(x, packed, dim) != 0) return -1;
+ if (reinterpret_cast(weight) & 15) return -1;
+ auto shape = cute::make_shape(seq_len, 1, dim, 1);
+ auto layout = CfgDN::tile_atom_to_shape_SFA(shape);
+ rms_norm_w_fp4_sfa_kernel<<>>(
+ reinterpret_cast(x),
+ reinterpret_cast(weight),
+ reinterpret_cast(packed),
+ reinterpret_cast(sfa),
+ layout, dim, eps);
+ const cudaError_t e = cudaGetLastError();
+ return (e == cudaSuccess) ? 0 : -static_cast(e);
+#else
+ (void)x; (void)weight; (void)packed; (void)sfa;
+ (void)seq_len; (void)dim; (void)eps; (void)stream;
+ return -2;
+#endif
+}
+
+} // namespace fused_fp4
+} // namespace flash_rt
diff --git a/csrc/fused_fp4/dit_norm_fp4_sfa.cuh b/csrc/fused_fp4/dit_norm_fp4_sfa.cuh
index de19c3fd..11a369ea 100644
--- a/csrc/fused_fp4/dit_norm_fp4_sfa.cuh
+++ b/csrc/fused_fp4/dit_norm_fp4_sfa.cuh
@@ -29,5 +29,11 @@ int layer_norm_no_affine_fp4_sfa_bf16(
const void* x, void* packed, void* sfa,
int seq_len, int dim, float eps, cudaStream_t stream);
+// packed/sfa = quantize(RMSNorm(x[row]) * weight) — no mean removal.
+// x: bf16 [S, D]; weight: bf16 [D]. Qwen3 pre-attn / pre-FF norms.
+int rms_norm_weight_fp4_sfa_bf16(
+ const void* x, const void* weight, void* packed, void* sfa,
+ int seq_len, int dim, float eps, cudaStream_t stream);
+
} // namespace fused_fp4
} // namespace flash_rt
diff --git a/csrc/fused_fp4/silu_mul_fp4_sfa_bf16.cu b/csrc/fused_fp4/silu_mul_fp4_sfa_bf16.cu
new file mode 100644
index 00000000..1cc46515
--- /dev/null
+++ b/csrc/fused_fp4/silu_mul_fp4_sfa_bf16.cu
@@ -0,0 +1,152 @@
+// ============================================================================
+// Fused SiLU(gate) * up (bf16) + NVFP4 quantize + SFA write.
+// One thread per 16-element block: two int4 loads per operand, silu in
+// fp32 rounded to bf16, bf16 multiply, then the standard per-block scale
+// selection + e2m1 rounding + tile-interleaved SFA byte.
+// ============================================================================
+#include "silu_mul_fp4_sfa_bf16.cuh"
+
+#include
+#include
+
+#if defined(CUTLASS_ARCH_MMA_SM100_SUPPORTED) || defined(__CUDA_ARCH__)
+# include "cutlass/cutlass.h"
+# include "cutlass/detail/sm100_blockscaled_layout.hpp"
+# include "cute/tensor.hpp"
+# define FV_HAVE_CUTLASS 1
+#else
+# define FV_HAVE_CUTLASS 0
+#endif
+
+namespace flash_rt {
+namespace fused_fp4 {
+
+#if FV_HAVE_CUTLASS
+
+namespace {
+
+using CfgSM = cutlass::detail::Sm1xxBlockScaledConfig<16>;
+
+__device__ __forceinline__ uint8_t fp32_to_e2m1_sm(float x) {
+ uint8_t sign = (x < 0.f) ? 0x8u : 0x0u;
+ float ax = fabsf(x);
+ uint8_t mant;
+ if (ax <= 0.25f) mant = 0u;
+ else if (ax <= 0.75f) mant = 1u;
+ else if (ax <= 1.25f) mant = 2u;
+ else if (ax <= 1.75f) mant = 3u;
+ else if (ax <= 2.5f) mant = 4u;
+ else if (ax <= 3.5f) mant = 5u;
+ else if (ax <= 5.0f) mant = 6u;
+ else mant = 7u;
+ return sign | mant;
+}
+
+template
+__global__ void kernel_silu_mul_fp4_sfa_bf16(
+ const int4* __restrict__ gate,
+ const int4* __restrict__ up,
+ uint2* __restrict__ dst_packed,
+ uint8_t* __restrict__ dst_sfa,
+ LayoutSF layout,
+ int N, int D8) {
+ const int block_idx = blockIdx.x * blockDim.x + threadIdx.x;
+ const int row = blockIdx.y;
+ const int n_blocks = D8 >> 1;
+ if (row >= N || block_idx >= n_blocks) return;
+
+ const int4 g0 = gate[row * D8 + 2 * block_idx];
+ const int4 g1 = gate[row * D8 + 2 * block_idx + 1];
+ const int4 u0 = up[row * D8 + 2 * block_idx];
+ const int4 u1 = up[row * D8 + 2 * block_idx + 1];
+ const __nv_bfloat16* gh0 = reinterpret_cast(&g0);
+ const __nv_bfloat16* gh1 = reinterpret_cast(&g1);
+ const __nv_bfloat16* uh0 = reinterpret_cast(&u0);
+ const __nv_bfloat16* uh1 = reinterpret_cast(&u1);
+
+ float vals[16];
+ float amax = 0.f;
+ #pragma unroll
+ for (int i = 0; i < 8; ++i) {
+ const float g[2] = {__bfloat162float(gh0[i]), __bfloat162float(gh1[i])};
+ const __nv_bfloat16 u[2] = {uh0[i], uh1[i]};
+ #pragma unroll
+ for (int h = 0; h < 2; ++h) {
+ const float s = g[h] / (1.f + expf(-g[h])); // silu fp32
+ const __nv_bfloat16 sb = __float2bfloat16(s); // round like torch
+ const __nv_bfloat16 prod = __hmul(sb, u[h]); // bf16 multiply
+ vals[h * 8 + i] = __bfloat162float(prod);
+ const float a = fabsf(vals[h * 8 + i]);
+ if (a > amax) amax = a;
+ }
+ }
+
+ float desired = amax / 6.f;
+ if (desired < 1e-12f) desired = 1e-12f;
+ __nv_fp8_e4m3 bs_q = __nv_fp8_e4m3(fmaxf(desired, 0.f));
+ const float bs_dq = static_cast(bs_q);
+
+ dst_sfa[layout(row, block_idx * 16, 0)] =
+ *reinterpret_cast(&bs_q);
+
+ const float inv_bs = 1.f / bs_dq;
+ uint2 out;
+ uint8_t* ob = reinterpret_cast(&out);
+ #pragma unroll
+ for (int p = 0; p < 8; ++p) {
+ const uint8_t lo = fp32_to_e2m1_sm(vals[2 * p] * inv_bs);
+ const uint8_t hi = fp32_to_e2m1_sm(vals[2 * p + 1] * inv_bs);
+ ob[p] = static_cast(lo | (hi << 4));
+ }
+ dst_packed[row * n_blocks + block_idx] = out;
+}
+
+} // namespace
+
+#endif // FV_HAVE_CUTLASS
+
+int silu_mul_fp4_sfa_bf16(
+ const void* gate, const void* up, void* packed, void* sfa,
+ int N, int D, bool is_sfb, cudaStream_t stream) {
+#if FV_HAVE_CUTLASS
+ if (D % 16 != 0) return -1;
+ if ((reinterpret_cast(gate) & 15) ||
+ (reinterpret_cast(up) & 15) ||
+ (reinterpret_cast(packed) & 7)) return -1;
+ const int n_blocks = D / 16;
+ const int threads = 128;
+ dim3 grid((n_blocks + threads - 1) / threads, N);
+
+ auto shape = cute::make_shape(
+ is_sfb ? 1 : N,
+ is_sfb ? N : 1,
+ D, 1);
+
+ if (is_sfb) {
+ auto layout = CfgSM::tile_atom_to_shape_SFB(shape);
+ kernel_silu_mul_fp4_sfa_bf16<<>>(
+ reinterpret_cast(gate),
+ reinterpret_cast(up),
+ reinterpret_cast(packed),
+ reinterpret_cast(sfa),
+ layout, N, D >> 3);
+ } else {
+ auto layout = CfgSM::tile_atom_to_shape_SFA(shape);
+ kernel_silu_mul_fp4_sfa_bf16<<>>(
+ reinterpret_cast(gate),
+ reinterpret_cast(up),
+ reinterpret_cast(packed),
+ reinterpret_cast(sfa),
+ layout, N, D >> 3);
+ }
+ const cudaError_t e = cudaGetLastError();
+ return (e == cudaSuccess) ? 0 : -static_cast(e);
+#else
+ (void)gate; (void)up; (void)packed; (void)sfa;
+ (void)N; (void)D; (void)is_sfb; (void)stream;
+ return -2;
+#endif
+}
+
+} // namespace fused_fp4
+} // namespace flash_rt
diff --git a/csrc/fused_fp4/silu_mul_fp4_sfa_bf16.cuh b/csrc/fused_fp4/silu_mul_fp4_sfa_bf16.cuh
new file mode 100644
index 00000000..af130d56
--- /dev/null
+++ b/csrc/fused_fp4/silu_mul_fp4_sfa_bf16.cuh
@@ -0,0 +1,26 @@
+// ============================================================================
+// FlashRT — fused SiLU(gate) * up (bf16 in) + NVFP4 quantize + SFA write.
+//
+// GR00T N1.6 Qwen3 FFN hand-off for the NVFP4 tier: replaces the torch
+// silu + mul + separate quantize chain (three elementwise passes over
+// [Se, Dff]) with one kernel emitting packed e2m1 + tile-interleaved UE4M3
+// scales. Value path mirrors torch: silu in fp32, rounded to bf16, then
+// bf16 multiply.
+//
+// Additive: new symbols only.
+// ============================================================================
+#pragma once
+
+#include
+
+namespace flash_rt {
+namespace fused_fp4 {
+
+// packed/sfa = quantize(bf16(silu(gate)) * up)
+// gate/up: bf16 [N, D] row-major. is_sfb selects SFB (weights) layout.
+int silu_mul_fp4_sfa_bf16(
+ const void* gate, const void* up, void* packed, void* sfa,
+ int N, int D, bool is_sfb, cudaStream_t stream);
+
+} // namespace fused_fp4
+} // namespace flash_rt
diff --git a/csrc/kernels/qk_norm_rope_rotate_half_bf16.cu b/csrc/kernels/qk_norm_rope_rotate_half_bf16.cu
new file mode 100644
index 00000000..bb5d1fdc
--- /dev/null
+++ b/csrc/kernels/qk_norm_rope_rotate_half_bf16.cu
@@ -0,0 +1,91 @@
+// ============================================================================
+// FlashRT — fused per-head RMSNorm + rotate-half RoPE (bf16, in-place).
+//
+// GR00T N1.6 Qwen3 tier: replaces the torch chain per layer
+// q = q_norm(q_proj_out.view(1,S,NH,HD)) (RMSNorm over HD)
+// q, k = apply_rotary_pos_emb(q, k, cos, sin) (cat/mul/rotate_half)
+// with one warp-per-(row, head) kernel. Launched once for Q (NHQ heads)
+// and once for K (NHKV heads), so GQA is handled by the caller.
+//
+// x : [S, NH*HD] bf16 row-major, modified in place
+// w : [HD] bf16 RMSNorm weight
+// cos/sin: [S, HD] bf16 (HF rotary_emb output; duplicated halves)
+// Rounding follows the HF chain closely: norm output rounded to bf16
+// before the RoPE multiplies; RoPE accumulated in fp32, rounded once.
+// ============================================================================
+#include "qk_norm_rope_rotate_half_bf16.cuh"
+
+#include
+
+namespace flash_rt {
+namespace kernels {
+
+namespace {
+
+__global__ void qk_norm_rope_rotate_half_bf16_kernel(
+ __nv_bfloat16* __restrict__ x,
+ const __nv_bfloat16* __restrict__ w,
+ const __nv_bfloat16* __restrict__ cos_t,
+ const __nv_bfloat16* __restrict__ sin_t,
+ int S, int NH, int HD, float eps) {
+ const int half = HD >> 1; // pairs per head (HD==128 -> 64)
+ const int warp_id = (blockIdx.x * blockDim.x + threadIdx.x) >> 5;
+ const int lane = threadIdx.x & 31;
+ if (warp_id >= S * NH) return;
+ const int n = warp_id % NH;
+ const int s = warp_id / NH;
+ const int base = s * NH * HD + n * HD;
+
+ // Lane l handles pairs p = l and p = l + 32 (half == 64).
+ float vals[4];
+ int p[2] = {lane, lane + 32};
+ float ssq = 0.f;
+ #pragma unroll
+ for (int j = 0; j < 2; ++j) {
+ const int d = p[j];
+ const float a = __bfloat162float(x[base + d]);
+ const float b = __bfloat162float(x[base + d + half]);
+ vals[2 * j] = a; vals[2 * j + 1] = b;
+ ssq += a * a + b * b;
+ }
+ #pragma unroll
+ for (int o = 16; o > 0; o >>= 1)
+ ssq += __shfl_xor_sync(0xffffffffu, ssq, o);
+ const float inv = rsqrtf(ssq / HD + eps);
+
+ #pragma unroll
+ for (int j = 0; j < 2; ++j) {
+ const int d = p[j];
+ // norm, rounded to bf16 like the HF RMSNorm output
+ const float n_lo = __bfloat162float(__float2bfloat16(
+ vals[2 * j] * inv * __bfloat162float(w[d])));
+ const float n_hi = __bfloat162float(__float2bfloat16(
+ vals[2 * j + 1] * inv * __bfloat162float(w[d + half])));
+ const float c = __bfloat162float(cos_t[s * HD + d]);
+ const float si = __bfloat162float(sin_t[s * HD + d]);
+ x[base + d] = __float2bfloat16(n_lo * c - n_hi * si);
+ x[base + d + half] = __float2bfloat16(n_hi * c + n_lo * si);
+ }
+}
+
+} // namespace
+
+int qk_norm_rope_rotate_half_bf16(
+ void* x, const void* w, const void* cos_t, const void* sin_t,
+ int S, int NH, int HD, float eps, cudaStream_t stream) {
+ if (HD != 128) return -1;
+ const int total_warps = S * NH;
+ const int threads = 128; // 4 warps per block
+ const int blocks = (total_warps * 32 + threads - 1) / threads;
+ qk_norm_rope_rotate_half_bf16_kernel<<>>(
+ reinterpret_cast<__nv_bfloat16*>(x),
+ reinterpret_cast(w),
+ reinterpret_cast(cos_t),
+ reinterpret_cast(sin_t),
+ S, NH, HD, eps);
+ const cudaError_t e = cudaGetLastError();
+ return (e == cudaSuccess) ? 0 : -static_cast(e);
+}
+
+} // namespace kernels
+} // namespace flash_rt
diff --git a/csrc/kernels/qk_norm_rope_rotate_half_bf16.cuh b/csrc/kernels/qk_norm_rope_rotate_half_bf16.cuh
new file mode 100644
index 00000000..214d5a1a
--- /dev/null
+++ b/csrc/kernels/qk_norm_rope_rotate_half_bf16.cuh
@@ -0,0 +1,15 @@
+#pragma once
+#include
+
+namespace flash_rt {
+namespace kernels {
+
+// Fused per-head RMSNorm + rotate-half RoPE (bf16, in-place). See .cu.
+// Launched once for Q (NHQ heads) and once for K (NHKV heads) for GQA.
+// Returns 0 on success, -1 unsupported HD, -cudaError otherwise.
+int qk_norm_rope_rotate_half_bf16(
+ void* x, const void* w, const void* cos_t, const void* sin_t,
+ int S, int NH, int HD, float eps, cudaStream_t stream);
+
+} // namespace kernels
+} // namespace flash_rt
diff --git a/flash_rt/models/groot/pipeline_thor.py b/flash_rt/models/groot/pipeline_thor.py
index d6d45a81..27b7c24d 100644
--- a/flash_rt/models/groot/pipeline_thor.py
+++ b/flash_rt/models/groot/pipeline_thor.py
@@ -597,9 +597,6 @@ def forward(self, x_in, s=0):
fvk.gpu_strided_copy_fp16(self.b_qkv.data_ptr(), self.b_attn.data_ptr(), Se, NHKV*HD, self.QKV, NHQ*HD+NHKV*HD, s)
fvk.gpu_repeat_interleave_heads(self.b_k.data_ptr(), self.b_k_exp.data_ptr(), Se, NHKV, HD, NHQ//NHKV, s)
fvk.gpu_repeat_interleave_heads(self.b_attn.data_ptr(), self.b_v_exp.data_ptr(), Se, NHKV, HD, NHQ//NHKV, s)
- if getattr(self, '_dbg', None) is not None:
- self._dbg.append(('k', self.b_k.clone()))
- self._dbg.append(('v', self.b_attn.clone()))
fvk.gpu_fill_neginf_fp16(self.b_logits.data_ptr(), self.b_logits.nelement(), s)
if self.attn is not None:
self.attn.run("qwen3", i, q_seq=Se, stream=s)
@@ -607,8 +604,6 @@ def forward(self, x_in, s=0):
fvk.attention_mha_fp16(self.ctx, self.b_q.data_ptr(), self.b_k_exp.data_ptr(), self.b_v_exp.data_ptr(),
self.b_logits.data_ptr(), self.b_o.data_ptr(), Se, Se, NHQ, HD, 1.0/math.sqrt(HD), s)
self.gemm.fp16_nn(self.b_o.data_ptr(), w['o_w'].data_ptr(), self.b_xn.data_ptr(), Se, D, D, s)
- if getattr(self, '_dbg', None) is not None:
- self._dbg.append(('o', self.b_xn.clone()))
fvk.residual_add_fp16(self.b_x.data_ptr(), self.b_xn.data_ptr(), Se * D, s)
fvk.rms_norm_fp16(self.b_x.data_ptr(), w['ln2_w'].data_ptr(), self.b_xn.data_ptr(), Se, D, 1e-6, s)
if self.use_fp8:
@@ -633,9 +628,6 @@ def forward(self, x_in, s=0):
self.gemm.fp16_nn(self.b_gu.data_ptr(), w['down_fp16'].data_ptr(),
self.b_down.data_ptr(), Se, D, H, s)
fvk.residual_add_fp16(self.b_x.data_ptr(), self.b_down.data_ptr(), Se * D, s)
- if getattr(self, '_dbg', None) is not None:
- self._dbg.append(('ff', self.b_down.clone()))
- self._dbg.append(('x', self.b_x.clone()))
fvk.rms_norm_fp16(self.b_x.data_ptr(), self.final_norm_w.data_ptr(), self.b_xn.data_ptr(), Se, D, 1e-6, s)
return self.b_xn
@@ -899,36 +891,23 @@ def _run_step(self, step, s=0):
fvk.gpu_cast_fp32_to_fp16(self.b_actions.data_ptr(), self.b_actions_fp16.data_ptr(), T*self.action_dim, s)
self._fp16_gemm(self.b_actions_fp16.data_ptr(), self.ae_w1.data_ptr(), self.b_a_emb.data_ptr(), T, D, self.action_dim, s)
fvk.add_bias_fp16(self.b_a_emb.data_ptr(), self.ae_b1.data_ptr(), T, D, s)
- if getattr(self, '_dbg', None) is not None:
- self._dbg.append(('ae1', self.b_a_emb.clone()))
# gpu_copy is a raw memcpy: it cannot place (T,D) rows into the first
# D columns of a (T,2D) buffer (row stride differs). Use concat2_bf16.
fvk.concat2_bf16(self.b_a_emb.data_ptr(),
self.action_time_embeds[step].data_ptr(),
self.b_concat.data_ptr(), T, D, D, s)
- if getattr(self, '_dbg', None) is not None:
- self._dbg.append(('concat', self.b_concat.clone()))
self._fp16_gemm(self.b_concat.data_ptr(), self.ae_w2.data_ptr(), self.b_enc_h.data_ptr(), T, D, 2*D, s)
fvk.add_bias_fp16(self.b_enc_h.data_ptr(), self.ae_b2.data_ptr(), T, D, s)
fvk.silu_inplace_fp16(self.b_enc_h.data_ptr(), T*D, s)
- if getattr(self, '_dbg', None) is not None:
- self._dbg.append(('ae2', self.b_enc_h.clone()))
self._fp16_gemm(self.b_enc_h.data_ptr(), self.ae_w3.data_ptr(), self.b_a_emb.data_ptr(), T, D, D, s)
fvk.add_bias_fp16(self.b_a_emb.data_ptr(), self.ae_b3.data_ptr(), T, D, s)
- if getattr(self, '_dbg', None) is not None:
- self._dbg.append(('ae3', self.b_a_emb.clone()))
- self._dbg.append(('tau', self.action_time_embeds[step].clone()))
fvk.residual_add_fp16(self.b_a_emb.data_ptr(), self.pos_emb[:T].data_ptr(), T*D, s)
fvk.gpu_copy(self.b_hidden.data_ptr(), self.b_state_feat.data_ptr(), D*2, s)
fvk.gpu_copy(self.b_hidden.data_ptr()+D*2, self.b_a_emb.data_ptr(), T*D*2, s)
- if getattr(self, '_dbg', None) is not None:
- self._dbg.append(('h0', self.b_hidden.clone()))
for l in range(self.L):
is_self = (l % 2 == 1); w = self.dit[l]
fvk.ada_layer_norm_fp16(self.b_hidden.data_ptr(), self.ada_scales[step,l].data_ptr(),
self.ada_shifts[step,l].data_ptr(), self.b_h_norm.data_ptr(), Sa, D, 1e-5, s)
- if getattr(self, '_dbg', None) is not None:
- self._dbg.append(('ada', self.b_h_norm.clone()))
as_qkv_ptr = self._dit_act_scales_dev.data_ptr() + (l * 3 + 0) * 4
as_up_ptr = self._dit_act_scales_dev.data_ptr() + (l * 3 + 1) * 4
as_dn_ptr = self._dit_act_scales_dev.data_ptr() + (l * 3 + 2) * 4
@@ -971,8 +950,6 @@ def _run_step(self, step, s=0):
self.b_attn_logits_cross.data_ptr(), self.b_attn_out.data_ptr(), Sa, kv_seq, NH, HD, 1.0/math.sqrt(HD), s)
self._fp16_gemm(self.b_attn_out.data_ptr(), w['o_w'].data_ptr(), self.b_o.data_ptr(), Sa, D, D, s)
fvk.add_bias_fp16(self.b_o.data_ptr(), w['o_b'].data_ptr(), Sa, D, s)
- if getattr(self, '_dbg', None) is not None:
- self._dbg.append(('attn_o', self.b_o.clone()))
fvk.residual_add_fp16(self.b_hidden.data_ptr(), self.b_o.data_ptr(), Sa*D, s)
fvk.layer_norm_no_affine_fp16(self.b_hidden.data_ptr(), self.b_h_norm.data_ptr(), Sa, D, 1e-5, s)
if self.use_fp8:
@@ -991,8 +968,6 @@ def _run_step(self, step, s=0):
self._fp16_gemm(self.b_ff_h.data_ptr(), w['ff_dn_fp16'].data_ptr(), self.b_ff_out.data_ptr(), Sa, D, H, s)
fvk.add_bias_fp16(self.b_ff_out.data_ptr(), w['ff_dn_b'].data_ptr(), Sa, D, s)
fvk.residual_add_fp16(self.b_hidden.data_ptr(), self.b_ff_out.data_ptr(), Sa*D, s)
- if getattr(self, '_dbg', None) is not None:
- self._dbg.append(self.b_hidden.clone())
fvk.ada_layer_norm_fp16(self.b_hidden.data_ptr(), self.out_scales[step].data_ptr(),
self.out_shifts[step].data_ptr(), self.b_h_norm.data_ptr(), Sa, D, 1e-6, s)
self._fp16_gemm(self.b_h_norm.data_ptr(), self.proj_out_2_w.data_ptr(), self.b_model_out.data_ptr(), Sa, 1024, D, s)
From a5e33078202fd4874aa7c7fb4877cabcc5ca1c14 Mon Sep 17 00:00:00 2001
From: DXICM <10598463@qq.com>
Date: Mon, 17 Aug 2026 08:27:01 +0000
Subject: [PATCH 3/6] docs(groot): N1.6 authoritative adaptation doc +
companion docs
- docs/groot_n16_thor_sm110.md: single authoritative document covering
architecture facts, 12-bug root-cause table, falsified hypotheses,
full optimization record (130 -> 28.5 ms), roofline/bandwidth ceiling
analysis (252-255 GB/s, ~93% of spec), precision tier switches, and
verification methodology.
- docs/groot_transformers5_weight_corruption.md: transformers>=5 silent
weight corruption via _initialize_missing_keys re-randomizing SigLIP2
vision tower (282 tensors). One-line fix + integrity guard.
- docs/thor_gpu_idle_reset_workaround.md: Thor GPU idle reset defect and
three-layer CUDA Graph protection (keepalive, idle reinit, finiteness).
---
docs/groot_n16_thor_sm110.md | 336 ++++++++++++++++++
docs/groot_transformers5_weight_corruption.md | 107 ++++++
docs/thor_gpu_idle_reset_workaround.md | 161 +++++++++
3 files changed, 604 insertions(+)
create mode 100644 docs/groot_n16_thor_sm110.md
create mode 100644 docs/groot_transformers5_weight_corruption.md
create mode 100644 docs/thor_gpu_idle_reset_workaround.md
diff --git a/docs/groot_n16_thor_sm110.md b/docs/groot_n16_thor_sm110.md
new file mode 100644
index 00000000..0200b1dc
--- /dev/null
+++ b/docs/groot_n16_thor_sm110.md
@@ -0,0 +1,336 @@
+# GR00T N1.6 × FlashRT(Jetson Thor SM110)— 权威适配与优化文档
+
+> 自权重对齐开始的完整记录:HF 数值对齐 → 12 个真实 bug 修复 → parity 通路 →
+> 逐轮性能优化(130 ms → 28.5 ms)。配套两份独立主题文档见 §10。
+>
+> 本文是 N1.6 的唯一权威文档(one authoritative doc per model/platform)。
+> 提交 PR 时以本文 + §11 文件清单为准。
+
+---
+
+## 0. 状态总览
+
+**完成并通过仿真验证。** FlashRT 与 HF eager 基线数值和动作行为
+一致,仿真可完成任务。
+
+| 指标 | 数值 | 备注 |
+|---|---|---|
+| GPU 推理(e2e,中位数) | **~28.5 ms** | 4 步 DiT、2 相机、252×252、T=50 |
+| 服务侧 total | ~32 ms | 含预处理 2.8 ms + 解码 0.3 ms |
+| HTTP 往返 / RCP 侧 | ~38 ms | 20 请求 median 39 / p95 40 / 全 finite |
+| 精度 vs HF eager | cos **0.999933** / maxd **0.059** | 去归一化动作空间 |
+| 起点(HF eager) | ~110–150 ms | NVIDIA 官方 eager ~126 ms |
+| Thor 实测带宽 | **252–255 GB/s**(官方 ~273 GB/s,~93%) | 见 §8 |
+
+推理参数与 HF 完全一致(4 步 flow-matching、252×252、T=50、bf16 数学),
+**未为提速改动任何推理超参**;加速全部来自 kernel 化 / 量化 / 图融合。
+
+---
+
+## 1. 背景与模型架构
+
+GR00T N1.6-3B = Eagle-Block2A-2B-v2 backbone + AlternateVLDiT action head:
+
+| 组件 | 结构 | 说明 |
+|---|---|---|
+| 视觉塔 | SigLIP2 NaFlex,27 层,D=1152,HD=72,16 头 | 252×252 → 18×18=324 patch/视图,2 视图打包成 648 token 单序列 |
+| LLM | Qwen3,16 层,D=2048,GQA 16Q/8KV,HD=128 | ★ checkpoint 只截断到 16 层(非 28) |
+| mlp1 | pixel-unshuffle + LN + 2×Linear | 视觉 → LLM 维度投影 |
+| Action head | AlternateVLDiT,32 层,D=1536,NH=32,HD=48,FF=6144 | Sa = T+1 = 51;4 步 flow-matching;奇数层 self-attn、偶数层 cross-attn(交替 text/image KV) |
+| 动作 | action_dim=128(padding),so101 实际仅前 ~6 维有效 | T=50(padded max) |
+
+关键架构事实(对性能分析至关重要):
+
+- **SigLIP 是 cross-view full attention**:HF(sdpa) 对打包的 648 token 做全注意力,
+ **不是** per-view。NaFlex 代码里按 img_idx 的 `seq_len_list` 分段**只有
+ flash_attention_2 后端消费,sdpa 静默忽略**;Eagle `extra_kwargs` 里的
+ `attn_implementation="flash_attention_2"` 是死代码(未传入加载)。训练/部署实际都是 sdpa。
+- **DiT 是权重带宽主导**:32 层 × 4 步,每步重读 ~415 MB fp4 权重;M=Sa=51 极小,
+ GEMM 完全 weight-bandwidth-bound。
+- **HF eval 图像链输出 252×252**(256→0.95 crop→Eagle smart_resize 到 14 的倍数),
+ 不是 224。
+
+---
+
+## 2. 适配路线总览(自权重对齐开始)
+
+```
+阶段一 权重对齐 & HF 基线 → 发现 transformers>=5 静默权重损坏,修复;
+ 建立 HF eager 数值基线
+阶段二 12 个真实 bug 定位修复 → tokenization / 分辨率 / cross-view / patch 序 /
+ FMHA 发散 / Qwen3 发散 / adaLN chunk 序 /
+ 图捕获野指针 / 校准 / prompt 切换 / 空闲重置 …
+阶段三 parity 通路落地 → SigLIP/Qwen3/DiT 全 HF 原生 bf16 + CUDA graph,
+ 与 HF eager cos≈1.0(~111 ms)
+阶段四 服务侧优化 → 预处理 11→2.5 ms(apply_state 直连 / 线程池 /
+ GPU 归一化 / JPEG→cv2)
+阶段五 FA4 + NVFP4 + 全 kernel 化 → 130 ms → 28.5 ms(§6 逐轮)
+```
+
+---
+
+## 3. 阶段一:权重对齐与 HF 基线
+
+### 3.1 transformers>=5 静默权重损坏(第一个核心问题)
+
+详见配套文档 `docs/groot_transformers5_weight_corruption.md`。摘要:
+
+- `from_pretrained` 加载本身正确(1106/1106,0 missing),但收尾阶段
+ `_initialize_missing_keys` 因 `_auto_class` 为空 → `is_remote_code()==False`
+ → 无视 `_is_hf_initialized` → 用 `_init_weights` **重新随机化整个 SigLIP2
+ 视觉塔(282 张量)+ mlp1.1/3**。
+- 症状:策略"不看图",只跟随 state;黑图 vs 真实图 Δ 被噪声淹没。
+- 修复(一行):`type(self)._auto_class = "AutoModel"`(`Gr00tN1d6.__init__`,
+ `post_init()` 之前)。
+- 预防:权重完整性校验(`verify_weight_integrity()`) 抽样 ~12 张量 live vs safetensors
+ 比对,不一致拒绝启动(日志 `[weight-check]`)。
+
+### 3.2 HF eager 基线
+
+HF eager 基线(独立部署仓库):`Gr00tPolicy`(AutoModel/AutoProcessor)纯 eager,
+作为数值/行为的 ground truth。所有 FlashRT 精度都对它度量。
+
+---
+
+## 4. 阶段二:真实 Bug 清单(12 项,已修)
+
+上游 N1.6 前端把 **openpi 系(Pi0/Pi0.5)的视觉/核假设**直接套到 N1.6,而 N1.6 的
+HF 实际行为不同;叠加若干实现 bug。全部修复集中在
+`flash_rt/frontends/torch/groot_thor.py`。
+
+| # | 问题 | 根因 | 修复 | 验证 |
+|---|------|------|------|------|
+| 1 | backbone 特征与 HF 正交 | **tokenization**:前端裸 `encode(prompt)`;HF 用 Eagle chat template(system/user 头 + formalize + 每视图图像块,168→196 tokens) | 前端 `build_input_ids` 逐 token 复现 HF | `torch.equal` |
+| 2 | 视觉输入分布错 | **分辨率**:HF eval 链输出 252×252,前端固定 224 | `image_size=252`;aux 在 252 用 processor 默认链 + 复刻 smart_resize(PIL bicubic) | 像素 maxΔ 0.002 |
+| 3 | SigLIP 深层发散(L26 cos 0.86) | **注意力范围**:HF(sdpa) 做 cross-view full attention,前端做 per-view(见 §1) | SigLIP 改 cross-view(batch=1 单序列) | 27 层逐层 cos≥0.999 |
+| 4 | patch-embed cos 0.966 | **patch 展平序**:openpi 系 `(C,ph,pw)`;HF NaFlex `convert_images_to_patches` 是 `(ph,pw,C)` | permute 改 `(0,2,3,4,5,1)` | patch-embed cos 1.0 |
+| 5 | 648-seq 注意力静默错 | **strided FMHA kernel 在非 2 幂 seq + 真实数据下发散**(256/512 与随机数据单测均正常,极隐蔽) | parity 模式 SigLIP attention 走 torch sdpa | kernel 单测 vs sdpa |
+| 6 | Qwen3 输出与 HF 正交 | **CKernelQwen3 与 HF 在真实序列上发散**(同输入对拍 block 级逐步衰减) | parity 模式改跑 HF 原生 `Qwen3Model`(bf16、sdpa、图捕获) | vlln cos 0.9998 |
+| 7 | 重捕获后 replay 出 NaN | **野指针**:Qwen3 图捕获时 vlln LN 引用的 `vlln_w/vlln_b` 是 `_capture_all_graphs` 局部张量,函数返回即被 allocator 复用 | 改持久属性 `_g_vlln_w/_g_vlln_b`;加 `_g_vlln_buf` 有限性自检 + 重捕获兜底 | 重捕获回归 |
+| 8 | DiT chunk 震荡、动作失真 | **最终 adaLN chunk 顺序反**:HF `proj_out_1` 为 `(shift, scale)`,前端写成 `(scale, shift)` | 交换 | chunk 单调平滑,maxΔ 0.018 |
+| 9 | 首帧后动作饱和/NaN | **单帧 FP8 校准过窄**:捕获由首帧触发,scale 只测该帧 | 服务侧「当前帧 + 7 合成帧」`calibrate(percentile=99.9)` 多帧校准 | 饱和消失 |
+| 10 | prompt 切换报错/卡死 | prompt 烘焙进图,建图后 `set_prompt` 被拒 | 检测 prompt 变化 → `reset_graph_runtime()` + `set_prompt` + 重捕获 | 多 prompt A/B |
+| 11 | 空闲后首帧垃圾 | Thor 空闲重置使已捕获图失效(见配套文档) | replay 后 `_g_vlln_buf` 有限性自检,非有限即重捕获重试 | 压测 |
+| 12 | prompt 切换重捕获 device-side assert | `reset_graph_runtime` 未删 DiT 静态缓冲/索引(`_dit_txt_idx` 等),重捕获后按旧 Se 越界 | 加入 stale 列表,重捕获时重建 | 切换+空闲 live 全 finite |
+
+---
+
+## 5. 证伪/排除项(**非** bug,勿再追)
+
+- **FP8/FP16 精度**:FP32/BF16 参考给出相同分叉形态,精度不是上述任何一项的根因
+ (parity DiT 用 bf16,与 HF eager 完全一致)。
+- **GELU 近似(exact/tanh)**:两种近似结果相同。
+- **position_embed resize**:16×16→16×16 为恒等;252 的 18×18 resize 与 HF 同参一致。
+- **每块 sinusoidal position embed「缺失」**:**假 bug**。本模型
+ `diffusion_model_cfg.positional_embeddings=None`,每块本就不加 pe;按 diffusers
+ 默认补加反而引入大偏差(曾误修,已撤)。
+- **「kernel DiT 独立不收敛」**:**归因错误**。噪声不收敛由 backbone 失真(#3/#4)+
+ DiT 实现 bug(#8)共同造成;无独立 kernel DiT 问题。旧文档
+ `groot_n16_dit_kernel_nonconvergence.md` 结论作废,已删除。
+- **训练/部署注意力不一致(NVIDIA 侧)**:不存在。flash_attention_2 为死代码,
+ 训练与部署均为 sdpa cross-view。
+- **DiT cross-KV fp4**:实测动作精度恶化 5×(cos 0.9992 / maxd 0.105)且无时延收益,
+ cross 特征喂所有 cross 层 —— 已排除(commit b5afeecc)。
+
+---
+
+## 6. 阶段四/五:性能优化全记录(130 → 28.5 ms)
+
+### 6.0 优化前服务侧预处理优化(阶段四)
+
+- `processor()` 全调用(5.7 ms,含用不到的 VLM/tokenizer 路径)替换为
+ `state_action_processor.apply_state` + 零填充 —— 与 processor 的 state 输出
+ **bit 一致**(28 组真实/合成 state 验证)。
+- 双视图 albumentations + PIL bicubic 变换改线程池并行(4.2→2.1 ms)。
+- SigLIP 像素归一化移 GPU(uint8 H2D + fp32 除法,数学不变);mlp1 权重布局缓存
+ (去掉每帧 `.T.contiguous()`)。
+- JPEG 解码 PIL→cv2(`pb_utils._jpeg_to_rgb`,带 PIL 回退):2.13→0.94 ms/图,
+ 解码像素 bit 一致(同为 libjpeg)。
+- 结果:预处理 11→2.5 ms,parity 整链 ~111→~73 ms(4 步)。
+
+### 6.1 Roofline / 天花板分析(阶段五的指导)
+
+- Thor 实测带宽 **252–255 GB/s**(官方 ~273 GB/s,~93%);GPU GPC 1575 MHz /
+ NVD 1692 MHz 均满载,无节流。
+- DiT 32 层权重 fp4 ~415 MB/步 × 4 步 = 1.66 GB → 纯权重地板 ~6.6 ms;加注意力/
+ norm/quant/激活,DiT ~15 ms 为该配置接近地板的水平。
+- 结论:**DiT/Qwen3 fp4 GEMM 已带宽受限**(L2-cold 实测 170–239 GB/s),tile 重调
+ 无空间;减步数伤行为(见 §6.4);**28.5 ms 已接近 2 相机 252px T=50 的实际下限**。
+
+### 6.2 FA4 + NVFP4 移植(参考 Chameleon/HyVLA/N1.7 #163)
+
+- **FA4(FlashAttention-4 CuTe-DSL,`flash_rt/hardware/thor/fa4_backend.py`)用于
+ SigLIP**:SigLIP 本就是 cross-view full attention(648 token 单序列),FA4
+ causal=False 为 sdpa 精确替换;encoder graph 10.3→9.0 ms,动作 cos 1.000000。
+ 开关 `FLASHRT_N16_FA4`(默认开,缺失自动回退)。
+- **FA4 不用于 DiT/Qwen3**:小 seq(51/208)下 FA4 0.042–0.394 ms ≫ sdpa 0.013–0.015 ms。
+- **NVFP4(W4A4 CUTLASS)用于 DiT**:block GEMM 走
+ `quantize_fp4_dynamic_sfa_fp16` + `cutlass_fp4_sq_fp16`(per-16 block scale、动态激活
+ 量化、无校准)。关键事实:**M=51 冷 L2 流式下 fp4 GEMM 比 bf16 cuBLAS 快 2–5×**
+ (此前 L2-hot 微基准误判为无收益)。开关 `FLASHRT_N16_DIT_FP4`。
+
+### 6.3 融合 epilogue kernel 移植(上游 N1.7 #163,本地重编译)
+
+从上游移植(`csrc/`,加入 `fp4_kernels_obj` + `fp4_bindings`):
+
+- `gemm/fp4/cutlass_fp4_gemm_bias_bf16_sm100.cu` — bias / bias+residual /
+ bias+tanh-GELU+fp4out 三种 epilogue。
+- `fused_fp4/dit_norm_fp4_sfa.cu` — AdaLN norm 与 no-affine LN 直接输出 fp4+SFA。
+- `quantize/quantize_fp4_sfa_bf16.cu` — bf16 向量化量化。
+
+DiT 链每层仅 8 kernel(无层间逐元素流量):**DiT 36.6→15.7 ms**,
+精度不变(vs HF cos 0.999995)。
+
+### 6.4 DiT 减步数实验(**已回退,勿用于生产**)
+
+| 步数 | e2e | 精度/行为 |
+|---|---|---|
+| 4(生产) | ~28.5 ms | 基线 |
+| 2(`FLASHRT_N16_DIT_STEPS=2`) | ~46 ms* | ⚠️ 仿真反馈动作犹豫、夹爪收起慢(Euler 大步长对速度场积分欠冲,夹取等速度突变阶段显现;离线录制帧诊断不可见)→ **回退** |
+| 1(`FLASHRT_N16_DIT_STEPS=1`) | ~36 ms* | maxΔ 0.055,更激进 |
+
+\* 减步数同时减少权重读,但**行为退化**,生产保持 4 步。该开关保留作实验用。
+
+### 6.5 全 kernel 化三轮(37.5 → 28.5 ms)
+
+- **第一轮 — Qwen3 融合**(`FLASHRT_N16_QWEN3_FP4`,生产已启用):16 层全部
+ q/k/v/o/gate/up/down 走 W4A4 融合 GEMM(residual 原地更新)。
+ - 新写两个 bf16 输入 producer kernel(`csrc/fused_fp4/`,与 torch 两步链 **bit 一致**):
+ `rms_norm_weight_fp4_sfa_bf16`(加权 RMSNorm→fp4 直出)、
+ `silu_mul_fp4_sfa_bf16`(silu·mul→fp4 直出)。
+ - torch LN/quant/silu/mul 五趟 → 两个融合 kernel:10.9→9.2 ms。
+ - **fused per-head RMSNorm + rotate-half RoPE**(`qk_norm_rope_rotate_half_bf16`):
+ −2.2 ms。
+ - **fused GQA attention**:torch `enable_gqa` 会退回非融合 math 路径(每层 2 个
+ fp16 GEMM + 显式 softmax);改 `repeat_interleave` 展开 KV 走融合后端:−2.0 ms。
+ - Qwen3 12.7→5.0 ms;vs HF cos 0.999986。
+- **第二轮 — SigLIP encoder fp4**(`FLASHRT_N16_SIGLIP_FP4`,默认开):LN producer
+ 用 scale=w−1/shift=b 表达 affine;q/k/v 分离 GEMM 写连续缓冲直喂 FA4;o/ffn 残差进
+ epilogue;fc1 N 与 fc2 K 4304→4352 pad(pad 维全零端到端无贡献)。encoder 9.0→6.9 ms。
+ 全开时动作精度不降(vs HF cos 0.999988)。
+- **第三轮 — SigLIP embeddings 入图**:推理 34→**28.5 ms**。NaFlex window split 对
+ 固定 image_size 是静态 gather、antialias 位置编码 resize 只依赖静态形状;setup 时
+ 预计算 gather 索引 + resize 后位置编码,per-frame 只做
+ patchify+patch_embedding+pos_add+gather 并入 SigLIP 图。对 HF embeddings 前向
+ **bit 一致(max diff 0.0)**,省去每帧 antialias interpolate + unfold/im2col。
+ 图捕获失败时自动回退「encoder-only 图 + eager embeddings」。
+
+### 6.6 已排除的优化路径(dead-end,勿重复尝试)
+
+- **torch.compile(DiT/Qwen3,max-autotune)**:无收益(43→41 ms),且曾出现图捕获
+ 数值异常,勿在生产启用。
+- **torch fp8(`torch._scaled_mm`)**:Thor 仅支持 tensorwise scale + bf16 输出;
+ DiT 权重 per-channel 量化精度好(cos 0.99993),但图捕获后量化/反量化开销吃掉权重
+ 减半收益(29.6 > bf16 21.5 ms),不采用。
+- **legacy fp8 kernel 快路径(`--fp8`)**:实测 siglip 32.8 / qwen3 12.8 / dit 41.5
+ = 91 ms,全面慢于 torch bf16,且含 #5/#6 数值问题。**N1.6 生产请用默认 `--no-fp8`**。
+- **SigLIP FFN torch 级 fp4**:量化开销 47→53 ms,弃用;SigLIP down-proj K=4304 需 pad。
+- **NVFP4 扩展到 SigLIP encoder(早期 torch 级)**:162 Linear fp4 后 9.0→20.7 ms
+ (M=648 激活流量 12.6× 于 DiT,无融合 kernel 时量化开销主导)且 postln cos 降至 0.94
+ → torch 级 fp4 只对 M 小、权重流量主导的 DiT 有效。(后被 §6.5 的融合 kernel 路线取代。)
+- **DiT attention head_dim 48→64 padding**:注意力仅 1.28 ms,pad 浪费 ~0.3 ms,
+ 需自写 head_dim-48 kernel,性价比低,不做。
+- **DiT GEMM tile 重调**:已带宽受限(§6.1),无空间。
+
+---
+
+## 7. 最终架构与配置
+
+### 7.1 数据流(parity + 三层 NVFP4 + FA4,生产默认)
+
+```
+obs ──> preprocess(apply_state 直连 + 线程池图像变换) ~2.8 ms
+ ──> SigLIP 图(embeddings 入图 + 27 层 fp4 encoder + FA4) ~6.5 ms
+ ──> mlp1 / pixel-unshuffle(bf16 torch) ~0.4 ms
+ ──> Qwen3 图(16 层 fp4 + fused norm/rope/GQA) ~5.0 ms
+ ──> DiT 图(4 步 × 32 层 fp4 fused epilogue) ~15.2 ms
+ ──> denormalize_actions(复刻 HF decode_action) ~0.3 ms
+```
+
+全部 CUDA Graph 捕获;三层各自独立 tier 开关,可单独回退。
+
+### 7.2 精度 tier 与开关
+
+| 开关 | 默认 | 作用 |
+|---|---|---|
+| `FLASHRT_N16_DIT_FP4` | 生产开 | DiT NVFP4 融合链 |
+| `FLASHRT_N16_QWEN3_FP4` | 生产开 | Qwen3 NVFP4 融合层(含 fused norm/rope/GQA) |
+| `FLASHRT_N16_SIGLIP_FP4` | 开 | SigLIP encoder fp4 层 |
+| `FLASHRT_N16_FA4` | 开 | SigLIP FA4 注意力(缺失自动回退) |
+| `FLASHRT_N16_DIT_STEPS` | 4 | flow-matching 步数(**生产勿改**,减步伤行为) |
+| `--no-fp8` / `parity` | 生产开 | HF 原生 parity 通路(非 legacy kernel 快路径) |
+
+精度汇总(去归一化动作 vs HF eager):
+
+| 配置 | cos | maxd |
+|---|---|---|
+| 全开(生产) | 0.999933 | 0.059 |
+| Qwen3 fp4 单独 | 0.999986 | 0.023 |
+| DiT fp4 单独 | 0.999995 | 0.012 |
+
+### 7.3 生产稳定性(继承 N1.7 三层防护)
+
+见 `docs/thor_gpu_idle_reset_workaround.md`:GPU 心跳保活
+(`FLASHRT_GPU_KEEPALIVE=0.15`)+ 空闲重捕获守卫(`FLASHRT_GRAPH_IDLE_REINIT_S=2`)
++ 有限性自检。对抗 Thor GPU 空闲 ~200–300 ms 后驱动重置破坏已捕获 graph 的缺陷。
+
+---
+
+## 8. 带宽与天花板分析
+
+| 项 | 值 |
+|---|---|
+| Thor 实测带宽 | 252–255 GB/s(读/拷贝、fp16/fp32、512/1024MB 一致) |
+| 官方标称 | ~273 GB/s(LPDDR5X),实测 ~93% |
+| GPU 时钟 | GPC 1575 MHz / NVD 1692 MHz(满载,无节流) |
+| DiT fp4 权重地板 | 1.66 GB / 253 GB/s ≈ 6.6 ms(4 步) |
+
+> 注:早期手测曾报 63 GB/s,系把 float32 **元素数当成字节数**少算 4× 的统计错误;
+> 修正后与 roofline.py 一致(~254 GB/s)。roofline 脚本:
+> `.qoder/skills/flashrt-model-adaptation/scripts/roofline.py --measure-bw`。
+
+**天花板结论**:DiT 15.2 ms(4 步权重重读,带宽主导)是剩余大头,无法在不改推理
+超参(减步数)的前提下进一步压缩。**28.5 ms 为该任务配置的实际下限附近。**
+
+---
+
+## 9. 验证方法(可复现)
+
+- **数值一致性**:离线 `cos / maxd` vs HF eager(`/tmp/hf_act_ref.npy` 参考),
+ 以及 live A/B(FlashRT vs HF eager 逐关节 Δ)。
+- **bit 一致验证**:fast embeddings、fused norm/rope/silu-mul producer 均对
+ torch 两步链 / HF 前向 bit 一致(max diff 0.0)。
+- **稳定性**:20 请求 median/p95/全 finite;stress_gaps(间隙压测)+ selfcheck。
+- **带宽**:`roofline.py --measure-bw`。
+- 时延分解:`FLASHRT_GROOT_TIMING=1` 输出 `[timing] preprocess/infer/total`。
+
+---
+
+## 10. 相关文件与配套文档
+
+**本 PR 改动的核心文件**(`git diff main..HEAD`):
+
+- 前端:`flash_rt/frontends/torch/groot_thor.py`(核心,+1526 行)
+- attention backend:`flash_rt/hardware/thor/attn_backend_groot.py`
+- 模型管线:`flash_rt/models/groot/pipeline_thor.py`
+- 新 kernel:`csrc/fused_fp4/{dit_norm_fp4_sfa,silu_mul_fp4_sfa_bf16}.{cu,cuh}`、
+ `csrc/gemm/fp4/cutlass_fp4_gemm_bias_bf16_sm100.{cu,cuh}`、
+ `csrc/quantize/quantize_fp4_sfa_bf16.{cu,cuh}`、`csrc/fp4_bindings.cpp`
+- 权重转换:`tools/convert_groot_n16_hf_checkpoint.py`
+- 文档:本文 + `docs/groot_transformers5_weight_corruption.md`
+
+**配套文档(独立主题)**:
+
+- `docs/thor_gpu_idle_reset_workaround.md` — Thor GPU 空闲重置缺陷与 CUDA Graph 防护。
+- `docs/groot_transformers5_weight_corruption.md` — transformers>=5 权重静默损坏。
+
+---
+
+## 11. 遗留工作(猜想/未测)
+
+- [ ] **N1.7 同清单巡检**:N1.7 视觉同为 Eagle/SigLIP2 NaFlex,猜想存在同款
+ cross-view/patch 序问题;N1.7 仿真"可用"但未与其 HF 基线数值对拍。
+- [ ] **`FLASHRT_N16_DIT_STEPS=2/1` 仿真验证**:离线数值已测,闭环任务成功率未测
+ (N=2 已见夹爪犹豫,默认不用)。
+- [ ] **parity 模式小时级长稳压测**:prompt 切换 + 空闲自愈已测,长稳未测。
+- [ ] **SigLIP embeddings 之外的进一步 kernel 化**:已接近地板,收益有限。
diff --git a/docs/groot_transformers5_weight_corruption.md b/docs/groot_transformers5_weight_corruption.md
new file mode 100644
index 00000000..9945262a
--- /dev/null
+++ b/docs/groot_transformers5_weight_corruption.md
@@ -0,0 +1,107 @@
+# GR00T N1.6/N1.7: transformers>=5 静默权重损坏与修复
+
+## 状态
+
+已修复(2026-08-08)。影响所有通过 `AutoModel.from_pretrained` 加载 Gr00tN1d6 / Gr00tN1d7
+的 transformers **5.x** 环境;transformers 4.51.3(训练同版本)不受影响。
+
+## 症状
+
+- 服务侧(`serving/groot_n16/eager_server.py`,HF eager 服务端)表现为"不使用实时图像":
+ 换图 / 换 prompt 时动作输出只变化 ~0.05,策略几乎只跟随 state。
+- 加载日志完全正常:`Loading weights: 100% 1106/1106`,missing / unexpected keys 均为 0。
+- checkpoint 本身完好(其他设备、transformers 4.51.3 下推理正常)。
+
+## 根因
+
+**加载本身是正确的,损坏发生在 `from_pretrained` 的收尾阶段。**
+
+1. `from_pretrained` 把全部 1106 个张量正确写入模型(加载结束瞬间逐张量与磁盘
+ safetensors 比对,maxΔ = 0)。
+2. 随后 `_finalize_model_loading` → `_initialize_missing_keys` → `initialize_weights()`
+ 遍历所有模块调用各自的 `_init_weights`。跳过已加载参数的唯一依据是参数上的
+ `_is_hf_initialized` 标记,而 `PreTrainedModel._initialize_weights` 只在
+ `is_remote_code() == True` 时才检查该标记:
+
+ ```python
+ # transformers/modeling_utils.py (5.10)
+ if getattr(module, "_is_hf_initialized", False):
+ return
+ if is_remote_code and all(getattr(p, "_is_hf_initialized", False) for p in module.parameters(recurse=False)) ...:
+ return
+ self._init_weights(module) # <-- 重新随机初始化
+ ```
+
+3. Gr00tN1d6 / Gr00tN1d7 虽然经 `trust_remote_code` 动态加载,但类的 `_auto_class`
+ 属性为空 → `is_remote_code()` 返回 `False` → 参数级标记被无视 →
+ Siglip2 视觉塔的 `_init_weights` 对已加载的 `nn.Linear` / `nn.Embedding`
+ 执行重新随机初始化。
+
+4. 受损范围:整个 Siglip2 视觉塔 282 个张量(`vision_model.vision_model.*`,
+ 含 `position_embedding`、全部 encoder layers、head)+ 投影层 `mlp1.1/3`。
+ Qwen3 LLM 与 action head 因各自 `_init_weights` 的行为未被波及。
+
+5. 后果:视觉特征是随机噪声 → DiT 交叉注意拿不到有效图像信息 → 策略退化为
+ 纯 state 条件模型。
+
+实测特征(可用于快速诊断):受损张量的 live 值 std ≈ 0.0294(随机初始化分布),
+与磁盘值的余弦相似度 ≈ 0;例如
+`backbone.model.vision_model.vision_model.encoder.layers.0.self_attn.q_proj.weight`
+磁盘 std 0.02049,加载后变为 0.02936。
+
+## 修复
+
+一行核心改动,在 `Gr00tN1d6.__init__` / `Gr00tN1d7.__init__` 的 `post_init()` 之前:
+
+```python
+# transformers>=5: 必须标记为 auto/remote-code 类,否则加载收尾阶段的
+# `_initialize_missing_keys` 会无视 `_is_hf_initialized` 标记,
+# 用子模型的 `_init_weights` 重新随机化已加载的权重。
+type(self)._auto_class = "AutoModel"
+```
+
+涉及文件:
+
+- `/gr00t/model/gr00t_n1d6/gr00t_n1d6.py`
+- `/gr00t/model/gr00t_n1d7/gr00t_n1d7.py`
+
+## 预防措施
+
+权重完整性自检收敛为共享函数
+`serving/groot_n17/aux_builder.py:verify_weight_integrity()`:对 checkpoint
+safetensors 均匀抽样 ~12 个张量与 live 参数数值比对(容差取 bf16 舍入量级),
+不一致则抛错、拒绝启动。两条服务链路都在构建 `Gr00tPolicy` 后立即执行:
+
+- FlashRT 服务:`Gr00tN17AuxBuilder.__init__`(HF 仅作预处理器,权重同样经
+ transformers 5 加载,必须检查);
+- HF 基线服务:`serving/groot_n16/eager_server.py` /
+ `serving/groot_n17/eager_server.py`(后者已复用共享函数)。
+
+日志标记 `[weight-check]`。
+
+## 验证数据(2026-08-08)
+
+| 指标(HF eager 服务端) | 修复前 | 修复后 |
+| --- | --- | --- |
+| 权重与磁盘不一致张量 | 282(整个视觉塔) | 0 / 1106 |
+| 相同请求重复噪声底 | 0.65 ~ 0.71 | 0.011 ~ 0.016 |
+| 黑图 vs 真实图 Δ(mean) | 被噪声淹没 | 0.135 |
+| 随机图 vs 真实图 Δ | — | 0.137 |
+| prompt 切换 Δ | — | 0.036 |
+| state +0.3 Δ | — | 0.307 |
+
+离线 `Gr00tPolicy` 同条件复现一致(seeded 噪声底 0,黑图 Δ ≈ 0.13)。
+
+N1.7(5558)权重 1030/1030 核对一致;其图像敏感度偏弱(反色图 Δ ≈ 0.03 <
+噪声底 0.07,state Δ 0.22 / prompt Δ 0.07 正常),属该 epoch5 checkpoint 自身
+特性,非加载问题。
+
+## 教训
+
+- 跨 transformers 大版本迁移 `trust_remote_code` 模型时,
+ `from_pretrained` 成功(0 missing / 0 unexpected)**不等于**权重正确,
+ 必须做数值级核对(抽样 live vs safetensors)。
+- 复合模型(PreTrainedModel 嵌套 PreTrainedModel)在 5.x 下的收尾初始化
+ 依赖 `is_remote_code()`;自定义顶层模型类务必显式设置 `_auto_class`。
+- 诊断此类问题的快捷手段:比较 live 权重 std 与随机初始化分布;
+ 对 `_load_pretrained_model` / `_initialize_missing_keys` 打桩二分定位。
diff --git a/docs/thor_gpu_idle_reset_workaround.md b/docs/thor_gpu_idle_reset_workaround.md
new file mode 100644
index 00000000..5d2dec02
--- /dev/null
+++ b/docs/thor_gpu_idle_reset_workaround.md
@@ -0,0 +1,161 @@
+# Thor 部署稳定性:GPU 空闲重置缺陷与 CUDA Graph 防护体系
+
+## 状态
+
+已修复并验证(2026-08-08/09)。本文记录 GR00T N1.7 FlashRT 服务
+(`serving/groot_n17/`,端口 5558)排查"推理结果周期性震荡 + 偶发 4s 卡顿"
+全过程的结论与修复方案,作为后续对 **所有 Thor(SM110)serving 代码**
+做系统化修复的依据。
+
+配套文档:
+- `docs/groot_transformers5_weight_corruption.md`(transformers>=5 权重静默损坏,
+ 本轮修复的第一个核心问题,已单独记录)。
+- `docs/groot_n16_thor_sm110.md`(N1.6×FlashRT Thor 权威文档,含 GPU 空闲重置
+ 防护在本服务上的适用说明)。
+
+## 一、问题现象
+
+1. **震荡**:服务稳定推理 30–50 帧后,action 输出突然剧烈震动;错误帧的
+ 耗时反而比正常帧短(50ms vs 正常 70ms)。
+2. **卡顿**:偶发单帧推理卡住 3.5–4.5s(约每 20–30s 一次),输出本身正常。
+3. 两者相关:卡顿帧之后若 graph 已失效,后续帧持续输出垃圾。
+
+## 二、根因(两个独立但同源的缺陷)
+
+### 根因 A:Jetson Thor CUDA 驱动空闲重置缺陷(平台级,无法从应用层修复)
+
+- **GPU 连续空闲超过约 200–300ms** 后,驱动进入某种低功耗/重置状态;
+ 下一次任意 CUDA 调用(哪怕 2KB 的 H2D 拷贝或一个 kernel launch)
+ 会在**内核态自旋约 3.5 秒**(实测 3.3–3.9s,`stime` 主导、wchan=0,
+ 周期 22–30s 随空闲节奏出现)。
+- 该重置过程**同时破坏已捕获的 CUDA graph**:之后的 `graph.replay()`
+ 跑得很快但输出垃圾——这就是现象 1 的根源。
+
+证据链(可复现,探针脚本见"复现方法"):
+
+| 实验 | 结果 |
+|---|---|
+| 纯 2KB H2D 拷贝 @1Hz,无任何模型 | 每 22–27s 卡一次,每次 ~3.49s |
+| 纯 kernel launch + sync | 同样卡顿 → 与拷贝/分配无关 |
+| `gc.disable()` | 无效 → 排除 Python GC |
+| CPU 调频(schedutil,实测锁 2.6GHz)/内存压力 | 无异常 → 排除 |
+| GPU 持续繁忙(matmul 循环)60s | **0 卡顿** |
+| 心跳周期 100/200ms | 0 卡顿 |
+| 心跳周期 300/500/1000ms | 复现卡顿 |
+
+结论:**空闲阈值在 200–300ms 之间;保活算子大小无关,只要 GPU 不空闲
+超过约 200ms 即可**。机器上无 `jetson_clocks`/`nvpmodel`,无法用官方
+工具锁定电源状态;只能靠保活规避。
+
+### 根因 B:CUDA graph 无空闲防护(代码级)
+
+前端原有逻辑:graph 一旦捕获就永远 replay。在根因 A 触发后,
+replay 的是已被驱动重置破坏的 graph → 垃圾输出。需要在 replay 前
+判断"距上次使用是否超过安全窗口",超时则丢弃重捕获。
+
+## 三、修复方案(三层防护,已在 N1.7 落地)
+
+### 防护 1:GPU 心跳保活(主修复,规避根因 A)
+
+位置:`serving/groot_n17/run_http_policy.py` lifespan。
+
+- 每 **150ms**(环境变量 `FLASHRT_GPU_KEEPALIVE`,秒,默认 `0.15`)
+ 向 GPU 提交一个微小算子(`torch.ones(16)` 加法 + `synchronize`)。
+- 心跳通过与推理相同的 `ThreadPoolExecutor` 提交,推理忙时自动排队让路。
+- **关键约束:心跳绝不能更新前端的 `_last_graph_use` 时间戳**,
+ 否则会掩盖防护 2 的空闲判断(早期踩过这个坑:心跳更新时间戳后,
+ 40s 空闲后直接输出垃圾)。
+- 设为 `0` 可关闭(不建议:机器人按 action chunk 消费时请求间隙
+ 常达 0.5–2s,必然触发)。
+
+### 防护 2:空闲重捕获守卫(兜底,对抗根因 B)
+
+空闲判断收敛在基类 `GrootN17TorchFrontendThor` 的共享助手:
+`_graph_idle_limit_s`(读 `FLASHRT_GRAPH_IDLE_REINIT_S`)、
+`_graph_idle_stale()`、`invalidate_graphs()`(外部强制重捕获入口)。
+两处对称使用:
+
+1. **backbone graph** — `flash_rt/frontends/torch/groot_n17_thor_fp8.py`
+ `set_prompt()`:比较 `time.monotonic() - _last_graph_use` 与
+ `FLASHRT_GRAPH_IDLE_REINIT_S`(默认 **2s**);超时或形状变化则
+ `reset_prompt_runtime()` 重新捕获,否则走快路径(copy_ + replay)。
+2. **DiT graph** — `flash_rt/frontends/torch/groot_n17_thor.py`
+ `infer()`:`use_dit_graph` 路径同样检查空闲时间,超时则
+ `del _k_dit_graph` 后重新 `_capture_kernel_dit_graphs()`。
+
+阈值演进:10s → 5s(9s 间隙实测损坏)→ 2s(4–5s 间隙实测损坏,
+且 5.0s 整的间隙因严格 `>` 比较漏网)。有心跳后正常不会触发,
+只在心跳失效或极端长间隙时兜底;触发时代价 ~500ms/帧。
+
+### 防护 3:graph vs eager 自检(保险,保证输出正确性)
+
+`serving/groot_n17/run_http_policy.py`,`FLASHRT_SELFCHECK=N`(每 N 帧):
+
+- 用**固定噪声**分别跑 graph 路径和 eager 路径(`use_dit_graph=False`),
+ 比较输出 maxΔ:健康基线 0.015–0.05;graph 损坏时 3.3–3.8。
+- 阈值 0.5 超限 → `fe.invalidate_graphs()` 强制重捕获 → 重跑该帧,
+ 客户端拿到的仍是正确结果。
+- 注意开销:每帧自检会让平均耗时从 ~70ms 升到 ~170ms,
+ 生产用 `N=10` 或更大。
+
+## 四、验证结果(5558,2026-08-08)
+
+- `stress_gaps`(60 帧 @1Hz + 每 10 帧 4.5s 间隙):**0 卡顿、0 坏帧**;
+ 普通帧 ~65ms,gap 后重捕获帧 ~500ms,selfcheck maxΔ=0.03。
+- 修复前同样测试:必现 1 次 3.5–4.5s 卡顿,4–5s 间隙后 4 次 graph
+ 损坏(d=3.29–3.84)。
+
+## 五、系统化修复清单(待执行)
+
+上述三层防护目前只落在 GR00T N1.7 链路上,需推广:
+
+- [ ] **N1.6 serving**(`serving/groot_n16/`):同样使用 captured graph,
+ 需移植空闲守卫 + 心跳 + 自检(先做离线数值一致性验证,任务 #10)。
+- [ ] **其他 Thor 前端**:Chameleon-7B、HyVLA-0.5 等 SM110 前端的
+ serving 入口,凡使用 CUDA graph 的都要加:
+ 1. 心跳保活(默认 0.15s);
+ 2. graph replay 前的空闲检查 + 重捕获;
+ 3. 可选自检。
+- [ ] **守卫参数统一**:把 `FLASHRT_GRAPH_IDLE_REINIT_S`(默认 2s)、
+ `FLASHRT_GPU_KEEPALIVE`(默认 0.15s)、`FLASHRT_SELFCHECK`
+ 三个环境变量的语义写进 `docs/serving_production.md`,
+ 避免各 serving 脚本各自实现走样。
+- [x] **代码清理**(2026-08-09 已完成,dev→生产整理):
+ `gc.freeze()` 已删(与本问题无关);debug 开关
+ `FLASHRT_REPROMPT_DEBUG`/`FLASHRT_FORCE_RECAPTURE`/`FLASHRT_DUMP_OBS`/
+ `FLASHRT_BUILD_STAGE_TIMING` 及 `[act]` 数组打印已删;
+ `aux_builder.py` 的 `[builder-slow]`/`[preprocess-slow]` 慢帧日志保留
+ (仅 >100ms 触发,零常态开销)。
+- [ ] **提交**:改动文件(均未提交)——
+ `flash_rt/frontends/torch/groot_n17_thor_fp8.py`、
+ `flash_rt/frontends/torch/groot_n17_thor.py`、
+ `serving/groot_n17/run_http_policy.py`、
+ `serving/groot_n17/aux_builder.py`、
+ `serving/groot_n17/eager_server.py`。建议 conventional commits 分拆。
+- [ ] **AGENTS.md**:在架构规则处加一条 Thor 空闲重置缺陷提示,
+ 指向本文档。
+
+## 六、复现与诊断方法(备查)
+
+- 最小复现:`/tmp/h2d_probe.py`(2KB H2D @1Hz)、
+ `/tmp/cuda_op_probe.py`(kernel/alloc/pinned 分模式)、
+ `/tmp/keepalive_probe.py`(`BEAT_MS`/`BEAT_TINY` 扫周期)。
+ 典型输出:`wall=3494ms cpu=3485ms STALL`(内核态自旋特征)。
+- 服务侧诊断:`FLASHRT_GROOT_TIMING=1` 输出每帧
+ `[timing] preprocess+backbone=… set_prompt=… infer=… total=…`。
+- 判别口诀:
+ - 错误帧**更快**(50ms)+ 间隙后出现 → graph 损坏,查防护 2;
+ - 单帧**更慢** 3.5s+ 且输出正常 → 驱动空闲重置,查防护 1;
+ - 平均耗时整体抬升 → 检查自检频率(`FLASHRT_SELFCHECK`)。
+
+## 七、关键事实速查
+
+| 项 | 值 |
+|---|---|
+| 空闲触发阈值 | 200–300ms(200 安全,300 复现) |
+| 驱动停顿时长 | ~3.5s(内核态自旋) |
+| graph 损坏表现 | replay 快(50ms)但输出垃圾,maxΔ≈3.3–3.8 |
+| 健康自检 maxΔ | 0.015–0.05,阈值 0.5 |
+| 心跳默认周期 | 150ms(`FLASHRT_GPU_KEEPALIVE=0.15`) |
+| 空闲守卫阈值 | 2s(`FLASHRT_GRAPH_IDLE_REINIT_S=2`) |
+| 正常帧耗时 | ~65–70ms;重捕获帧 ~500ms;自检帧 +100ms |
From 4cae6c0eb27ce47c3bd80c775fb8acf320478037 Mon Sep 17 00:00:00 2001
From: DXICM <10598463@qq.com>
Date: Mon, 17 Aug 2026 09:24:39 +0000
Subject: [PATCH 4/6] fix(fa4): set CUTE_DSL_ARCH before cutlass-dsl import
(SM110 NVVM ICE)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
cutlass-dsl caches the device arch at import time. The previous code
imported cutlass to check its version, then set CUTE_DSL_ARCH=sm_101a
— too late; NVVM already cached sm_110a and ICEs on the hd256 2CTA
kernel (introduced in #164, commit 7fd75d20).
Fix: set CUTE_DSL_ARCH=sm_101a unconditionally before any cutlass
import. Also revert the hd256 2CTA dispatch to SM100-only (the
dedicated kernel was never validated on SM110) and restore the
_fa4_trimmed lazy loader for BlackwellFusedMultiHeadAttentionForward.
Verified: all-tier E2E on Thor — median 27.7 ms, p95 28.5 ms,
actions finite, cos 0.999933 vs HF eager.
---
.../flashrt_fa4/cute/interface_fwd_sm100.py | 12 +-
docs/groot_n16_thor_sm110.md | 2 +-
flash_rt/frontends/torch/groot_thor.py | 58 +++--
flash_rt/hardware/thor/fa4_backend.py | 21 +-
tools/convert_groot_n16_hf_checkpoint.py | 217 ------------------
5 files changed, 48 insertions(+), 262 deletions(-)
delete mode 100644 tools/convert_groot_n16_hf_checkpoint.py
diff --git a/csrc/attention/flash_attn_4_src/flashrt_fa4/cute/interface_fwd_sm100.py b/csrc/attention/flash_attn_4_src/flashrt_fa4/cute/interface_fwd_sm100.py
index 3eb0e664..ef1250fb 100644
--- a/csrc/attention/flash_attn_4_src/flashrt_fa4/cute/interface_fwd_sm100.py
+++ b/csrc/attention/flash_attn_4_src/flashrt_fa4/cute/interface_fwd_sm100.py
@@ -56,9 +56,7 @@ def _raise(*_a, **_k):
FlashAttentionForwardSm90 = _fa4_trimmed("FlashAttentionForwardSm90")
FlashAttentionForwardSm120 = _fa4_trimmed("FlashAttentionForwardSm120")
FlashAttentionMLAForwardSm100 = _fa4_trimmed("FlashAttentionMLAForwardSm100")
-from flashrt_fa4.cute.sm100_hd256_2cta_fmha_forward import (
- BlackwellFusedMultiHeadAttentionForward,
-)
+BlackwellFusedMultiHeadAttentionForward = _fa4_trimmed("BlackwellFusedMultiHeadAttentionForward")
from flashrt_fa4.cute.block_sparsity import (
BlockSparseTensorsTorch,
@@ -580,12 +578,8 @@ def _flash_attn_fwd(
and (tile_m % qhead_per_kvhead == 0 or not pack_gqa)
)
- # hd=256 2CTA forward uses the dedicated kernel on both SM100 and SM110.
- use_dedicated_hd256_kernel = (
- arch // 10 in [10, 11]
- and head_dim == 256
- and head_dim_v == 256
- )
+ # hd=256 2CTA forward uses dedicated kernel (SM100 only; SM110 NVVM ICE)
+ use_dedicated_hd256_kernel = arch // 10 == 10 and head_dim == 256 and head_dim_v == 256
use_2cta_instrs = use_2cta_instrs or use_dedicated_hd256_kernel
if softcap is not None:
diff --git a/docs/groot_n16_thor_sm110.md b/docs/groot_n16_thor_sm110.md
index 0200b1dc..d98019ac 100644
--- a/docs/groot_n16_thor_sm110.md
+++ b/docs/groot_n16_thor_sm110.md
@@ -316,7 +316,7 @@ obs ──> preprocess(apply_state 直连 + 线程池图像变换)
- 新 kernel:`csrc/fused_fp4/{dit_norm_fp4_sfa,silu_mul_fp4_sfa_bf16}.{cu,cuh}`、
`csrc/gemm/fp4/cutlass_fp4_gemm_bias_bf16_sm100.{cu,cuh}`、
`csrc/quantize/quantize_fp4_sfa_bf16.{cu,cuh}`、`csrc/fp4_bindings.cpp`
-- 权重转换:`tools/convert_groot_n16_hf_checkpoint.py`
+- 权重转换(开发调试工具,不含于本 PR):前端已内置完整的 HF→FlashRT layout 变换(transpose、QKV fuse),直接加载 HF 原始 safetensors 即可推理。离线审计脚本 `convert_groot_n16_hf_checkpoint.py` 仅用于 parity 对拍时定位 weight mapping 问题。
- 文档:本文 + `docs/groot_transformers5_weight_corruption.md`
**配套文档(独立主题)**:
diff --git a/flash_rt/frontends/torch/groot_thor.py b/flash_rt/frontends/torch/groot_thor.py
index 394c5b0c..21ed7962 100644
--- a/flash_rt/frontends/torch/groot_thor.py
+++ b/flash_rt/frontends/torch/groot_thor.py
@@ -859,17 +859,22 @@ def _setup_torch_dit(self):
@staticmethod
def _resolve_eagle_dir() -> pathlib.Path:
- """Locate the Eagle-Block2A-2B-v2 remote-code directory."""
+ """Locate the Eagle-Block2A-2B-v2 remote-code directory.
+
+ The HF modules cache stores only .py files (no config.json), so we
+ use modeling_siglip2.py as the marker.
+ """
override = os.environ.get("FLASHRT_N16_EAGLE_DIR")
if override:
p = pathlib.Path(override)
- if (p / "config.json").exists():
+ if (p / "modeling_siglip2.py").exists() or (p / "config.json").exists():
return p
raise RuntimeError(
- f"FLASHRT_N16_EAGLE_DIR={override} has no config.json")
+ f"FLASHRT_N16_EAGLE_DIR={override} has neither "
+ "modeling_siglip2.py nor config.json")
cache = pathlib.Path.home() / ".cache/huggingface/modules/transformers_modules"
for cand in sorted(cache.glob(
- "Eagle_hyphen_Block2A_hyphen_2B_hyphen_v2/*/config.json")):
+ "Eagle_hyphen_Block2A_hyphen_2B_hyphen_v2/*/modeling_siglip2.py")):
return cand.parent
raise RuntimeError(
"Eagle-Block2A-2B-v2 remote code not found. Load the GR00T N1.6 "
@@ -879,25 +884,26 @@ def _resolve_eagle_dir() -> pathlib.Path:
def _setup_torch_siglip(self):
"""Parity mode: HF-native Siglip2VisionModel (bf16) from the Eagle
remote code, weights from the checkpoint state dict."""
- import glob as _glob
import importlib.util as _ilu
import json as _json
eagle_dir = self._resolve_eagle_dir()
- cfg = _json.load(open(eagle_dir / "config.json"))
- mod_path = None
- for cand in _glob.glob(str(eagle_dir / "modeling_siglip2.py")) + \
- _glob.glob(str(pathlib.Path.home() /
- ".cache/huggingface/modules/transformers_modules/"
- "Eagle_hyphen_Block2A_hyphen_2B_hyphen_v2/*/"
- "modeling_siglip2.py")):
- mod_path = cand
- break
- if mod_path is None:
- raise RuntimeError("modeling_siglip2.py not found for parity SigLIP")
- spec = _ilu.spec_from_file_location("eagle_siglip2_parity", mod_path)
+ mod_path = eagle_dir / "modeling_siglip2.py"
+ if not mod_path.exists():
+ raise RuntimeError(
+ f"modeling_siglip2.py not found in {eagle_dir}")
+ spec = _ilu.spec_from_file_location("eagle_siglip2_parity", str(mod_path))
mod = _ilu.module_from_spec(spec)
spec.loader.exec_module(mod)
- vc = dict(cfg["vision_config"])
+ cfg_json = eagle_dir / "config.json"
+ if cfg_json.exists():
+ vc = dict(_json.load(open(cfg_json))["vision_config"])
+ else:
+ vc = dict(
+ hidden_size=self.D_sig, num_attention_heads=self.NH_sig,
+ num_hidden_layers=self.L_sig, intermediate_size=self.H_sig,
+ image_size=252, patch_size=14, hidden_act="gelu_pytorch_tanh",
+ layer_norm_eps=1e-6, attention_dropout=0.0, dropout=0.0,
+ )
vc.pop("_attn_implementation_autoset", None)
model = mod.Siglip2VisionModel(mod.Siglip2VisionConfig(**vc))
model = model.to(torch.bfloat16).cuda().eval()
@@ -965,8 +971,20 @@ def _setup_torch_qwen3(self):
import json as _json
from transformers import Qwen3Config, Qwen3Model
eagle_dir = self._resolve_eagle_dir()
- cfg = _json.load(open(eagle_dir / "config.json"))
- tc = dict(cfg.get("text_config", cfg))
+ cfg_json = eagle_dir / "config.json"
+ if cfg_json.exists():
+ tc = dict(_json.load(open(cfg_json)).get("text_config", {}))
+ else:
+ tc = {}
+ if not tc:
+ tc = dict(
+ hidden_size=self.D_llm, num_attention_heads=self.NHQ,
+ num_key_value_heads=self.NHKV, head_dim=self.HD_llm,
+ intermediate_size=self.H_llm, hidden_act="silu",
+ max_position_embeddings=4096, rms_norm_eps=1e-6,
+ rope_theta=1000000.0, use_sliding_window=False,
+ vocab_size=151680,
+ )
tc["num_hidden_layers"] = 16 # checkpoint is truncated
tc["_attn_implementation"] = "sdpa"
config = Qwen3Config(**tc)
diff --git a/flash_rt/hardware/thor/fa4_backend.py b/flash_rt/hardware/thor/fa4_backend.py
index a8df381d..9b292913 100644
--- a/flash_rt/hardware/thor/fa4_backend.py
+++ b/flash_rt/hardware/thor/fa4_backend.py
@@ -53,21 +53,12 @@ def _load() -> None:
_REASON = "disabled (FLASHRT_THOR_FA4=0)"
return
- # Thor compilation target. The accepted chip string depends on the
- # installed nvidia-cutlass-dsl: 4.5+ needs ``sm_101a`` (the legacy
- # sm_110 alias; its ``sm_110a`` path hits a chip-string bug), while
- # 4.4.x only knows ``sm_110a``. Either way the vendored SM100-compatible
- # forward is what runs — this tree ships no separate SM110 kernel — so
- # FLASH_ATTENTION_ARCH stays sm_100a below. Don't clobber a user value.
- try:
- import cutlass as _ctl # nvidia-cutlass-dsl
- _dsl_ver = tuple(
- int(x) for x in str(getattr(_ctl, "__version__", "0.0"))
- .split(".")[:2])
- except Exception: # noqa: BLE001 — any failure: assume the newer alias
- _dsl_ver = (4, 5)
- os.environ.setdefault(
- "CUTE_DSL_ARCH", "sm_101a" if _dsl_ver >= (4, 5) else "sm_110a")
+ # Thor compilation target: sm_101a (the legacy alias of sm_110).
+ # Must be set BEFORE importing cutlass-dsl, which caches the device
+ # arch at import time; setting it after is too late (NVVM ICE on
+ # sm_110a). Requires nvidia-cutlass-dsl >= 4.5 (which accepts the
+ # sm_101a alias); 4.4.x only knows sm_110a and is NOT supported.
+ os.environ.setdefault("CUTE_DSL_ARCH", "sm_101a")
os.environ.setdefault("FLASH_ATTENTION_ARCH", "sm_100a")
# Allow an override dir (e.g. for local FA4 development), else the vendor.
diff --git a/tools/convert_groot_n16_hf_checkpoint.py b/tools/convert_groot_n16_hf_checkpoint.py
deleted file mode 100644
index 0a9244c6..00000000
--- a/tools/convert_groot_n16_hf_checkpoint.py
+++ /dev/null
@@ -1,217 +0,0 @@
-#!/usr/bin/env python3
-"""Offline converter: HF GR00T N1.6 checkpoint -> FlashRT weight layout.
-
-Phase-1b of the N1.6 parity plan. Converts a fine-tuned (or base) HF GR00T
-N1.6 checkpoint into the exact tensor layout the FlashRT N1.6 kernels
-consume, emitting BOTH the converted ``.safetensors`` and a JSON manifest
-recording, for every tensor: HF key, HF shape, transform, FlashRT key,
-FlashRT shape. This makes every weight mapping explicit and auditable, so a
-backbone/weight-layout mismatch can be localized to a specific rule instead
-of guessed from the final action.
-
-Layout rules (one explicit rule per weight family):
-
- A. SigLIP2 (``backbone.model.vision_model.vision_model.*``)
- - attention q/k/v/o and FFN fc1/fc2: HF ``[out,in]`` -> ``[in,out]``
- (FlashRT GEMMs take ``[in,out]``); QKV kept separate, order Q,K,V.
- - layernorm / position_embedding / patch_embedding bias: passthrough.
- - (the double ``vision_model`` prefix is part of the HF key and kept).
-
- B. Qwen3 (``backbone.model.language_model.model.layers.*``)
- - q ``[2048,2048]``, k ``[1024,2048]``, v ``[1024,2048]`` are fused as
- ``cat([q,k,v], dim=0).T.contiguous()`` -> ``[2048, 4096]``
- (Q first, then K, then V; NO interleaving).
- - FFN ``cat([gate_proj, up_proj], dim=0).T.contiguous()`` with order
- ``gate | up`` (must not be swapped); down_proj transposed.
- - layernorm / q_norm / k_norm: passthrough.
-
- C. DiT (``action_head.model.transformer_blocks.{l}.*``)
- - even block = cross-attention, odd block = self-attention.
- - self-attn (odd): QKV fused ``cat([q,k,v],0).T`` -> ``[1536, 4608]``
- (K/V input dim 1536).
- - cross-attn (even): q transposed; k/v ``[1536,2048]`` transposed,
- kept separate (NOT fused).
- - FFN is GELU (not GEGLU): net.0.proj and net.2 transposed.
- - norm1.linear (AdaLN) transposed; norm1.norm / norm3 / norm_out have
- NO affine parameters (absent from the checkpoint); output
- conditioning chunk order is (shift, scale).
- - proj_out_1 / proj_out_2 / timestep_encoder linears transposed.
-
- D. Embodiment (``action_head.{action_encoder,state_encoder,action_decoder}``)
- - CategorySpecificLinear ``W`` is already ``[num_categories,in,out]``;
- after selecting ``W[eid]`` it is ``[in,out]`` already -> NO extra
- transpose. Biases passthrough.
-
-Usage:
- python tools/convert_groot_n16_hf_checkpoint.py \
- --src /mnt/lerobot_so101_sim_v1_gr00t_n1d6_sim_fruits_cubes_10w \
- --dst /mnt/.../n1d6_flashrt_layout \
- [--dtype fp16]
-"""
-from __future__ import annotations
-
-import argparse
-import json
-from pathlib import Path
-
-import torch
-from safetensors import safe_open
-from safetensors.torch import save_file
-
-
-def load_src(src: Path) -> dict:
- sd = {}
- for f in sorted(src.glob("*.safetensors")):
- with safe_open(str(f), framework="pt") as sf:
- for k in sf.keys():
- sd[k] = sf.get_tensor(k)
- return sd
-
-
-def main() -> None:
- ap = argparse.ArgumentParser()
- ap.add_argument("--src", required=True)
- ap.add_argument("--dst", required=True)
- ap.add_argument("--dtype", default="keep", choices=["keep", "fp16", "bf16"])
- args = ap.parse_args()
-
- src = Path(args.src)
- dst = Path(args.dst)
- dst.mkdir(parents=True, exist_ok=True)
- sd = load_src(src)
-
- cast = {"keep": None, "fp16": torch.float16, "bf16": torch.bfloat16}[args.dtype]
- def maybe_cast(t: torch.Tensor) -> torch.Tensor:
- return t.to(cast) if (cast is not None and t.is_floating_point()) else t
-
- out: dict[str, torch.Tensor] = {}
- manifest: list[dict] = []
-
- def emit(hf_key: str, fr_key: str, tensor: torch.Tensor, transform: str) -> None:
- hf_shape = list(sd[hf_key].shape) if hf_key in sd else None
- tensor = maybe_cast(tensor)
- out[fr_key] = tensor
- manifest.append({
- "hf_key": hf_key, "hf_shape": hf_shape, "transform": transform,
- "fr_key": fr_key, "fr_shape": list(tensor.shape),
- })
-
- T = lambda t: t.T.contiguous() # [out,in] -> [in,out]
-
- for k, v in sd.items():
- # ── A. SigLIP2 ──
- if k.startswith("backbone.model.vision_model.vision_model."):
- if any(k.endswith(s) for s in (".to_q.weight", ".to_k.weight",
- ".to_v.weight", ".to_out.0.weight",
- ".mlp.fc1.weight", ".mlp.fc2.weight")):
- emit(k, k, T(v), "transpose[out,in]->[in,out]")
- else:
- emit(k, k, v, "passthrough")
-
- # ── B. Qwen3 ──
- elif k.startswith("backbone.model.language_model.model.layers."):
- if k.endswith(".self_attn.q_proj.weight"):
- # fuse when the sibling k/v are present (handled at q key)
- pre = k[: -len(".self_attn.q_proj.weight")]
- q = sd[f"{pre}.self_attn.q_proj.weight"]
- kk = sd[f"{pre}.self_attn.k_proj.weight"]
- vv = sd[f"{pre}.self_attn.v_proj.weight"]
- fused = torch.cat([q, kk, vv], dim=0).T.contiguous()
- emit(k, f"{pre}.self_attn.qkv_fused", fused,
- "cat([q,k,v],0).T -> [in,out], order Q,K,V")
- elif k.endswith((".self_attn.k_proj.weight",
- ".self_attn.v_proj.weight")):
- continue # already fused into qkv_fused
- elif k.endswith(".self_attn.q_proj.bias"):
- pre = k[: -len(".self_attn.q_proj.bias")]
- q = sd.get(f"{pre}.self_attn.q_proj.bias")
- if q is not None:
- kk = sd[f"{pre}.self_attn.k_proj.bias"]; vv = sd[f"{pre}.self_attn.v_proj.bias"]
- emit(k, f"{pre}.self_attn.qkv_bias", torch.cat([q, kk, vv], 0),
- "cat([qb,kb,vb],0)")
- else:
- emit(k, k, v, "passthrough")
- elif k.endswith((".self_attn.k_proj.bias", ".self_attn.v_proj.bias")):
- pre = k[: -len(".self_attn.k_proj.bias")]
- if f"{pre}.self_attn.q_proj.bias" in sd:
- continue # fused into qkv_bias
- emit(k, k, v, "passthrough")
- elif k.endswith(".mlp.gate_proj.weight"):
- pre = k[: -len(".mlp.gate_proj.weight")]
- g = sd[f"{pre}.mlp.gate_proj.weight"]
- u = sd[f"{pre}.mlp.up_proj.weight"]
- emit(k, f"{pre}.mlp.gate_up_fused",
- torch.cat([g, u], dim=0).T.contiguous(),
- "cat([gate,up],0).T -> [in,out], order gate|up")
- elif k.endswith(".mlp.up_proj.weight"):
- continue # fused into gate_up_fused
- elif k.endswith(".mlp.down_proj.weight"):
- emit(k, k, T(v), "transpose[out,in]->[in,out]")
- else:
- emit(k, k, v, "passthrough")
-
- # ── C. DiT ──
- elif k.startswith("action_head.model.transformer_blocks."):
- parts = k.split(".")
- l = int(parts[3])
- is_self = (l % 2 == 1)
- if k.endswith(".attn1.to_q.weight"):
- pre = k[: -len(".attn1.to_q.weight")]
- q = sd[f"{pre}.attn1.to_q.weight"]
- if is_self:
- kk = sd[f"{pre}.attn1.to_k.weight"]
- vv = sd[f"{pre}.attn1.to_v.weight"]
- emit(k, f"{pre}.attn1.qkv_fused",
- torch.cat([q, kk, vv], dim=0).T.contiguous(),
- "self-attn cat([q,k,v],0).T -> [in,out]")
- else:
- emit(k, k, T(q), "cross-attn q transpose")
- elif k.endswith(".attn1.to_k.weight") or k.endswith(".attn1.to_v.weight"):
- if is_self:
- continue # fused
- emit(k, k, T(v), "cross-attn k/v transpose [1536,2048]->[2048,1536]")
- elif k.endswith((".attn1.to_q.bias",)):
- pre = k[: -len(".attn1.to_q.bias")]
- q = sd[f"{pre}.attn1.to_q.bias"]; kk = sd[f"{pre}.attn1.to_k.bias"]; vv = sd[f"{pre}.attn1.to_v.bias"]
- if is_self:
- emit(k, f"{pre}.qkv_bias", torch.cat([q, kk, vv], 0), "cat([qb,kb,vb],0)")
- else:
- emit(k, k, v, "passthrough")
- elif k.endswith((".attn1.to_k.bias", ".attn1.to_v.bias")):
- if is_self:
- continue
- emit(k, k, v, "passthrough")
- elif k.endswith(".ff.net.0.proj.weight") or k.endswith(".ff.net.2.weight") \
- or k.endswith(".norm1.linear.weight") or k.endswith(".attn1.to_out.0.weight"):
- emit(k, k, T(v), "transpose[out,in]->[in,out] (GELU FFN, not GEGLU)")
- else:
- emit(k, k, v, "passthrough")
-
- # ── DiT top-level linears ──
- elif k.startswith("action_head.model.") and k.endswith(".weight") \
- and any(s in k for s in ("proj_out_1", "proj_out_2", "timestep_embedder")):
- emit(k, k, T(v), "transpose[out,in]->[in,out]")
-
- # ── D. Embodiment CategorySpecificLinear (NO transpose) ──
- elif k.startswith("action_head.") and any(
- s in k for s in ("action_encoder", "state_encoder", "action_decoder")):
- emit(k, k, v, "passthrough (W[eid] already [in,out]; no transpose)")
-
- # ── everything else (embeddings, vlln, mlp1, norms, etc.) ──
- else:
- emit(k, k, v, "passthrough")
-
- save_file(out, str(dst / "model_flashrt.safetensors"))
- with open(dst / "flashrt_layout_manifest.json", "w") as f:
- json.dump(manifest, f, indent=1)
- print(f"wrote {dst/'model_flashrt.safetensors'} ({len(out)} tensors)")
- print(f"wrote {dst/'flashrt_layout_manifest.json'} ({len(manifest)} rules)")
- # summary of transforms
- from collections import Counter
- c = Counter(m["transform"] for m in manifest)
- for t, n in c.most_common():
- print(f" {n:5d} {t}")
-
-
-if __name__ == "__main__":
- main()
From eb01fa8d470df024d4245d64a1ffa774f1f4f94f Mon Sep 17 00:00:00 2001
From: LiangSu8899 <7thuniversels@gmail.com>
Date: Sun, 23 Aug 2026 13:03:51 -0400
Subject: [PATCH 5/6] fix(groot): close PR 177 audit gaps
---
csrc/bindings.cpp | 4 ++
docs/groot_n16_thor_sm110.md | 20 +++---
flash_rt/frontends/torch/groot_thor.py | 65 ++++++++++---------
flash_rt/hardware/thor/fa4_backend.py | 8 +--
tests/test_groot_n16_pr177_maintenance.py | 79 +++++++++++++++++++++++
tests/test_prompt_runtime_lifecycle.py | 13 ++--
6 files changed, 138 insertions(+), 51 deletions(-)
create mode 100644 tests/test_groot_n16_pr177_maintenance.py
diff --git a/csrc/bindings.cpp b/csrc/bindings.cpp
index 0b401947..2f7f7e35 100644
--- a/csrc/bindings.cpp
+++ b/csrc/bindings.cpp
@@ -312,7 +312,9 @@ extern "C" void flash_rt_awq_quant_fp8_static_fp16(
#include "quantize/bf16_quant_fp8_ncdhw_to_ndhwc.cuh"
#endif
#include "quantize/qkv_split_norm_rope_bf16.cuh"
+#ifdef FLASHRT_HAVE_THOR_VLA_KERNELS
#include "kernels/qk_norm_rope_rotate_half_bf16.cuh"
+#endif
#include "attention/fmha_dispatch.h"
#ifdef ENABLE_MOTUS_SAGE2_RAW
#include "attention/sage2/sage2_attn_raw.cuh"
@@ -1801,6 +1803,7 @@ PYBIND11_MODULE(flash_rt_kernels, m) {
py::arg("eps") = 1e-5f, py::arg("stream") = 0);
#endif // FLASHRT_ENABLE_CHAMELEON
+#ifdef FLASHRT_HAVE_THOR_VLA_KERNELS
m.def("qk_norm_rope_rotate_half_bf16",
[](uintptr_t x, uintptr_t w, uintptr_t cos_t, uintptr_t sin_t,
int S, int NH, int HD, float eps, uintptr_t stream) -> int {
@@ -1810,6 +1813,7 @@ PYBIND11_MODULE(flash_rt_kernels, m) {
}, py::arg("x"), py::arg("w"), py::arg("cos_table"), py::arg("sin_table"),
py::arg("S"), py::arg("NH"), py::arg("HD"), py::arg("eps") = 1e-6f,
py::arg("stream") = 0);
+#endif // FLASHRT_HAVE_THOR_VLA_KERNELS
m.def("gate_mul_residual_fp16",
[](uintptr_t residual, uintptr_t x, uintptr_t gate,
diff --git a/docs/groot_n16_thor_sm110.md b/docs/groot_n16_thor_sm110.md
index d98019ac..f594442c 100644
--- a/docs/groot_n16_thor_sm110.md
+++ b/docs/groot_n16_thor_sm110.md
@@ -162,7 +162,7 @@ HF 实际行为不同;叠加若干实现 bug。全部修复集中在
- **FA4(FlashAttention-4 CuTe-DSL,`flash_rt/hardware/thor/fa4_backend.py`)用于
SigLIP**:SigLIP 本就是 cross-view full attention(648 token 单序列),FA4
causal=False 为 sdpa 精确替换;encoder graph 10.3→9.0 ms,动作 cos 1.000000。
- 开关 `FLASHRT_N16_FA4`(默认开,缺失自动回退)。
+ 开关 `FLASHRT_N16_FA4=1`(显式开启,缺失自动回退)。
- **FA4 不用于 DiT/Qwen3**:小 seq(51/208)下 FA4 0.042–0.394 ms ≫ sdpa 0.013–0.015 ms。
- **NVFP4(W4A4 CUTLASS)用于 DiT**:block GEMM 走
`quantize_fp4_dynamic_sfa_fp16` + `cutlass_fp4_sq_fp16`(per-16 block scale、动态激活
@@ -193,7 +193,7 @@ DiT 链每层仅 8 kernel(无层间逐元素流量):**DiT 36.6→15.7 ms**
### 6.5 全 kernel 化三轮(37.5 → 28.5 ms)
-- **第一轮 — Qwen3 融合**(`FLASHRT_N16_QWEN3_FP4`,生产已启用):16 层全部
+- **第一轮 — Qwen3 融合**(`FLASHRT_N16_QWEN3_FP4=1`,显式 fast profile):16 层全部
q/k/v/o/gate/up/down 走 W4A4 融合 GEMM(residual 原地更新)。
- 新写两个 bf16 输入 producer kernel(`csrc/fused_fp4/`,与 torch 两步链 **bit 一致**):
`rms_norm_weight_fp4_sfa_bf16`(加权 RMSNorm→fp4 直出)、
@@ -204,7 +204,7 @@ DiT 链每层仅 8 kernel(无层间逐元素流量):**DiT 36.6→15.7 ms**
- **fused GQA attention**:torch `enable_gqa` 会退回非融合 math 路径(每层 2 个
fp16 GEMM + 显式 softmax);改 `repeat_interleave` 展开 KV 走融合后端:−2.0 ms。
- Qwen3 12.7→5.0 ms;vs HF cos 0.999986。
-- **第二轮 — SigLIP encoder fp4**(`FLASHRT_N16_SIGLIP_FP4`,默认开):LN producer
+- **第二轮 — SigLIP encoder fp4**(`FLASHRT_N16_SIGLIP_FP4=1`,显式 fast profile):LN producer
用 scale=w−1/shift=b 表达 affine;q/k/v 分离 GEMM 写连续缓冲直喂 FA4;o/ffn 残差进
epilogue;fc1 N 与 fc2 K 4304→4352 pad(pad 维全零端到端无贡献)。encoder 9.0→6.9 ms。
全开时动作精度不降(vs HF cos 0.999988)。
@@ -236,7 +236,7 @@ DiT 链每层仅 8 kernel(无层间逐元素流量):**DiT 36.6→15.7 ms**
## 7. 最终架构与配置
-### 7.1 数据流(parity + 三层 NVFP4 + FA4,生产默认)
+### 7.1 数据流(parity + 三层 NVFP4 + FA4,显式 fast profile)
```
obs ──> preprocess(apply_state 直连 + 线程池图像变换) ~2.8 ms
@@ -253,18 +253,18 @@ obs ──> preprocess(apply_state 直连 + 线程池图像变换)
| 开关 | 默认 | 作用 |
|---|---|---|
-| `FLASHRT_N16_DIT_FP4` | 生产开 | DiT NVFP4 融合链 |
-| `FLASHRT_N16_QWEN3_FP4` | 生产开 | Qwen3 NVFP4 融合层(含 fused norm/rope/GQA) |
-| `FLASHRT_N16_SIGLIP_FP4` | 开 | SigLIP encoder fp4 层 |
-| `FLASHRT_N16_FA4` | 开 | SigLIP FA4 注意力(缺失自动回退) |
+| `FLASHRT_N16_DIT_FP4` | 关 | DiT NVFP4 融合链;设为 `1` 开启 |
+| `FLASHRT_N16_QWEN3_FP4` | 关 | Qwen3 NVFP4 融合层(含 fused norm/rope/GQA);设为 `1` 开启 |
+| `FLASHRT_N16_SIGLIP_FP4` | 关 | SigLIP encoder fp4 层;设为 `1` 开启 |
+| `FLASHRT_N16_FA4` | 关 | SigLIP FA4 注意力;设为 `1` 开启,缺失时回退 |
| `FLASHRT_N16_DIT_STEPS` | 4 | flow-matching 步数(**生产勿改**,减步伤行为) |
-| `--no-fp8` / `parity` | 生产开 | HF 原生 parity 通路(非 legacy kernel 快路径) |
+| `parity` | 关 | HF 原生 parity 通路(非 legacy kernel 快路径);直接构造 frontend 时显式开启 |
精度汇总(去归一化动作 vs HF eager):
| 配置 | cos | maxd |
|---|---|---|
-| 全开(生产) | 0.999933 | 0.059 |
+| 全开(显式 fast profile) | 0.999933 | 0.059 |
| Qwen3 fp4 单独 | 0.999986 | 0.023 |
| DiT fp4 单独 | 0.999995 | 0.012 |
diff --git a/flash_rt/frontends/torch/groot_thor.py b/flash_rt/frontends/torch/groot_thor.py
index 21ed7962..b580401e 100644
--- a/flash_rt/frontends/torch/groot_thor.py
+++ b/flash_rt/frontends/torch/groot_thor.py
@@ -81,8 +81,8 @@ class GrootTorchFrontendThor:
"""GROOT N1.6 inference pipeline on Thor SM110."""
def __init__(self, checkpoint, num_views=2, autotune=3,
- embodiment_tag="new_embodiment", use_fp8=False,
- image_size=252, parity=True):
+ embodiment_tag="new_embodiment", use_fp8=True,
+ image_size=224, parity=False):
"""Initialize GROOT pipeline.
Args:
@@ -94,6 +94,8 @@ def __init__(self, checkpoint, num_views=2, autotune=3,
224 = 16x16 patches (legacy); 252 = 18x18 patches, the
GR00T N1.6 training/eval resolution (HF processor chain
LetterBoxPad -> 256 -> 0.95 crop -> 252).
+ parity: opt into the HF-native BF16 backbone and DiT. The default
+ keeps the pre-existing fully-kernelized execution path.
"""
if embodiment_tag not in EMBODIMENT_TAG_TO_INDEX:
raise ValueError(
@@ -857,29 +859,29 @@ def _setup_torch_dit(self):
* (math.log(10000.0) / hd))
self._dit_tw = w
- @staticmethod
- def _resolve_eagle_dir() -> pathlib.Path:
+ def _resolve_eagle_dir(self) -> pathlib.Path:
"""Locate the Eagle-Block2A-2B-v2 remote-code directory.
- The HF modules cache stores only .py files (no config.json), so we
- use modeling_siglip2.py as the marker.
+ Only checkpoint-local code or an explicitly selected checkout is
+ accepted. Selecting a cache directory by sort order is not
+ reproducible when several revisions are installed.
"""
override = os.environ.get("FLASHRT_N16_EAGLE_DIR")
if override:
p = pathlib.Path(override)
- if (p / "modeling_siglip2.py").exists() or (p / "config.json").exists():
+ if (p / "modeling_siglip2.py").is_file():
return p
raise RuntimeError(
- f"FLASHRT_N16_EAGLE_DIR={override} has neither "
- "modeling_siglip2.py nor config.json")
- cache = pathlib.Path.home() / ".cache/huggingface/modules/transformers_modules"
- for cand in sorted(cache.glob(
- "Eagle_hyphen_Block2A_hyphen_2B_hyphen_v2/*/modeling_siglip2.py")):
- return cand.parent
+ f"FLASHRT_N16_EAGLE_DIR={override} has no modeling_siglip2.py")
+ for candidate in (
+ self._checkpoint_path,
+ self._checkpoint_path / "Eagle-Block2A-2B-v2"):
+ if (candidate / "modeling_siglip2.py").is_file():
+ return candidate
raise RuntimeError(
- "Eagle-Block2A-2B-v2 remote code not found. Load the GR00T N1.6 "
- "model once with transformers (AutoModel.from_pretrained) to "
- "populate the HF cache, or set FLASHRT_N16_EAGLE_DIR.")
+ "Eagle-Block2A-2B-v2 remote code was not found in the checkpoint. "
+ "Set FLASHRT_N16_EAGLE_DIR to an explicitly pinned checkout; "
+ "FlashRT does not select an arbitrary Hugging Face cache revision.")
def _setup_torch_siglip(self):
"""Parity mode: HF-native Siglip2VisionModel (bf16) from the Eagle
@@ -931,7 +933,7 @@ def _patch_siglip_fa4(self, mod):
(2026-08-14). FLASHRT_N16_FA4=0 forces sdpa; a missing FA4 runtime
falls back silently. Must run before the encoder graph is captured.
"""
- if os.environ.get("FLASHRT_N16_FA4", "1") == "0":
+ if os.environ.get("FLASHRT_N16_FA4", "0") != "1":
return
try:
from flash_rt.hardware.thor import fa4_backend
@@ -1010,7 +1012,7 @@ def _setup_qwen3_fp4(self):
and the residual stream is updated in place. Weight traffic drops
2.85 GB -> ~0.8 GB per inference.
"""
- if os.environ.get("FLASHRT_N16_QWEN3_FP4", "1") != "1":
+ if os.environ.get("FLASHRT_N16_QWEN3_FP4", "0") != "1":
return
if getattr(self, "_qwen3_fp4_done", False):
return
@@ -1177,7 +1179,7 @@ def _bias(t):
logger.info("Qwen3 NVFP4 fused-epilogue tier enabled (16 layers)")
def _setup_siglip_fp4(self):
- """FLASHRT_N16_SIGLIP_FP4 (default on): NVFP4 fused-epilogue encoder.
+ """FLASHRT_N16_SIGLIP_FP4=1: NVFP4 fused-epilogue encoder.
Every layer runs the kernel chain: LN->fp4 producer (affine LayerNorm
expressed as AdaLN with scale=w-1, shift=b), q/k/v bias GEMMs into
@@ -1188,7 +1190,7 @@ def _setup_siglip_fp4(self):
0.038 (vs 0.023 bf16) — the vision-quality trade is simulation-gated;
set FLASHRT_N16_SIGLIP_FP4=0 to revert to bf16.
"""
- if os.environ.get("FLASHRT_N16_SIGLIP_FP4", "1") != "1":
+ if os.environ.get("FLASHRT_N16_SIGLIP_FP4", "0") != "1":
return
if getattr(self, "_siglip_fp4_done", False):
return
@@ -1404,7 +1406,7 @@ def _dit_setup_graph_buffers(self):
# cos 0.999994 / maxd 0.012 (2026-08-14). Weight tables are static
# and survive graph re-captures.
self._dit_use_fp4 = False
- if os.environ.get("FLASHRT_N16_DIT_FP4", "1") == "1":
+ if os.environ.get("FLASHRT_N16_DIT_FP4", "0") == "1":
try:
import flash_rt.flash_rt_fp4 as _f4
except ImportError:
@@ -1900,6 +1902,7 @@ def reset_graph_runtime(self) -> None:
stale_exact = ("_siglip_graph", "_qwen3_graph", "_dit_graph",
"_attn", "_vision_features", "_unit_scale",
"_qwen3_torch_graph", "_siglip_torch_graph",
+ "_qwen3_fp4_done", "_siglip_fp4_done",
# DiT static buffers/indexes depend on Se/masks and
# must be rebuilt on prompt-switch re-capture.
"_dit_in_state", "_dit_in_kvt", "_dit_in_kvi",
@@ -1929,11 +1932,6 @@ def set_prompt(self, prompt, input_ids=None):
When supplied (e.g. from the serving aux builder, which
reproduces HF exactly), these ids are used verbatim.
"""
- if getattr(self, '_graphs_built', False):
- raise RuntimeError(
- "set_prompt() after the pipeline is built is not supported; "
- "construct a new GrootTorchFrontendThor instance for a new prompt")
-
# Image special-token ids (fixed for the Eagle vocab).
self._img_token_id = 151669 #
self._img_start_id = 151670 #
@@ -1952,7 +1950,6 @@ def set_prompt(self, prompt, input_ids=None):
# Local GROOT code Eagle dir
str(pathlib.Path(__file__).parent.parent.parent.parent.parent /
"GR00T" / "Isaac-GR00T" / "gr00t" / "model" / "modules" / "nvidia" / "Eagle-Block2A-2B-v2"),
- "nvidia/Eagle-Block2A-2B-v2", # HF hub (fallback)
]:
try:
self._tokenizer = AutoTokenizer.from_pretrained(tok_path, trust_remote_code=True)
@@ -1968,7 +1965,16 @@ def set_prompt(self, prompt, input_ids=None):
full_ids = text_ids + [self._img_start_id] + [self._img_token_id] * S_img + [self._img_end_id]
text_count = len(text_ids)
+ input_id_values = tuple(full_ids)
+ if getattr(self, '_graphs_built', False):
+ if input_id_values == getattr(self, '_input_id_values', None):
+ self._prompt_text = prompt
+ return
+ logger.info("Prompt changed after graph capture; resetting graph runtime")
+ self.reset_graph_runtime()
+
self._input_ids = torch.tensor([full_ids], dtype=torch.long, device='cuda')
+ self._input_id_values = input_id_values
self._text_len = text_count
self._Se = len(full_ids)
self._prompt_text = prompt
@@ -2761,8 +2767,9 @@ def _collect_dit_amax(self):
ae_concat = torch.empty(T, 2*D, dtype=fp16, device='cuda')
self._gemm.fp16_nn(actions_fp16.data_ptr(), dit.ae_w1.data_ptr(), a_emb_out.data_ptr(), T, D, dit.action_dim, 0)
fvk.add_bias_fp16(a_emb_out.data_ptr(), dit.ae_b1.data_ptr(), T, D, 0)
- fvk.gpu_copy(ae_concat.data_ptr(), a_emb_out.data_ptr(), T*D*2, 0)
- fvk.gpu_copy(ae_concat.data_ptr()+T*D*2, dit.action_time_embeds[0].data_ptr(), T*D*2, 0)
+ fvk.concat2_bf16(a_emb_out.data_ptr(),
+ dit.action_time_embeds[0].data_ptr(),
+ ae_concat.data_ptr(), T, D, D, 0)
enc_h = torch.empty(T, D, dtype=fp16, device='cuda')
self._gemm.fp16_nn(ae_concat.data_ptr(), dit.ae_w2.data_ptr(), enc_h.data_ptr(), T, D, 2*D, 0)
fvk.add_bias_fp16(enc_h.data_ptr(), dit.ae_b2.data_ptr(), T, D, 0)
diff --git a/flash_rt/hardware/thor/fa4_backend.py b/flash_rt/hardware/thor/fa4_backend.py
index 9b292913..d7dd0fde 100644
--- a/flash_rt/hardware/thor/fa4_backend.py
+++ b/flash_rt/hardware/thor/fa4_backend.py
@@ -1,9 +1,8 @@
"""Isolated FlashAttention-4 (CuTe-DSL) backend for Thor (sm_110).
-FA4's SM100 Blackwell forward kernel runs on Thor when compiled for ``sm_110a``.
-The vendored tree contains the SM100-compatible kernel only, so the loader
-keeps ``FLASH_ATTENTION_ARCH=sm_100a`` as the runtime dispatch key while
-``CUTE_DSL_ARCH=sm_110a`` selects the Thor compilation target.
+FA4's SM100-compatible forward kernel runs on Thor through the ``sm_101a``
+CuTe-DSL compilation alias. Runtime dispatch still uses the physical device
+capability (SM110); the compilation alias must never select SM100-only kernels.
At the LingBot denoise shape (Sq=51, Skv~891, GQA 16/2, HD=128) with
``pack_gqa`` it is ~17% faster than the vendored fmha kernel, cos=1.0, and is
CUDA-graph capture-safe.
@@ -59,7 +58,6 @@ def _load() -> None:
# sm_110a). Requires nvidia-cutlass-dsl >= 4.5 (which accepts the
# sm_101a alias); 4.4.x only knows sm_110a and is NOT supported.
os.environ.setdefault("CUTE_DSL_ARCH", "sm_101a")
- os.environ.setdefault("FLASH_ATTENTION_ARCH", "sm_100a")
# Allow an override dir (e.g. for local FA4 development), else the vendor.
src = os.environ.get("LINGBOT_FA4_SRC") or (
diff --git a/tests/test_groot_n16_pr177_maintenance.py b/tests/test_groot_n16_pr177_maintenance.py
new file mode 100644
index 00000000..aecabe3a
--- /dev/null
+++ b/tests/test_groot_n16_pr177_maintenance.py
@@ -0,0 +1,79 @@
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def _read(path: str) -> str:
+ return (ROOT / path).read_text()
+
+
+def test_thor_qk_norm_source_and_binding_share_one_gate():
+ cmake = _read("CMakeLists.txt")
+ bindings = _read("csrc/bindings.cpp")
+
+ gate = cmake.index("if(ENABLE_SM100_CUTLASS)", cmake.index("Thor-class VLA"))
+ source = cmake.index("csrc/kernels/qk_norm_rope_rotate_half_bf16.cu", gate)
+ gate_end = cmake.index("endif()", gate)
+ assert gate < source < gate_end
+ assert '#ifdef FLASHRT_HAVE_THOR_VLA_KERNELS\n#include "kernels/qk_norm_rope_rotate_half_bf16.cuh"' in bindings
+ assert '#ifdef FLASHRT_HAVE_THOR_VLA_KERNELS\n m.def("qk_norm_rope_rotate_half_bf16"' in bindings
+
+
+def test_fa4_compilation_alias_does_not_override_runtime_arch():
+ backend = _read("flash_rt/hardware/thor/fa4_backend.py")
+ interface = _read(
+ "csrc/attention/flash_attn_4_src/flashrt_fa4/cute/"
+ "interface_fwd_sm100.py"
+ )
+
+ assert 'setdefault("CUTE_DSL_ARCH", "sm_101a")' in backend
+ assert 'setdefault("FLASH_ATTENTION_ARCH"' not in backend
+ assert "torch.cuda.get_device_capability()" in interface
+ assert "use_dedicated_hd256_kernel = arch // 10 == 10" in interface
+
+
+def test_n16_new_precision_tiers_are_opt_in():
+ source = _read("flash_rt/frontends/torch/groot_thor.py")
+
+ assert 'embodiment_tag="new_embodiment", use_fp8=True,' in source
+ assert "image_size=224, parity=False" in source
+ for name in (
+ "FLASHRT_N16_FA4",
+ "FLASHRT_N16_QWEN3_FP4",
+ "FLASHRT_N16_SIGLIP_FP4",
+ "FLASHRT_N16_DIT_FP4",
+ ):
+ assert f'os.environ.get("{name}", "0")' in source
+
+
+def test_prompt_switch_and_calibration_follow_runtime_layout_contracts():
+ source = _read("flash_rt/frontends/torch/groot_thor.py")
+ prompt_start = source.index(" def set_prompt(")
+ prompt = source[prompt_start:source.index(" def infer_action_head(", prompt_start)]
+
+ assert "self.reset_graph_runtime()" in prompt
+ assert prompt.index("self.reset_graph_runtime()") < prompt.index(
+ "self._input_ids = torch.tensor"
+ )
+ reset_start = source.index(" def reset_graph_runtime(")
+ reset = source[reset_start:source.index(" def set_prompt(", reset_start)]
+ assert '"_qwen3_fp4_done", "_siglip_fp4_done"' in reset
+
+ collect_start = source.index(" def _collect_dit_amax(")
+ collect = source[collect_start:source.index(" def _calibrate_dit(", collect_start)]
+ assert "fvk.concat2_bf16(a_emb_out.data_ptr()," in collect
+ assert "ae_concat.data_ptr()+T*D*2" not in collect
+
+
+def test_eagle_remote_code_requires_checkpoint_local_or_explicit_revision():
+ source = _read("flash_rt/frontends/torch/groot_thor.py")
+ resolver_start = source.index(" def _resolve_eagle_dir(")
+ resolver = source[resolver_start:source.index(
+ " def _setup_torch_siglip(", resolver_start
+ )]
+
+ assert "FLASHRT_N16_EAGLE_DIR" in resolver
+ assert "self._checkpoint_path" in resolver
+ assert "transformers_modules" not in resolver
+ assert ".glob(" not in resolver
diff --git a/tests/test_prompt_runtime_lifecycle.py b/tests/test_prompt_runtime_lifecycle.py
index f32a0841..239d2ede 100644
--- a/tests/test_prompt_runtime_lifecycle.py
+++ b/tests/test_prompt_runtime_lifecycle.py
@@ -219,15 +219,14 @@ def infer(self, obs):
assert BucketFrontend.calibrations == 2
-def test_groot_thor_rejects_prompt_changes_after_graph_build():
+def test_groot_thor_resets_graphs_before_accepting_prompt_changes():
source = Path("flash_rt/frontends/torch/groot_thor.py").read_text()
- set_prompt_pos = source.index(" def set_prompt(self, prompt):")
- guard_pos = source.index("getattr(self, '_graphs_built', False)",
- set_prompt_pos)
- tokenizer_pos = source.index("from transformers import AutoTokenizer",
- set_prompt_pos)
+ set_prompt_pos = source.index(" def set_prompt(self, prompt, input_ids=None):")
+ reset_pos = source.index("self.reset_graph_runtime()", set_prompt_pos)
+ assign_pos = source.index("self._input_ids = torch.tensor", set_prompt_pos)
- assert guard_pos < tokenizer_pos
+ assert reset_pos < assign_pos
+ assert "set_prompt() after the pipeline is built is not supported" not in source
def test_groot_thor_infer_refreshes_state_before_dit_graph_replay():
From 4c830e898482cd48960a4401e732216aeb0edcf2 Mon Sep 17 00:00:00 2001
From: LiangSu8899 <7thuniversels@gmail.com>
Date: Sun, 23 Aug 2026 13:06:43 -0400
Subject: [PATCH 6/6] fix(groot): keep parity routing coherent
---
flash_rt/frontends/torch/groot_thor.py | 17 +++++++++++++----
tests/test_groot_n16_pr177_maintenance.py | 8 ++++++++
2 files changed, 21 insertions(+), 4 deletions(-)
diff --git a/flash_rt/frontends/torch/groot_thor.py b/flash_rt/frontends/torch/groot_thor.py
index b580401e..1d2f4937 100644
--- a/flash_rt/frontends/torch/groot_thor.py
+++ b/flash_rt/frontends/torch/groot_thor.py
@@ -106,6 +106,14 @@ def __init__(self, checkpoint, num_views=2, autotune=3,
self._num_views = num_views
self._autotune = autotune
self.use_fp8 = bool(use_fp8)
+ if parity and self.use_fp8:
+ raise ValueError(
+ "parity=True requires use_fp8=False; mixing the FP8 backbone "
+ "with the HF-native DiT is not a supported precision profile")
+ if parity and image_size != 252:
+ raise ValueError(
+ "parity=True requires image_size=252 to match the GR00T "
+ "N1.6 evaluation preprocessing contract")
# Working dtype of the vision/LLM feature path: FP8 mode keeps the
# legacy fp16 buffers; parity mode stays bf16 end-to-end to match HF.
self._bd = (torch.float16 if (self.use_fp8 or not parity)
@@ -3230,12 +3238,13 @@ def _capture_all_graphs(self, obs, release_full_sd: bool = True):
# Extract bf16 action-head weights for the HF-faithful torch DiT
# path (must happen while _full_sd is alive).
- self._setup_torch_dit()
- if not self.use_fp8 and not hasattr(self, "_torch_qwen3"):
+ if self.parity:
+ self._setup_torch_dit()
+ if self.parity and not hasattr(self, "_torch_qwen3"):
self._setup_torch_qwen3()
- if not self.use_fp8:
+ if self.parity:
self._setup_qwen3_fp4()
- if not self.use_fp8 and not hasattr(self, "_torch_siglip"):
+ if self.parity and not hasattr(self, "_torch_siglip"):
self._setup_torch_siglip()
# Static NaFlex window meta (shapes fixed per image_size):
# needed by the capture-time eager encoder run before 6c.
diff --git a/tests/test_groot_n16_pr177_maintenance.py b/tests/test_groot_n16_pr177_maintenance.py
index aecabe3a..fcc9fbf8 100644
--- a/tests/test_groot_n16_pr177_maintenance.py
+++ b/tests/test_groot_n16_pr177_maintenance.py
@@ -38,6 +38,8 @@ def test_n16_new_precision_tiers_are_opt_in():
assert 'embodiment_tag="new_embodiment", use_fp8=True,' in source
assert "image_size=224, parity=False" in source
+ assert "parity=True requires use_fp8=False" in source
+ assert "parity=True requires image_size=252" in source
for name in (
"FLASHRT_N16_FA4",
"FLASHRT_N16_QWEN3_FP4",
@@ -46,6 +48,12 @@ def test_n16_new_precision_tiers_are_opt_in():
):
assert f'os.environ.get("{name}", "0")' in source
+ capture_start = source.index(" def _capture_all_graphs(")
+ capture = source[capture_start:]
+ assert "if self.parity:\n self._setup_torch_dit()" in capture
+ assert 'if self.parity and not hasattr(self, "_torch_qwen3")' in capture
+ assert 'if self.parity and not hasattr(self, "_torch_siglip")' in capture
+
def test_prompt_switch_and_calibration_follow_runtime_layout_contracts():
source = _read("flash_rt/frontends/torch/groot_thor.py")