groot-n1.7-mlx / groot_mlx.py
tokimoa's picture
groot-n1.7-mlx: MLX port (E2E parity cos 1.000000) + runtime + NVIDIA non-commercial license
e1ee88a verified
Raw
History Blame Contribute Delete
13.2 kB
"""GR00T N1.7 (nvidia/GR00T-N1.7-3B) inference runtime for Apple Silicon / MLX.
NON-COMMERCIAL USE ONLY: the base model is released under the NVIDIA license
included in this repository (Section 3.3: research or evaluation purposes only).
Faithful port of the LeRobot reference (which is parity-tested against NVIDIA's
original gr00t package):
- backbone: Cosmos-Reason2-2B (Qwen3-VL) truncated to 16 layers, pre-final-norm
features, fp32 execution (matching the reference runtime's fp32 upcast)
- action head: 32-layer AlternateVL-DiT (AdaLN conditioning, alternating
image/text cross-attention) + per-embodiment encoders, 4-step flow matching
- verified end-to-end vs PyTorch reference: cosine 1.000000 / max diff 0.00027
Preprocessing (Qwen3-VL chat template, image packing, state normalization) is
produced with the LeRobot pipeline — see preprocess_lerobot.py.
Usage:
from groot_mlx import GrootMLX
m = GrootMLX.from_pretrained(".")
chunk = m.sample_actions_from_processed("processed.pt") # -> (B, 40, 132)
"""
import json
import math
from pathlib import Path
import mlx.core as mx
import mlx.nn as nn
import numpy as np
from mlx_vlm.models.qwen3_vl import Model, ModelConfig
from mlx_vlm.models.qwen3_vl.language import create_attention_mask
N_BB_LAYERS = 16
# ---------------- バックボーン(Qwen3-VL 16層・pre-norm出力・fp32) ----------------
def build_backbone(ckpt_dir):
ckpt_dir = Path(ckpt_dir)
cfg_dict = json.load(open(ckpt_dir / "cosmos_config.json"))
cfg_dict["text_config"]["num_hidden_layers"] = N_BB_LAYERS
cfg = ModelConfig.from_dict(cfg_dict)
cfg.text_config = type(cfg.text_config).from_dict(cfg_dict["text_config"])
cfg.vision_config = type(cfg.vision_config).from_dict(cfg_dict["vision_config"])
model = Model(cfg)
weights = {}
for f in ckpt_dir.glob("model-*.safetensors"):
weights.update(mx.load(str(f)))
pref = "backbone.model."
bb = {k[len(pref):]: v for k, v in weights.items() if k.startswith(pref)}
bb = {k: v for k, v in bb.items() if ".layers." not in k or
int(k.split(".layers.")[1].split(".")[0]) < N_BB_LAYERS or "visual" in k}
# torch参照はbf16チェックポイントをfp32昇格して実行する。ここを揃えないと一致しない
bb = {k: (v.astype(mx.float32) if v.dtype == mx.bfloat16 else v) for k, v in bb.items()}
bb = model.sanitize(bb)
if hasattr(model.vision_tower, "sanitize"):
bb = model.vision_tower.sanitize(bb)
model.load_weights(list(bb.items()), strict=False)
mx.eval(model.parameters())
return model
def backbone_features(model, input_ids, pixel_values, image_grid_thw):
feats = model.get_input_embeddings(input_ids, pixel_values, image_grid_thw=image_grid_thw)
lm = model.language_model.model
h = feats.inputs_embeds
mask = create_attention_mask(h, [None] * len(lm.layers))
position_ids = feats.position_ids
position_embeddings = None
if position_ids is not None and not lm.layers[0].self_attn.rotary_emb.fused_apply:
position_embeddings = lm.layers[0].self_attn.rotary_emb(h, position_ids)
dse = feats.deepstack_visual_embeds
vpm = feats.visual_pos_masks
for layer_idx, layer in enumerate(lm.layers):
h = layer(h, mask, None, position_ids, position_embeddings)
if dse is not None and layer_idx in range(len(dse)):
h = lm._deepstack_process(h, vpm, dse[layer_idx])
return h # GR00Tはpre-final-norm出力を使う
# ---------------- アクションヘッド(DiT + per-embodimentエンコーダ) ----------------
def layer_norm(x, w=None, b=None, eps=1e-5):
x32 = x.astype(mx.float32)
mu = mx.mean(x32, axis=-1, keepdims=True)
var = mx.var(x32, axis=-1, keepdims=True)
out = (x32 - mu) * mx.rsqrt(var + eps)
if w is not None:
out = out * w.astype(mx.float32) + b.astype(mx.float32)
return out.astype(x.dtype)
def timesteps_embed(t_vals, num_channels=256, max_period=10000.0, shift=1.0):
half = num_channels // 2
exponent = -math.log(max_period) * np.arange(half, dtype=np.float64) / (half - shift)
emb = np.exp(exponent)[None, :] * np.asarray(t_vals, dtype=np.float64)[:, None]
return mx.array(np.concatenate([np.cos(emb), np.sin(emb)], axis=1).astype(np.float32))
def sinusoidal_time_action(timesteps_2d, dim):
half = dim // 2
exponent = -np.arange(half, dtype=np.float32) * (math.log(10000.0) / half)
freqs = np.asarray(timesteps_2d, dtype=np.float32)[..., None] * np.exp(exponent)[None, None, :]
return mx.array(np.concatenate([np.sin(freqs), np.cos(freqs)], axis=-1))
class GrootHead:
def __init__(self, weights, cfg):
self.w = {k[len("action_head."):]: v for k, v in weights.items() if k.startswith("action_head.")}
d = cfg["diffusion_model_cfg"]
self.N_DIT = d["num_layers"]
self.N_HEADS = d["num_attention_heads"]
self.HEAD_DIM = d["attention_head_dim"]
self.ATTEND_TEXT_N = cfg.get("attend_text_every_n_blocks") or 2
self.VL_CFG = cfg["vl_self_attention_cfg"]
self.HORIZON = cfg["action_horizon"]
self.NUM_STEPS = cfg["num_inference_timesteps"]
self.BUCKETS = cfg["num_timestep_buckets"]
def cs_linear(self, prefix, x, cat_id):
return x @ self.w[prefix + ".W"][cat_id] + self.w[prefix + ".b"][cat_id][:, None, :]
def cs_mlp(self, prefix, x, cat_id):
h = mx.maximum(self.cs_linear(prefix + ".layer1", x, cat_id), 0)
return self.cs_linear(prefix + ".layer2", h, cat_id)
def attention(self, prefix, q_in, kv_in, n_heads, head_dim, mask=None):
B, Lq = q_in.shape[:2]
Lk = kv_in.shape[1]
q = q_in @ self.w[prefix + ".to_q.weight"].T + self.w[prefix + ".to_q.bias"]
k = kv_in @ self.w[prefix + ".to_k.weight"].T + self.w[prefix + ".to_k.bias"]
v = kv_in @ self.w[prefix + ".to_v.weight"].T + self.w[prefix + ".to_v.bias"]
q = q.reshape(B, Lq, n_heads, head_dim).transpose(0, 2, 1, 3)
k = k.reshape(B, Lk, n_heads, head_dim).transpose(0, 2, 1, 3)
v = v.reshape(B, Lk, n_heads, head_dim).transpose(0, 2, 1, 3)
att = (q @ k.transpose(0, 1, 3, 2)) * (head_dim ** -0.5)
if mask is not None:
att = mx.where(mask[:, None, None, :], att, mx.finfo(mx.float32).min)
probs = mx.softmax(att.astype(mx.float32), axis=-1).astype(v.dtype)
out = (probs @ v).transpose(0, 2, 1, 3).reshape(B, Lq, n_heads * head_dim)
return out @ self.w[prefix + ".to_out.0.weight"].T + self.w[prefix + ".to_out.0.bias"]
def ff(self, prefix, x):
h = x @ self.w[prefix + ".net.0.proj.weight"].T + self.w[prefix + ".net.0.proj.bias"]
h = nn.gelu_approx(h)
return h @ self.w[prefix + ".net.2.weight"].T + self.w[prefix + ".net.2.bias"]
def vl_self_attention(self, feats):
n, hd = self.VL_CFG["num_attention_heads"], self.VL_CFG["attention_head_dim"]
h = feats
for i in range(self.VL_CFG["num_layers"]):
p = f"vl_self_attention.transformer_blocks.{i}"
hn = layer_norm(h, self.w[p + ".norm1.weight"], self.w[p + ".norm1.bias"])
h = self.attention(p + ".attn1", hn, hn, n, hd) + h
hn = layer_norm(h, self.w[p + ".norm3.weight"], self.w[p + ".norm3.bias"])
h = self.ff(p + ".ff", hn) + h
return h
def ada_norm(self, prefix, x, temb):
t = nn.silu(temb) @ self.w[prefix + ".linear.weight"].T + self.w[prefix + ".linear.bias"]
scale, shift = mx.split(t, 2, axis=1)
return layer_norm(x) * (1 + scale[:, None]) + shift[:, None]
def dit(self, sa_embs, vl_embs, temb, image_mask, bb_att_mask):
image_att = image_mask & bb_att_mask
text_att = (~image_mask) & bb_att_mask
h = sa_embs
for i in range(self.N_DIT):
p = f"model.transformer_blocks.{i}"
hn = self.ada_norm(p + ".norm1", h, temb)
if i % 2 == 1:
h = self.attention(p + ".attn1", hn, hn, self.N_HEADS, self.HEAD_DIM) + h
else:
m = text_att if i % (2 * self.ATTEND_TEXT_N) == 0 else image_att
h = self.attention(p + ".attn1", hn, vl_embs, self.N_HEADS, self.HEAD_DIM, mask=m) + h
h = self.ff(p + ".ff", layer_norm(h)) + h
t = nn.silu(temb) @ self.w["model.proj_out_1.weight"].T + self.w["model.proj_out_1.bias"]
shift, scale = mx.split(t, 2, axis=1)
h = layer_norm(h, eps=1e-6) * (1 + scale[:, None]) + shift[:, None]
return h @ self.w["model.proj_out_2.weight"].T + self.w["model.proj_out_2.bias"]
def timestep_encoder(self, t_disc):
e = timesteps_embed(t_disc)
e = e @ self.w["model.timestep_encoder.timestep_embedder.linear_1.weight"].T \
+ self.w["model.timestep_encoder.timestep_embedder.linear_1.bias"]
e = nn.silu(e)
return e @ self.w["model.timestep_encoder.timestep_embedder.linear_2.weight"].T \
+ self.w["model.timestep_encoder.timestep_embedder.linear_2.bias"]
def action_encoder(self, actions, t_disc, cat_id):
B, T, _ = actions.shape
a = self.cs_linear("action_encoder.W1", actions, cat_id)
tt = np.broadcast_to(np.asarray(t_disc, dtype=np.float32)[:, None], (B, T))
te = sinusoidal_time_action(tt, a.shape[-1]).astype(a.dtype)
x = self.cs_linear("action_encoder.W2", mx.concatenate([a, te], axis=-1), cat_id)
x = x * mx.sigmoid(x)
return self.cs_linear("action_encoder.W3", x, cat_id)
def get_action(self, bb_raw, image_mask, bb_att_mask, state, embodiment_id, noise):
feats = layer_norm(bb_raw, self.w["vlln.weight"], self.w["vlln.bias"])
vl_embs = self.vl_self_attention(feats)
state_feats = self.cs_mlp("state_encoder", state.reshape(state.shape[0], 1, -1), embodiment_id)
x_t = noise
dt = 1.0 / self.NUM_STEPS
for step in range(self.NUM_STEPS):
t_disc = [int(step / float(self.NUM_STEPS) * self.BUCKETS)] * x_t.shape[0]
temb = self.timestep_encoder(t_disc)
act = self.action_encoder(x_t, t_disc, embodiment_id)
act = act + self.w["position_embedding.weight"][mx.arange(act.shape[1])][None]
pred = self.dit(mx.concatenate([state_feats, act], axis=1), vl_embs, temb,
image_mask, bb_att_mask)
pred = self.cs_mlp("action_decoder", pred, embodiment_id)
x_t = x_t + dt * pred[:, -self.HORIZON:]
mx.eval(x_t)
return x_t
# ---------------- 統合ランタイム ----------------
class GrootMLX:
def __init__(self, path="."):
self.dir = Path(path)
self.cfg = json.load(open(self.dir / "config.json"))
self.backbone = build_backbone(self.dir)
weights = {}
for f in self.dir.glob("model-*.safetensors"):
weights.update(mx.load(str(f)))
self.head = GrootHead(weights, self.cfg)
self.image_token_id = json.load(open(self.dir / "cosmos_config.json"))["image_token_id"]
@classmethod
def from_pretrained(cls, path):
return cls(path)
def sample_actions(self, input_ids, pixel_values, image_grid_thw, attention_mask,
state, embodiment_id, noise=None, seed=42):
bb = backbone_features(self.backbone, input_ids, pixel_values, image_grid_thw).astype(mx.float32)
image_mask = input_ids == self.image_token_id
att = attention_mask.astype(mx.bool_)
if noise is None:
np.random.seed(seed)
noise = mx.array(np.random.standard_normal(
(state.shape[0], self.head.HORIZON, 132)).astype("float32"))
return np.array(self.head.get_action(bb, image_mask, att, state, embodiment_id, noise))
def sample_actions_from_processed(self, processed_pt, noise=None, seed=42):
"""preprocess_lerobot.pyの出力(.pt)から行動チャンクを生成する。"""
import torch
ref = torch.load(processed_pt, weights_only=False)
proc = ref.get("proc", ref)
def get(k):
return proc[k] if k in proc else ref[k]
if noise is None and "noise" in ref:
noise = mx.array(ref["noise"].cpu().float().numpy())
return self.sample_actions(
mx.array(proc["input_ids"].cpu().numpy()),
mx.array(proc["pixel_values"].cpu().float().numpy()),
mx.array(proc["image_grid_thw"].cpu().numpy()),
mx.array(proc["attention_mask"].cpu().numpy()),
mx.array(get("state").cpu().float().numpy()),
mx.array(get("embodiment_id").cpu().numpy()),
noise=noise, seed=seed,
)
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--processed", required=True, help="preprocess_lerobot.pyの出力.pt")
ap.add_argument("--out", default="actions.npy")
args = ap.parse_args()
m = GrootMLX.from_pretrained(Path(__file__).parent)
chunk = m.sample_actions_from_processed(args.processed)
np.save(args.out, chunk)
print(f"action chunk {chunk.shape} -> {args.out}")