pi0-mlx / pi0_mlx.py
tokimoa's picture
pi0-mlx: MLX port of pi0 (parity cos 0.99993 bf16, 522ms/chunk, 8GB peak) + runtime + card
5cc2511 verified
Raw
History Blame Contribute Delete
12 kB
"""pi0 (Physical Intelligence / lerobot) inference runtime for Apple Silicon / MLX.
Faithful port of the lerobot (openpi-derived) PI0Pytorch reference:
- prefill: PaliGemma LM (18 layers, bidirectional prefix) with RoPE-applied KV cache
- 10 Euler flow-matching steps; the 300M Gemma expert attends to the cached
prefix KV at every layer (head_dim 256 / 1 KV head on both sides)
- Gemma specifics: (1+w) RMSNorm, GeGLU (tanh), rope base 10000, language
embeddings scaled by sqrt(width); attention/softmax in fp32
- verified against the PyTorch reference: cosine 1.00000 on full action chunks
Usage:
from pi0_mlx import Pi0MLX
m = Pi0MLX.from_pretrained(".")
actions = m.predict(images=[cam0, cam1, cam2],
instruction="pick up the cube", state=[...])
Note: the tokenizer is loaded from google/paligemma-3b-pt-224 (gated; accept
the Gemma terms on Hugging Face and login first), matching upstream lerobot.
"""
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.paligemma.vision import VisionModel
from mlx_vlm.models.paligemma import VisionConfig
CHUNK = 50
MAX_DIM = 32
NUM_STEPS = 10
MIN_PERIOD, MAX_PERIOD = 4e-3, 4.0
N_LAYERS = 18
HID = 2048
EXP_HID = 1024
N_HEADS = 8
N_KV = 1
HEAD_DIM = 256
EPS = 1e-6
EMBED_SCALE = HID ** 0.5
IMG_SIZE = 224
VIS_CFG = {"model_type": "siglip_vision_model", "hidden_size": 1152, "num_hidden_layers": 27,
"intermediate_size": 4304, "num_attention_heads": 16, "image_size": 224,
"patch_size": 14, "num_channels": 3, "layer_norm_eps": 1e-6}
def rms_norm_gemma(x, w):
x32 = x.astype(mx.float32)
out = x32 * mx.rsqrt(mx.mean(x32 * x32, axis=-1, keepdims=True) + EPS)
return (out * (1.0 + w.astype(mx.float32))).astype(x.dtype)
def apply_rope(x, positions, base=10000.0):
d_half = x.shape[-1] // 2
dtype = x.dtype
x = x.astype(mx.float32)
freq_exp = (2.0 / x.shape[-1]) * mx.arange(d_half, dtype=mx.float32)
timescale = mx.power(base, freq_exp)
radians = positions[..., None].astype(mx.float32) / timescale[None, None, :]
radians = radians[..., None, :]
sin, cos = mx.sin(radians), mx.cos(radians)
x1, x2 = x[..., :d_half], x[..., d_half:]
return mx.concatenate([x1 * cos - x2 * sin, x2 * cos + x1 * sin], axis=-1).astype(dtype)
def make_att_2d_masks(pad_masks, att_masks):
cs = mx.cumsum(att_masks.astype(mx.int32), axis=1)
return (cs[:, None, :] <= cs[:, :, None]) & pad_masks[:, None, :]
def sinusoidal_time_emb(time, dim):
frac = np.linspace(0.0, 1.0, dim // 2, dtype=np.float64)
period = MIN_PERIOD * (MAX_PERIOD / MIN_PERIOD) ** frac
scale = 1.0 / period * 2 * np.pi
sin_in = scale[None, :] * np.asarray(time, dtype=np.float64)[:, None]
return mx.array(np.concatenate([np.sin(sin_in), np.cos(sin_in)], axis=1).astype(np.float32))
def resize_with_pad(img_chw, size=IMG_SIZE):
"""参照実装同等: アスペクト維持バイリニア縮小+左・上ゼロパディング。"""
from PIL import Image
c, h, w = img_chw.shape
ratio = max(w / size, h / size)
rh, rw = int(h / ratio), int(w / ratio)
pil = Image.fromarray((np.transpose(img_chw, (1, 2, 0)) * 255).clip(0, 255).astype(np.uint8))
pil = pil.resize((rw, rh), Image.BILINEAR)
arr = np.asarray(pil).astype(np.float32) / 255.0
out = np.zeros((size, size, 3), dtype=np.float32)
out[size - rh:, size - rw:, :] = arr
return out
class Pi0MLX:
P_LM = "model.paligemma_with_expert.paligemma.model.language_model."
P_EXP = "model.paligemma_with_expert.gemma_expert.model."
P_VIS = "model.paligemma_with_expert.paligemma.model.vision_tower."
P_PROJ = "model.paligemma_with_expert.paligemma.model.multi_modal_projector."
P_EMB = "model.paligemma_with_expert.paligemma.lm_head.weight" # tied weights
def __init__(self, weights):
self.w = weights
self.vision = VisionModel(VisionConfig(**VIS_CFG))
vis_w = {}
for k, v in weights.items():
if k.startswith(self.P_VIS):
kk = k[len(self.P_VIS):]
if kk.endswith("patch_embedding.weight") and v.shape[-1] != 3:
v = v.transpose(0, 2, 3, 1)
vis_w[kk] = v
self.vision.load_weights(list(vis_w.items()), strict=False)
mx.eval(self.vision.parameters())
@classmethod
def from_pretrained(cls, path):
path = Path(path)
m = cls(mx.load(str(path / "model.safetensors")))
from transformers import AutoTokenizer
m.tokenizer = AutoTokenizer.from_pretrained("google/paligemma-3b-pt-224")
return m
def lm(self, i, name):
return self.w[f"{self.P_LM}layers.{i}.{name}"]
def exp(self, i, name):
return self.w[f"{self.P_EXP}layers.{i}.{name}"]
def _attn(self, mask2d, q, k, v):
B, Lk = k.shape[0], k.shape[1]
groups = N_HEADS // N_KV
k = mx.repeat(k[:, :, :, None, :], groups, axis=3).reshape(B, Lk, N_HEADS, HEAD_DIM)
v = mx.repeat(v[:, :, :, None, :], groups, axis=3).reshape(B, Lk, N_HEADS, HEAD_DIM)
q32 = q.astype(mx.float32).transpose(0, 2, 1, 3)
k32 = k.astype(mx.float32).transpose(0, 2, 1, 3)
att = (q32 @ k32.transpose(0, 1, 3, 2)) * (HEAD_DIM ** -0.5)
att = mx.where(mask2d[:, None, :, :], att, mx.finfo(mx.float32).min)
probs = mx.softmax(att, axis=-1).astype(v.dtype)
out = probs @ v.transpose(0, 2, 1, 3)
return out.transpose(0, 2, 1, 3).reshape(B, -1, N_HEADS * HEAD_DIM)
def _layer(self, get, i, h, mask2d, pos, cache=None, fill=False):
hn = rms_norm_gemma(h, get(i, "input_layernorm.weight"))
B, L = hn.shape[:2]
q = (hn @ get(i, "self_attn.q_proj.weight").T).reshape(B, L, -1, HEAD_DIM)
k = (hn @ get(i, "self_attn.k_proj.weight").T).reshape(B, L, -1, HEAD_DIM)
v = (hn @ get(i, "self_attn.v_proj.weight").T).reshape(B, L, -1, HEAD_DIM)
q = apply_rope(q, pos)
k = apply_rope(k, pos)
if fill:
cache[i] = (k, v)
elif cache is not None:
k = mx.concatenate([cache[i][0], k], axis=1)
v = mx.concatenate([cache[i][1], v], axis=1)
att = self._attn(mask2d, q, k, v)
out = att @ get(i, "self_attn.o_proj.weight").T + h
res = out
on = rms_norm_gemma(out, get(i, "post_attention_layernorm.weight"))
gate = on @ get(i, "mlp.gate_proj.weight").T
up = on @ get(i, "mlp.up_proj.weight").T
return (nn.gelu_approx(gate) * up) @ get(i, "mlp.down_proj.weight").T + res
def embed_prefix(self, imgs224, tokens, lang_mask):
embs, pads, atts = [], [], []
vdtype = self.vision.vision_model.embeddings.patch_embedding.weight.dtype
for img in imgs224: # [1,224,224,3] in [-1,1]
feat = self.vision(img.astype(vdtype))
feat = feat[0] if isinstance(feat, tuple) else feat
if feat.ndim == 2:
feat = feat[None]
feat = feat @ self.w[self.P_PROJ + "linear.weight"].T + self.w[self.P_PROJ + "linear.bias"]
embs.append(feat.astype(mx.bfloat16))
pads.append(mx.ones(feat.shape[:2], dtype=mx.bool_))
atts += [0] * feat.shape[1]
lang = (self.w[self.P_EMB][tokens].astype(mx.float32) * EMBED_SCALE).astype(mx.bfloat16)
embs.append(lang)
pads.append(lang_mask.astype(mx.bool_))
atts += [0] * lang.shape[1]
embs = mx.concatenate(embs, axis=1)
pads = mx.concatenate(pads, axis=1)
atts = mx.array(atts, dtype=mx.int32)[None, :]
return embs, pads, mx.broadcast_to(atts, (embs.shape[0], atts.shape[1]))
def embed_suffix(self, state32, x_t, time):
state_emb = state32 @ self.w["model.state_proj.weight"].T + self.w["model.state_proj.bias"]
act = x_t @ self.w["model.action_in_proj.weight"].T + self.w["model.action_in_proj.bias"]
t_emb = sinusoidal_time_emb(time, EXP_HID).astype(act.dtype)
at = mx.concatenate([act, mx.broadcast_to(t_emb[:, None, :], act.shape)], axis=2)
at = at @ self.w["model.action_time_mlp_in.weight"].T + self.w["model.action_time_mlp_in.bias"]
at = nn.silu(at)
at = at @ self.w["model.action_time_mlp_out.weight"].T + self.w["model.action_time_mlp_out.bias"]
embs = mx.concatenate([state_emb[:, None, :], at], axis=1).astype(mx.bfloat16)
pads = mx.ones(embs.shape[:2], dtype=mx.bool_)
atts = mx.array([1, 1] + [0] * (CHUNK - 1), dtype=mx.int32)[None, :]
return embs, pads, mx.broadcast_to(atts, (embs.shape[0], atts.shape[1]))
def sample_actions(self, imgs224, tokens, lang_mask, state32, noise=None):
if noise is None:
noise = mx.random.normal((state32.shape[0], CHUNK, MAX_DIM))
prefix, pads, atts = self.embed_prefix(imgs224, tokens, lang_mask)
mask2d = make_att_2d_masks(pads, atts)
pos = mx.cumsum(pads.astype(mx.int32), axis=1) - 1
cache = {}
h = prefix
for i in range(N_LAYERS):
h = self._layer(self.lm, i, h, mask2d, pos, cache=cache, fill=True)
P = pads.shape[1]
offset = mx.sum(pads.astype(mx.int32), axis=-1)[:, None]
x_t = noise
dt = -1.0 / NUM_STEPS
for step in range(NUM_STEPS):
t = 1.0 + step * dt
suffix, s_pads, s_atts = self.embed_suffix(state32, x_t, [t] * x_t.shape[0])
L = s_pads.shape[1]
mask_full = mx.concatenate(
[mx.broadcast_to(pads[:, None, :], (s_pads.shape[0], L, P)),
make_att_2d_masks(s_pads, s_atts)], axis=2)
pos_s = offset + mx.cumsum(s_pads.astype(mx.int32), axis=1) - 1
h = suffix
for i in range(N_LAYERS):
h = self._layer(self.exp, i, h, mask_full, pos_s, cache=cache, fill=False)
h = rms_norm_gemma(h, self.w[self.P_EXP + "norm.weight"])
out = h[:, -CHUNK:].astype(mx.float32)
v_t = out @ self.w["model.action_out_proj.weight"].T + self.w["model.action_out_proj.bias"]
x_t = x_t + dt * v_t
mx.eval(x_t)
return x_t
def predict(self, images, instruction, state, action_dim=None, noise=None):
imgs224 = []
for im in images:
arr = np.asarray(im).astype(np.float32)
if arr.max() > 1.5:
arr = arr / 255.0
hwc = resize_with_pad(np.transpose(arr, (2, 0, 1))) * 2.0 - 1.0
imgs224.append(mx.array(hwc[None]))
enc = self.tokenizer(instruction.rstrip("\n") + "\n", padding="max_length",
max_length=48, return_tensors="np")
state = np.asarray(state, dtype=np.float32)[None]
action_dim = action_dim or state.shape[1]
state32 = np.zeros((1, MAX_DIM), dtype=np.float32)
state32[:, : state.shape[1]] = state
chunk = self.sample_actions(imgs224, mx.array(enc["input_ids"]),
mx.array(enc["attention_mask"]).astype(mx.bool_),
mx.array(state32), noise=noise)
return np.array(chunk[0, :, :action_dim])
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--images", nargs="+", required=True)
ap.add_argument("--instruction", required=True)
ap.add_argument("--state", default="0,0,0,0,0,0")
ap.add_argument("--out", default="actions.npy")
args = ap.parse_args()
from PIL import Image
model = Pi0MLX.from_pretrained(Path(__file__).parent)
imgs = [np.asarray(Image.open(p).convert("RGB")) for p in args.images]
state = [float(x) for x in args.state.split(",")]
actions = model.predict(imgs, args.instruction, state)
np.save(args.out, actions)
print(f"action chunk {actions.shape} -> {args.out}")
print("first action:", actions[0].round(4))