tokimoa commited on
Commit
5cc2511
·
verified ·
1 Parent(s): 2969d13

pi0-mlx: MLX port of pi0 (parity cos 0.99993 bf16, 522ms/chunk, 8GB peak) + runtime + card

Browse files
README.md ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: lerobot/pi0
4
+ pipeline_tag: robotics
5
+ tags:
6
+ - mlx
7
+ - robotics
8
+ - vla
9
+ - lerobot
10
+ - pi0
11
+ ---
12
+
13
+ # pi0-mlx
14
+
15
+ Physical Intelligenceのロボット基盤モデル [π0](https://huggingface.co/lerobot/pi0)(PaliGemma 3B + アクションエキスパート・Vision-Language-Action)のApple Silicon(MLX)移植です。カメラ画像・言語指示・関節状態から50手先までのアクションチャンクをflow matchingで生成します。
16
+
17
+ | 実行系 | 1チャンク(50手)生成 | ピークメモリ |
18
+ |---|---|---|
19
+ | **本移植(MLX・bf16)** | **522ms** | 8.0GB |
20
+ | PyTorch MPS(参照実装) | 660ms | |
21
+ | PyTorch CPU(参照実装) | 2,395ms | |
22
+
23
+ 同一入力・同一ノイズでPyTorch参照実装(lerobot main / openpi直系)とコサイン類似度0.99993(bf16、fp32重みでは1.00000)の出力一致を検証済みです。プレフィル→エキスパートがキャッシュへ毎層アテンションする二相構造、Gemma固有の正規化((1+w)RMSNorm)・GeGLU・言語埋め込みの√widthスケーリングまで参照実装を忠実に再現しています。
24
+
25
+ ## 使い方
26
+
27
+ トークナイザは上流(lerobot)と同じくgoogle/paligemma-3b-pt-224を参照します。Hugging FaceでGemma利用規約に同意し、`hf auth login`してから実行してください。
28
+
29
+ ```bash
30
+ pip install mlx-vlm pillow transformers
31
+ hf download tokimoa/pi0-mlx --local-dir pi0-mlx
32
+ ```
33
+
34
+ ```python
35
+ from pi0_mlx import Pi0MLX
36
+
37
+ model = Pi0MLX.from_pretrained("pi0-mlx")
38
+ actions = model.predict(
39
+ images=[cam0, cam1, cam2], # HWC uint8(1〜3カメラ)
40
+ instruction="pick up the cube",
41
+ state=[0.1, -0.2, 0.3, 0.0, 0.5, 0.0],
42
+ ) # -> (50, len(state)) アクションチャンク
43
+ ```
44
+
45
+ CLIでも動きます。
46
+
47
+ ```bash
48
+ python pi0-mlx/pi0_mlx.py --images cam0.png cam1.png cam2.png \
49
+ --instruction "pick up the cube" --state 0,0,0,0,0,0
50
+ ```
51
+
52
+ ## 位置づけ
53
+
54
+ π0はベースモデルであり、実タスクへの適用には手元のロボットでのファインチューニングが前提です。学習は[LeRobot](https://github.com/huggingface/lerobot)で行い、Mac上での推論・検証・デモに本移植を使う構成を想定しています。同一アーキテクチャのFT済み重みは`model.safetensors`を差し替えれば動きます。前処理(224pxアスペクト維持リサイズ・言語トークナイズ・状態パディング)はランタイムに内蔵しています。
55
+
56
+ 同シリーズ: [smolvla-mlx](https://huggingface.co/tokimoa/smolvla-mlx)(450M・軽量版のVLA移植)
57
+
58
+ ## ライセンス
59
+
60
+ Apache-2.0(ベースモデルlerobot/pi0のライセンスを継承)
61
+
62
+ ---
63
+ Developed by [tokimoa](https://tokimoa.jp)
__pycache__/pi0_mlx.cpython-311.pyc ADDED
Binary file (24.1 kB). View file
 
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:af62ab4f208e0263a1cd4b5e5e166cd08e68c56f3d936d4153085428496c6cee
3
+ size 7827762546
pi0_mlx.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """pi0 (Physical Intelligence / lerobot) inference runtime for Apple Silicon / MLX.
2
+
3
+ Faithful port of the lerobot (openpi-derived) PI0Pytorch reference:
4
+ - prefill: PaliGemma LM (18 layers, bidirectional prefix) with RoPE-applied KV cache
5
+ - 10 Euler flow-matching steps; the 300M Gemma expert attends to the cached
6
+ prefix KV at every layer (head_dim 256 / 1 KV head on both sides)
7
+ - Gemma specifics: (1+w) RMSNorm, GeGLU (tanh), rope base 10000, language
8
+ embeddings scaled by sqrt(width); attention/softmax in fp32
9
+ - verified against the PyTorch reference: cosine 1.00000 on full action chunks
10
+
11
+ Usage:
12
+ from pi0_mlx import Pi0MLX
13
+ m = Pi0MLX.from_pretrained(".")
14
+ actions = m.predict(images=[cam0, cam1, cam2],
15
+ instruction="pick up the cube", state=[...])
16
+
17
+ Note: the tokenizer is loaded from google/paligemma-3b-pt-224 (gated; accept
18
+ the Gemma terms on Hugging Face and login first), matching upstream lerobot.
19
+ """
20
+ import json
21
+ import math
22
+ from pathlib import Path
23
+
24
+ import mlx.core as mx
25
+ import mlx.nn as nn
26
+ import numpy as np
27
+
28
+ from mlx_vlm.models.paligemma.vision import VisionModel
29
+ from mlx_vlm.models.paligemma import VisionConfig
30
+
31
+ CHUNK = 50
32
+ MAX_DIM = 32
33
+ NUM_STEPS = 10
34
+ MIN_PERIOD, MAX_PERIOD = 4e-3, 4.0
35
+ N_LAYERS = 18
36
+ HID = 2048
37
+ EXP_HID = 1024
38
+ N_HEADS = 8
39
+ N_KV = 1
40
+ HEAD_DIM = 256
41
+ EPS = 1e-6
42
+ EMBED_SCALE = HID ** 0.5
43
+ IMG_SIZE = 224
44
+ VIS_CFG = {"model_type": "siglip_vision_model", "hidden_size": 1152, "num_hidden_layers": 27,
45
+ "intermediate_size": 4304, "num_attention_heads": 16, "image_size": 224,
46
+ "patch_size": 14, "num_channels": 3, "layer_norm_eps": 1e-6}
47
+
48
+
49
+ def rms_norm_gemma(x, w):
50
+ x32 = x.astype(mx.float32)
51
+ out = x32 * mx.rsqrt(mx.mean(x32 * x32, axis=-1, keepdims=True) + EPS)
52
+ return (out * (1.0 + w.astype(mx.float32))).astype(x.dtype)
53
+
54
+
55
+ def apply_rope(x, positions, base=10000.0):
56
+ d_half = x.shape[-1] // 2
57
+ dtype = x.dtype
58
+ x = x.astype(mx.float32)
59
+ freq_exp = (2.0 / x.shape[-1]) * mx.arange(d_half, dtype=mx.float32)
60
+ timescale = mx.power(base, freq_exp)
61
+ radians = positions[..., None].astype(mx.float32) / timescale[None, None, :]
62
+ radians = radians[..., None, :]
63
+ sin, cos = mx.sin(radians), mx.cos(radians)
64
+ x1, x2 = x[..., :d_half], x[..., d_half:]
65
+ return mx.concatenate([x1 * cos - x2 * sin, x2 * cos + x1 * sin], axis=-1).astype(dtype)
66
+
67
+
68
+ def make_att_2d_masks(pad_masks, att_masks):
69
+ cs = mx.cumsum(att_masks.astype(mx.int32), axis=1)
70
+ return (cs[:, None, :] <= cs[:, :, None]) & pad_masks[:, None, :]
71
+
72
+
73
+ def sinusoidal_time_emb(time, dim):
74
+ frac = np.linspace(0.0, 1.0, dim // 2, dtype=np.float64)
75
+ period = MIN_PERIOD * (MAX_PERIOD / MIN_PERIOD) ** frac
76
+ scale = 1.0 / period * 2 * np.pi
77
+ sin_in = scale[None, :] * np.asarray(time, dtype=np.float64)[:, None]
78
+ return mx.array(np.concatenate([np.sin(sin_in), np.cos(sin_in)], axis=1).astype(np.float32))
79
+
80
+
81
+ def resize_with_pad(img_chw, size=IMG_SIZE):
82
+ """参照実装同等: アスペクト維持バイリニア縮小+左・上ゼロパディング。"""
83
+ from PIL import Image
84
+
85
+ c, h, w = img_chw.shape
86
+ ratio = max(w / size, h / size)
87
+ rh, rw = int(h / ratio), int(w / ratio)
88
+ pil = Image.fromarray((np.transpose(img_chw, (1, 2, 0)) * 255).clip(0, 255).astype(np.uint8))
89
+ pil = pil.resize((rw, rh), Image.BILINEAR)
90
+ arr = np.asarray(pil).astype(np.float32) / 255.0
91
+ out = np.zeros((size, size, 3), dtype=np.float32)
92
+ out[size - rh:, size - rw:, :] = arr
93
+ return out
94
+
95
+
96
+ class Pi0MLX:
97
+ P_LM = "model.paligemma_with_expert.paligemma.model.language_model."
98
+ P_EXP = "model.paligemma_with_expert.gemma_expert.model."
99
+ P_VIS = "model.paligemma_with_expert.paligemma.model.vision_tower."
100
+ P_PROJ = "model.paligemma_with_expert.paligemma.model.multi_modal_projector."
101
+ P_EMB = "model.paligemma_with_expert.paligemma.lm_head.weight" # tied weights
102
+
103
+ def __init__(self, weights):
104
+ self.w = weights
105
+ self.vision = VisionModel(VisionConfig(**VIS_CFG))
106
+ vis_w = {}
107
+ for k, v in weights.items():
108
+ if k.startswith(self.P_VIS):
109
+ kk = k[len(self.P_VIS):]
110
+ if kk.endswith("patch_embedding.weight") and v.shape[-1] != 3:
111
+ v = v.transpose(0, 2, 3, 1)
112
+ vis_w[kk] = v
113
+ self.vision.load_weights(list(vis_w.items()), strict=False)
114
+ mx.eval(self.vision.parameters())
115
+
116
+ @classmethod
117
+ def from_pretrained(cls, path):
118
+ path = Path(path)
119
+ m = cls(mx.load(str(path / "model.safetensors")))
120
+ from transformers import AutoTokenizer
121
+
122
+ m.tokenizer = AutoTokenizer.from_pretrained("google/paligemma-3b-pt-224")
123
+ return m
124
+
125
+ def lm(self, i, name):
126
+ return self.w[f"{self.P_LM}layers.{i}.{name}"]
127
+
128
+ def exp(self, i, name):
129
+ return self.w[f"{self.P_EXP}layers.{i}.{name}"]
130
+
131
+ def _attn(self, mask2d, q, k, v):
132
+ B, Lk = k.shape[0], k.shape[1]
133
+ groups = N_HEADS // N_KV
134
+ k = mx.repeat(k[:, :, :, None, :], groups, axis=3).reshape(B, Lk, N_HEADS, HEAD_DIM)
135
+ v = mx.repeat(v[:, :, :, None, :], groups, axis=3).reshape(B, Lk, N_HEADS, HEAD_DIM)
136
+ q32 = q.astype(mx.float32).transpose(0, 2, 1, 3)
137
+ k32 = k.astype(mx.float32).transpose(0, 2, 1, 3)
138
+ att = (q32 @ k32.transpose(0, 1, 3, 2)) * (HEAD_DIM ** -0.5)
139
+ att = mx.where(mask2d[:, None, :, :], att, mx.finfo(mx.float32).min)
140
+ probs = mx.softmax(att, axis=-1).astype(v.dtype)
141
+ out = probs @ v.transpose(0, 2, 1, 3)
142
+ return out.transpose(0, 2, 1, 3).reshape(B, -1, N_HEADS * HEAD_DIM)
143
+
144
+ def _layer(self, get, i, h, mask2d, pos, cache=None, fill=False):
145
+ hn = rms_norm_gemma(h, get(i, "input_layernorm.weight"))
146
+ B, L = hn.shape[:2]
147
+ q = (hn @ get(i, "self_attn.q_proj.weight").T).reshape(B, L, -1, HEAD_DIM)
148
+ k = (hn @ get(i, "self_attn.k_proj.weight").T).reshape(B, L, -1, HEAD_DIM)
149
+ v = (hn @ get(i, "self_attn.v_proj.weight").T).reshape(B, L, -1, HEAD_DIM)
150
+ q = apply_rope(q, pos)
151
+ k = apply_rope(k, pos)
152
+ if fill:
153
+ cache[i] = (k, v)
154
+ elif cache is not None:
155
+ k = mx.concatenate([cache[i][0], k], axis=1)
156
+ v = mx.concatenate([cache[i][1], v], axis=1)
157
+ att = self._attn(mask2d, q, k, v)
158
+ out = att @ get(i, "self_attn.o_proj.weight").T + h
159
+ res = out
160
+ on = rms_norm_gemma(out, get(i, "post_attention_layernorm.weight"))
161
+ gate = on @ get(i, "mlp.gate_proj.weight").T
162
+ up = on @ get(i, "mlp.up_proj.weight").T
163
+ return (nn.gelu_approx(gate) * up) @ get(i, "mlp.down_proj.weight").T + res
164
+
165
+ def embed_prefix(self, imgs224, tokens, lang_mask):
166
+ embs, pads, atts = [], [], []
167
+ vdtype = self.vision.vision_model.embeddings.patch_embedding.weight.dtype
168
+ for img in imgs224: # [1,224,224,3] in [-1,1]
169
+ feat = self.vision(img.astype(vdtype))
170
+ feat = feat[0] if isinstance(feat, tuple) else feat
171
+ if feat.ndim == 2:
172
+ feat = feat[None]
173
+ feat = feat @ self.w[self.P_PROJ + "linear.weight"].T + self.w[self.P_PROJ + "linear.bias"]
174
+ embs.append(feat.astype(mx.bfloat16))
175
+ pads.append(mx.ones(feat.shape[:2], dtype=mx.bool_))
176
+ atts += [0] * feat.shape[1]
177
+ lang = (self.w[self.P_EMB][tokens].astype(mx.float32) * EMBED_SCALE).astype(mx.bfloat16)
178
+ embs.append(lang)
179
+ pads.append(lang_mask.astype(mx.bool_))
180
+ atts += [0] * lang.shape[1]
181
+ embs = mx.concatenate(embs, axis=1)
182
+ pads = mx.concatenate(pads, axis=1)
183
+ atts = mx.array(atts, dtype=mx.int32)[None, :]
184
+ return embs, pads, mx.broadcast_to(atts, (embs.shape[0], atts.shape[1]))
185
+
186
+ def embed_suffix(self, state32, x_t, time):
187
+ state_emb = state32 @ self.w["model.state_proj.weight"].T + self.w["model.state_proj.bias"]
188
+ act = x_t @ self.w["model.action_in_proj.weight"].T + self.w["model.action_in_proj.bias"]
189
+ t_emb = sinusoidal_time_emb(time, EXP_HID).astype(act.dtype)
190
+ at = mx.concatenate([act, mx.broadcast_to(t_emb[:, None, :], act.shape)], axis=2)
191
+ at = at @ self.w["model.action_time_mlp_in.weight"].T + self.w["model.action_time_mlp_in.bias"]
192
+ at = nn.silu(at)
193
+ at = at @ self.w["model.action_time_mlp_out.weight"].T + self.w["model.action_time_mlp_out.bias"]
194
+ embs = mx.concatenate([state_emb[:, None, :], at], axis=1).astype(mx.bfloat16)
195
+ pads = mx.ones(embs.shape[:2], dtype=mx.bool_)
196
+ atts = mx.array([1, 1] + [0] * (CHUNK - 1), dtype=mx.int32)[None, :]
197
+ return embs, pads, mx.broadcast_to(atts, (embs.shape[0], atts.shape[1]))
198
+
199
+ def sample_actions(self, imgs224, tokens, lang_mask, state32, noise=None):
200
+ if noise is None:
201
+ noise = mx.random.normal((state32.shape[0], CHUNK, MAX_DIM))
202
+ prefix, pads, atts = self.embed_prefix(imgs224, tokens, lang_mask)
203
+ mask2d = make_att_2d_masks(pads, atts)
204
+ pos = mx.cumsum(pads.astype(mx.int32), axis=1) - 1
205
+ cache = {}
206
+ h = prefix
207
+ for i in range(N_LAYERS):
208
+ h = self._layer(self.lm, i, h, mask2d, pos, cache=cache, fill=True)
209
+ P = pads.shape[1]
210
+ offset = mx.sum(pads.astype(mx.int32), axis=-1)[:, None]
211
+ x_t = noise
212
+ dt = -1.0 / NUM_STEPS
213
+ for step in range(NUM_STEPS):
214
+ t = 1.0 + step * dt
215
+ suffix, s_pads, s_atts = self.embed_suffix(state32, x_t, [t] * x_t.shape[0])
216
+ L = s_pads.shape[1]
217
+ mask_full = mx.concatenate(
218
+ [mx.broadcast_to(pads[:, None, :], (s_pads.shape[0], L, P)),
219
+ make_att_2d_masks(s_pads, s_atts)], axis=2)
220
+ pos_s = offset + mx.cumsum(s_pads.astype(mx.int32), axis=1) - 1
221
+ h = suffix
222
+ for i in range(N_LAYERS):
223
+ h = self._layer(self.exp, i, h, mask_full, pos_s, cache=cache, fill=False)
224
+ h = rms_norm_gemma(h, self.w[self.P_EXP + "norm.weight"])
225
+ out = h[:, -CHUNK:].astype(mx.float32)
226
+ v_t = out @ self.w["model.action_out_proj.weight"].T + self.w["model.action_out_proj.bias"]
227
+ x_t = x_t + dt * v_t
228
+ mx.eval(x_t)
229
+ return x_t
230
+
231
+ def predict(self, images, instruction, state, action_dim=None, noise=None):
232
+ imgs224 = []
233
+ for im in images:
234
+ arr = np.asarray(im).astype(np.float32)
235
+ if arr.max() > 1.5:
236
+ arr = arr / 255.0
237
+ hwc = resize_with_pad(np.transpose(arr, (2, 0, 1))) * 2.0 - 1.0
238
+ imgs224.append(mx.array(hwc[None]))
239
+ enc = self.tokenizer(instruction.rstrip("\n") + "\n", padding="max_length",
240
+ max_length=48, return_tensors="np")
241
+ state = np.asarray(state, dtype=np.float32)[None]
242
+ action_dim = action_dim or state.shape[1]
243
+ state32 = np.zeros((1, MAX_DIM), dtype=np.float32)
244
+ state32[:, : state.shape[1]] = state
245
+ chunk = self.sample_actions(imgs224, mx.array(enc["input_ids"]),
246
+ mx.array(enc["attention_mask"]).astype(mx.bool_),
247
+ mx.array(state32), noise=noise)
248
+ return np.array(chunk[0, :, :action_dim])
249
+
250
+
251
+ if __name__ == "__main__":
252
+ import argparse
253
+
254
+ ap = argparse.ArgumentParser()
255
+ ap.add_argument("--images", nargs="+", required=True)
256
+ ap.add_argument("--instruction", required=True)
257
+ ap.add_argument("--state", default="0,0,0,0,0,0")
258
+ ap.add_argument("--out", default="actions.npy")
259
+ args = ap.parse_args()
260
+ from PIL import Image
261
+
262
+ model = Pi0MLX.from_pretrained(Path(__file__).parent)
263
+ imgs = [np.asarray(Image.open(p).convert("RGB")) for p in args.images]
264
+ state = [float(x) for x in args.state.split(",")]
265
+ actions = model.predict(imgs, args.instruction, state)
266
+ np.save(args.out, actions)
267
+ print(f"action chunk {actions.shape} -> {args.out}")
268
+ print("first action:", actions[0].round(4))