944 lines
42 KiB
Python
944 lines
42 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||||
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|||
|
|
"""Inference-only π0 (Pi-Zero) VLA math kernel for vllm-omni.
|
|||
|
|
|
|||
|
|
A self-contained inference kernel: only the math that turns a robot observation
|
|||
|
|
into an action chunk, with no serving/request glue — validated bit-for-bit
|
|||
|
|
against the LeRobot ``PI0Policy`` reference (``max|Δ| < 1e-4``).
|
|||
|
|
|
|||
|
|
π0 = PaliGemma (SigLIP vision + Gemma 2B LM) prefix + Gemma 300M action expert
|
|||
|
|
suffix + flow-matching head. Inference:
|
|||
|
|
|
|||
|
|
1. Embed prefix (images + language) → bidirectional prefix tokens.
|
|||
|
|
2. Forward prefix through PaliGemma → a per-layer ``list[(k, v)]`` KV cache.
|
|||
|
|
3. For each Euler step ``t = 1.0, 1-dt, ..., 0``: embed the suffix
|
|||
|
|
(state + noisy actions + timestep), run the action expert with cross
|
|||
|
|
attention over the cached prefix K/V, predict velocity ``v_t``, and
|
|||
|
|
integrate ``x_t = x_t + dt * v_t``.
|
|||
|
|
4. Return ``x_0`` as the action chunk ``(batch, action_horizon, action_dim)``.
|
|||
|
|
|
|||
|
|
We walk Gemma decoder layers ourselves in ``_compute_layer_*`` so we never go
|
|||
|
|
through ``GemmaModel.forward`` (which has version-dependent mask/KV-cache
|
|||
|
|
behaviour). Targets modern transformers (PaliGemmaForConditionalGeneration with
|
|||
|
|
an inner PaliGemmaModel + DynamicCache).
|
|||
|
|
|
|||
|
|
Reference implementations:
|
|||
|
|
- OpenPI: openpi/src/openpi/models_pytorch/pi0_pytorch.py, gemma_pytorch.py
|
|||
|
|
- LeRobot: lerobot/src/lerobot/policies/pi0/modeling_pi0.py
|
|||
|
|
Weight source: https://huggingface.co/lerobot/pi0_base
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
import math
|
|||
|
|
from collections.abc import Iterable
|
|||
|
|
|
|||
|
|
import torch
|
|||
|
|
import torch.nn as nn
|
|||
|
|
import torch.nn.functional as F
|
|||
|
|
from transformers.models.auto import CONFIG_MAPPING
|
|||
|
|
from transformers.models.gemma.modeling_gemma import (
|
|||
|
|
GemmaForCausalLM,
|
|||
|
|
apply_rotary_pos_emb,
|
|||
|
|
)
|
|||
|
|
from transformers.models.paligemma.modeling_paligemma import (
|
|||
|
|
PaliGemmaForConditionalGeneration,
|
|||
|
|
)
|
|||
|
|
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
# ──────────────────────────────────────────────────────────────────────
|
|||
|
|
# Constants
|
|||
|
|
# ──────────────────────────────────────────────────────────────────────
|
|||
|
|
DEFAULT_ACTION_DIM = 32
|
|||
|
|
DEFAULT_ACTION_HORIZON = 50
|
|||
|
|
DEFAULT_MAX_TOKEN_LEN = 48
|
|||
|
|
DEFAULT_NUM_INFERENCE_STEPS = 10
|
|||
|
|
DEFAULT_IMAGE_RESOLUTION = (224, 224)
|
|||
|
|
|
|||
|
|
# Large negative value to fill masked-out positions in a float attention mask.
|
|||
|
|
# Matches OpenPI's constant exactly so that numerics line up during parity.
|
|||
|
|
# Ref: openpi/src/openpi/models/gemma.py
|
|||
|
|
OPENPI_ATTENTION_MASK_VALUE = -2.3819763e38
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ──────────────────────────────────────────────────────────────────────
|
|||
|
|
# Gemma variant configs (matches openpi/models/gemma.py get_config)
|
|||
|
|
# ──────────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
class GemmaVariantConfig:
|
|||
|
|
def __init__(self, width, depth, mlp_dim, num_heads, num_kv_heads, head_dim):
|
|||
|
|
self.width = width
|
|||
|
|
self.depth = depth
|
|||
|
|
self.mlp_dim = mlp_dim
|
|||
|
|
self.num_heads = num_heads
|
|||
|
|
self.num_kv_heads = num_kv_heads
|
|||
|
|
self.head_dim = head_dim
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_gemma_config(variant: str) -> GemmaVariantConfig:
|
|||
|
|
if variant == "gemma_2b":
|
|||
|
|
return GemmaVariantConfig(2048, 18, 16384, 8, 1, 256)
|
|||
|
|
elif variant == "gemma_300m":
|
|||
|
|
return GemmaVariantConfig(1024, 18, 4096, 8, 1, 256)
|
|||
|
|
else:
|
|||
|
|
raise ValueError(f"Unknown variant: {variant}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ──────────────────────────────────────────────────────────────────────
|
|||
|
|
# Utility functions (match openpi/models_pytorch/pi0_pytorch.py)
|
|||
|
|
# ──────────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_sinusoidal_pos_embedding(
|
|||
|
|
time: torch.Tensor,
|
|||
|
|
dimension: int,
|
|||
|
|
min_period: float = 4e-3,
|
|||
|
|
max_period: float = 4.0,
|
|||
|
|
device: torch.device = None,
|
|||
|
|
) -> torch.Tensor:
|
|||
|
|
"""Compute a sine/cosine positional embedding for scalar timesteps.
|
|||
|
|
|
|||
|
|
Ref: openpi/models_pytorch/pi0_pytorch.py create_sinusoidal_pos_embedding
|
|||
|
|
"""
|
|||
|
|
if dimension % 2 != 0:
|
|||
|
|
raise ValueError(f"dimension ({dimension}) must be divisible by 2")
|
|||
|
|
if time.ndim != 1:
|
|||
|
|
raise ValueError("time tensor must be 1-D (batch_size,)")
|
|||
|
|
if device is None:
|
|||
|
|
device = time.device
|
|||
|
|
|
|||
|
|
# Use float64 for the log-linear sweep and for the inner products, to
|
|||
|
|
# match the reference implementation's numerical behaviour exactly.
|
|||
|
|
dtype = torch.float64
|
|||
|
|
fraction = torch.linspace(0.0, 1.0, dimension // 2, dtype=dtype, device=device)
|
|||
|
|
period = min_period * (max_period / min_period) ** fraction
|
|||
|
|
scaling_factor = 1.0 / period * 2 * math.pi
|
|||
|
|
sin_input = scaling_factor[None, :] * time[:, None].to(dtype)
|
|||
|
|
return torch.cat([torch.sin(sin_input), torch.cos(sin_input)], dim=1)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def make_att_2d_masks(pad_masks: torch.Tensor, att_masks: torch.Tensor) -> torch.Tensor:
|
|||
|
|
"""Build a 2D attention mask from a padding mask and an autoregressive mask.
|
|||
|
|
|
|||
|
|
Ref: openpi/models_pytorch/pi0_pytorch.py make_att_2d_masks
|
|||
|
|
"""
|
|||
|
|
if att_masks.ndim != 2:
|
|||
|
|
raise ValueError(f"att_masks must be 2-D, got {att_masks.ndim}-D")
|
|||
|
|
if pad_masks.ndim != 2:
|
|||
|
|
raise ValueError(f"pad_masks must be 2-D, got {pad_masks.ndim}-D")
|
|||
|
|
|
|||
|
|
cumsum = torch.cumsum(att_masks, dim=1)
|
|||
|
|
att_2d_masks = cumsum[:, None, :] <= cumsum[:, :, None]
|
|||
|
|
pad_2d_masks = pad_masks[:, None, :] * pad_masks[:, :, None]
|
|||
|
|
return att_2d_masks & pad_2d_masks
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_norm_buffers(norm_stats: dict | None, key: str) -> dict[str, torch.Tensor] | None:
|
|||
|
|
"""Parse a ``norm_stats[key]`` entry into CPU tensors, or ``None``.
|
|||
|
|
|
|||
|
|
Matches LeRobot's ``NormalizationMode``:
|
|||
|
|
- ``mean_std`` : ``forward = (x - mean) / std``, ``inverse = x * std + mean``
|
|||
|
|
- ``min_max`` : forward maps into ``[-1, 1]``, inverse maps back.
|
|||
|
|
"""
|
|||
|
|
if not norm_stats or not isinstance(norm_stats, dict):
|
|||
|
|
return None
|
|||
|
|
entry = norm_stats.get(key)
|
|||
|
|
if not entry:
|
|||
|
|
return None
|
|||
|
|
mode = str(entry.get("mode", "mean_std")).lower()
|
|||
|
|
if mode == "mean_std":
|
|||
|
|
mean = entry.get("mean")
|
|||
|
|
std = entry.get("std")
|
|||
|
|
if mean is None or std is None:
|
|||
|
|
return None
|
|||
|
|
return {
|
|||
|
|
"mode": mode,
|
|||
|
|
"mean": torch.as_tensor(mean, dtype=torch.float32),
|
|||
|
|
"std": torch.as_tensor(std, dtype=torch.float32),
|
|||
|
|
}
|
|||
|
|
if mode == "min_max":
|
|||
|
|
lo = entry.get("min")
|
|||
|
|
hi = entry.get("max")
|
|||
|
|
if lo is None or hi is None:
|
|||
|
|
return None
|
|||
|
|
return {
|
|||
|
|
"mode": mode,
|
|||
|
|
"min": torch.as_tensor(lo, dtype=torch.float32),
|
|||
|
|
"max": torch.as_tensor(hi, dtype=torch.float32),
|
|||
|
|
}
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _apply_norm(
|
|||
|
|
x: torch.Tensor,
|
|||
|
|
stats: dict[str, torch.Tensor] | None,
|
|||
|
|
inverse: bool,
|
|||
|
|
eps: float = 1e-8,
|
|||
|
|
) -> torch.Tensor:
|
|||
|
|
"""Apply (un)normalization using the given stats. No-op if ``stats`` is None.
|
|||
|
|
|
|||
|
|
Broadcasts over leading dims; stats vectors align with the last dim. When
|
|||
|
|
``x`` has more last-dim entries than the stats (π0 pads state/action to
|
|||
|
|
``max_dim``), only the first ``len(stats)`` entries are transformed and
|
|||
|
|
the padded tail is left untouched.
|
|||
|
|
"""
|
|||
|
|
if stats is None:
|
|||
|
|
return x
|
|||
|
|
mode = stats["mode"]
|
|||
|
|
if mode == "mean_std":
|
|||
|
|
mean = stats["mean"].to(device=x.device, dtype=x.dtype)
|
|||
|
|
std = stats["std"].to(device=x.device, dtype=x.dtype)
|
|||
|
|
valid = mean.shape[0]
|
|||
|
|
head = x[..., :valid]
|
|||
|
|
if inverse:
|
|||
|
|
head = head * std + mean
|
|||
|
|
else:
|
|||
|
|
head = (head - mean) / (std + eps)
|
|||
|
|
if valid == x.shape[-1]:
|
|||
|
|
return head
|
|||
|
|
return torch.cat([head, x[..., valid:]], dim=-1)
|
|||
|
|
if mode == "min_max":
|
|||
|
|
lo = stats["min"].to(device=x.device, dtype=x.dtype)
|
|||
|
|
hi = stats["max"].to(device=x.device, dtype=x.dtype)
|
|||
|
|
valid = lo.shape[0]
|
|||
|
|
head = x[..., :valid]
|
|||
|
|
denom = (hi - lo).clamp_min(eps)
|
|||
|
|
if inverse:
|
|||
|
|
head = (head + 1.0) * 0.5 * denom + lo
|
|||
|
|
else:
|
|||
|
|
head = 2.0 * (head - lo) / denom - 1.0
|
|||
|
|
if valid == x.shape[-1]:
|
|||
|
|
return head
|
|||
|
|
return torch.cat([head, x[..., valid:]], dim=-1)
|
|||
|
|
return x
|
|||
|
|
|
|||
|
|
|
|||
|
|
def prepare_attention_masks_4d(att_2d_masks: torch.Tensor) -> torch.Tensor:
|
|||
|
|
"""Convert ``(B, S, S)`` bool masks to ``(B, 1, S, S)`` float masks.
|
|||
|
|
|
|||
|
|
``True`` → 0.0 (attend), ``False`` → ``OPENPI_ATTENTION_MASK_VALUE``.
|
|||
|
|
Ref: openpi PI0Pytorch._prepare_attention_masks_4d
|
|||
|
|
"""
|
|||
|
|
att_2d_masks_4d = att_2d_masks[:, None, :, :]
|
|||
|
|
return torch.where(att_2d_masks_4d, 0.0, OPENPI_ATTENTION_MASK_VALUE)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ──────────────────────────────────────────────────────────────────────
|
|||
|
|
# Dual-backbone: PaliGemma + Action Expert
|
|||
|
|
# Ref: openpi/models_pytorch/gemma_pytorch.py PaliGemmaWithExpertModel
|
|||
|
|
# Ref: lerobot/policies/pi0/modeling_pi0.py PaliGemmaWithExpertModel
|
|||
|
|
# ──────────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _repeat_kv(hidden_states: torch.Tensor, n_rep: int) -> torch.Tensor:
|
|||
|
|
"""Repeat KV heads for grouped-query attention.
|
|||
|
|
|
|||
|
|
``(B, num_kv_heads, S, D) → (B, num_kv_heads * n_rep, S, D)``.
|
|||
|
|
Matches ``transformers.models.gemma.modeling_gemma.repeat_kv``.
|
|||
|
|
"""
|
|||
|
|
if n_rep == 1:
|
|||
|
|
return hidden_states
|
|||
|
|
b, nh, s, d = hidden_states.shape
|
|||
|
|
hidden_states = hidden_states[:, :, None, :, :].expand(b, nh, n_rep, s, d)
|
|||
|
|
return hidden_states.reshape(b, nh * n_rep, s, d)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _attend(query_states, key_states, value_states, attention_mask, num_kv_groups, scaling):
|
|||
|
|
"""Manual eager attention: ``softmax(Q Kᵀ · scale + mask) · V``.
|
|||
|
|
|
|||
|
|
Important: we slice ``attention_mask[..., :key_states.shape[-2]]`` so that
|
|||
|
|
the same ``(B, 1, Q, prefix+suffix)`` mask produced by ``denoise_step``
|
|||
|
|
can be re-used both in the prefix pass (K length = prefix) and in the
|
|||
|
|
suffix pass (K length = prefix + suffix after we concat the cached prefix).
|
|||
|
|
Stock transformers' ``eager_attention_forward`` adds the mask without
|
|||
|
|
slicing and therefore breaks when the two dims disagree.
|
|||
|
|
"""
|
|||
|
|
k = _repeat_kv(key_states, num_kv_groups)
|
|||
|
|
v = _repeat_kv(value_states, num_kv_groups)
|
|||
|
|
attn_weights = torch.matmul(query_states, k.transpose(2, 3)) * scaling
|
|||
|
|
if attention_mask is not None:
|
|||
|
|
attn_weights = attn_weights + attention_mask[:, :, :, : k.shape[-2]]
|
|||
|
|
attn_weights = F.softmax(attn_weights, dim=-1, dtype=torch.float32).to(query_states.dtype)
|
|||
|
|
return torch.matmul(attn_weights, v)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _compute_layer_prefix_only(layer_idx, hidden_states, attention_mask, position_ids, paligemma):
|
|||
|
|
"""Run one PaliGemma LM layer on the prefix only, returning the layer
|
|||
|
|
output **and** the post-RoPE ``(k, v)`` so the caller can cache them for
|
|||
|
|
the suffix pass."""
|
|||
|
|
model = paligemma.model.language_model
|
|||
|
|
layer = model.layers[layer_idx]
|
|||
|
|
residual = hidden_states
|
|||
|
|
x = layer.input_layernorm(hidden_states)
|
|||
|
|
|
|||
|
|
hidden_shape = (*x.shape[:-1], -1, layer.self_attn.head_dim)
|
|||
|
|
q = layer.self_attn.q_proj(x).view(hidden_shape).transpose(1, 2)
|
|||
|
|
k = layer.self_attn.k_proj(x).view(hidden_shape).transpose(1, 2)
|
|||
|
|
v = layer.self_attn.v_proj(x).view(hidden_shape).transpose(1, 2)
|
|||
|
|
|
|||
|
|
cos, sin = model.rotary_emb(v, position_ids)
|
|||
|
|
q, k = apply_rotary_pos_emb(q, k, cos, sin, unsqueeze_dim=1)
|
|||
|
|
|
|||
|
|
att = _attend(
|
|||
|
|
q,
|
|||
|
|
k,
|
|||
|
|
v,
|
|||
|
|
attention_mask,
|
|||
|
|
num_kv_groups=layer.self_attn.num_key_value_groups,
|
|||
|
|
scaling=1.0 / math.sqrt(layer.self_attn.head_dim),
|
|||
|
|
)
|
|||
|
|
att = att.transpose(1, 2).reshape(q.shape[0], -1, q.shape[1] * layer.self_attn.head_dim)
|
|||
|
|
|
|||
|
|
out = layer.self_attn.o_proj(att) + residual
|
|||
|
|
after_resid = out
|
|||
|
|
out = layer.mlp(layer.post_attention_layernorm(out)) + after_resid
|
|||
|
|
return out, (k, v)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _compute_layer_suffix_only(
|
|||
|
|
layer_idx,
|
|||
|
|
hidden_states,
|
|||
|
|
prefix_kv,
|
|||
|
|
attention_mask,
|
|||
|
|
position_ids,
|
|||
|
|
paligemma,
|
|||
|
|
gemma_expert,
|
|||
|
|
):
|
|||
|
|
"""Run one action-expert layer on the suffix, attending to the cached
|
|||
|
|
prefix K/V concatenated with freshly computed suffix K/V.
|
|||
|
|
|
|||
|
|
``prefix_kv`` is a ``(k_prefix, v_prefix)`` tuple, shape
|
|||
|
|
``(B, num_kv_heads, prefix_len, head_dim)``, post-RoPE, produced by the
|
|||
|
|
corresponding ``_compute_layer_prefix_only`` call. Bypasses
|
|||
|
|
``GemmaModel.forward`` entirely — no HF mask rebuild, no cache format
|
|||
|
|
gymnastics.
|
|||
|
|
"""
|
|||
|
|
layer = gemma_expert.model.layers[layer_idx]
|
|||
|
|
residual = hidden_states
|
|||
|
|
x = layer.input_layernorm(hidden_states)
|
|||
|
|
|
|||
|
|
hidden_shape = (*x.shape[:-1], -1, layer.self_attn.head_dim)
|
|||
|
|
q = layer.self_attn.q_proj(x).view(hidden_shape).transpose(1, 2)
|
|||
|
|
k_suf = layer.self_attn.k_proj(x).view(hidden_shape).transpose(1, 2)
|
|||
|
|
v_suf = layer.self_attn.v_proj(x).view(hidden_shape).transpose(1, 2)
|
|||
|
|
|
|||
|
|
# RoPE frequencies are shared between PaliGemma and the expert.
|
|||
|
|
cos, sin = gemma_expert.model.rotary_emb(v_suf, position_ids)
|
|||
|
|
q, k_suf = apply_rotary_pos_emb(q, k_suf, cos, sin, unsqueeze_dim=1)
|
|||
|
|
|
|||
|
|
# Concatenate cached prefix K/V (possibly different dtype) with suffix K/V.
|
|||
|
|
k_prefix, v_prefix = prefix_kv
|
|||
|
|
k = torch.cat([k_prefix.to(k_suf.dtype), k_suf], dim=2)
|
|||
|
|
v = torch.cat([v_prefix.to(v_suf.dtype), v_suf], dim=2)
|
|||
|
|
|
|||
|
|
att = _attend(
|
|||
|
|
q,
|
|||
|
|
k,
|
|||
|
|
v,
|
|||
|
|
attention_mask,
|
|||
|
|
num_kv_groups=layer.self_attn.num_key_value_groups,
|
|||
|
|
scaling=1.0 / math.sqrt(layer.self_attn.head_dim),
|
|||
|
|
)
|
|||
|
|
att = att.transpose(1, 2).reshape(q.shape[0], -1, q.shape[1] * layer.self_attn.head_dim)
|
|||
|
|
|
|||
|
|
out = layer.self_attn.o_proj(att) + residual
|
|||
|
|
after_resid = out
|
|||
|
|
out = layer.mlp(layer.post_attention_layernorm(out)) + after_resid
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
class PaliGemmaWithActionExpert(nn.Module):
|
|||
|
|
"""Dual-backbone transformer: PaliGemma (Gemma 2B) + Action Expert (Gemma 300M).
|
|||
|
|
|
|||
|
|
``forward`` has two inference modes:
|
|||
|
|
- **prefix_only**: ``inputs_embeds=[prefix, None]`` + ``use_cache=True``
|
|||
|
|
→ compute prefix hidden states + a layer-wise K/V cache.
|
|||
|
|
- **suffix_only**: ``inputs_embeds=[None, suffix]`` + the cache from the
|
|||
|
|
previous prefix pass → compute expert hidden states with cross-attention
|
|||
|
|
over the concatenated (prefix, suffix) K/V.
|
|||
|
|
|
|||
|
|
Both modes walk Gemma decoder layers manually through ``_compute_layer_*``
|
|||
|
|
instead of ``GemmaModel.forward`` — that way we own the attention mask
|
|||
|
|
handling and the KV cache format (a plain ``list[(k, v)]`` one entry
|
|||
|
|
per layer).
|
|||
|
|
|
|||
|
|
Ref: lerobot/policies/pi0/modeling_pi0.py PaliGemmaWithExpertModel
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, vlm_config, action_expert_config):
|
|||
|
|
super().__init__()
|
|||
|
|
|
|||
|
|
# Build HF PaliGemma config from the variant dims we expose.
|
|||
|
|
# NOTE: we do NOT set use_adarms / adarms_cond_dim — those are
|
|||
|
|
# OpenPI-only attributes used by π0.5, not π0.
|
|||
|
|
vlm_config_hf = CONFIG_MAPPING["paligemma"]()
|
|||
|
|
vlm_config_hf._vocab_size = 257152
|
|||
|
|
vlm_config_hf.image_token_index = 257152
|
|||
|
|
vlm_config_hf.text_config.hidden_size = vlm_config.width
|
|||
|
|
vlm_config_hf.text_config.intermediate_size = vlm_config.mlp_dim
|
|||
|
|
vlm_config_hf.text_config.num_attention_heads = vlm_config.num_heads
|
|||
|
|
vlm_config_hf.text_config.head_dim = vlm_config.head_dim
|
|||
|
|
vlm_config_hf.text_config.num_hidden_layers = vlm_config.depth
|
|||
|
|
vlm_config_hf.text_config.num_key_value_heads = vlm_config.num_kv_heads
|
|||
|
|
vlm_config_hf.text_config.hidden_activation = "gelu_pytorch_tanh"
|
|||
|
|
# transformers ≥ 5 uses ``dtype``; older versions still accept
|
|||
|
|
# ``torch_dtype``. We set the canonical name; HF's PretrainedConfig
|
|||
|
|
# normalizes one to the other internally.
|
|||
|
|
vlm_config_hf.text_config.dtype = "float32"
|
|||
|
|
vlm_config_hf.text_config.vocab_size = 257152
|
|||
|
|
vlm_config_hf.vision_config.intermediate_size = 4304
|
|||
|
|
vlm_config_hf.vision_config.projection_dim = 2048
|
|||
|
|
vlm_config_hf.vision_config.projector_hidden_act = "gelu_fast"
|
|||
|
|
vlm_config_hf.vision_config.dtype = "float32"
|
|||
|
|
|
|||
|
|
action_expert_config_hf = CONFIG_MAPPING["gemma"](
|
|||
|
|
head_dim=action_expert_config.head_dim,
|
|||
|
|
hidden_size=action_expert_config.width,
|
|||
|
|
intermediate_size=action_expert_config.mlp_dim,
|
|||
|
|
num_attention_heads=action_expert_config.num_heads,
|
|||
|
|
num_hidden_layers=action_expert_config.depth,
|
|||
|
|
num_key_value_heads=action_expert_config.num_kv_heads,
|
|||
|
|
vocab_size=257152,
|
|||
|
|
hidden_activation="gelu_pytorch_tanh",
|
|||
|
|
dtype="float32",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
self.paligemma = PaliGemmaForConditionalGeneration(config=vlm_config_hf)
|
|||
|
|
self.gemma_expert = GemmaForCausalLM(config=action_expert_config_hf)
|
|||
|
|
# The action expert doesn't embed tokens — it only consumes the
|
|||
|
|
# suffix state/action embeddings we feed in.
|
|||
|
|
self.gemma_expert.model.embed_tokens = None
|
|||
|
|
|
|||
|
|
def embed_image(self, pixel_values: torch.Tensor) -> torch.Tensor:
|
|||
|
|
"""Encode images with SigLIP vision tower + PaliGemma projector.
|
|||
|
|
|
|||
|
|
We run the two steps explicitly instead of calling
|
|||
|
|
``PaliGemmaModel.get_image_features`` because that convenience
|
|||
|
|
method historically has returned either the projected tensor (old)
|
|||
|
|
or the raw vision-tower output (new). Being explicit makes us
|
|||
|
|
independent of which transformers release we're on.
|
|||
|
|
"""
|
|||
|
|
# Shapes: pixel_values (B, 3, 224, 224) → SigLIP (B, 256, 1152)
|
|||
|
|
# → projector (B, 256, 2048)
|
|||
|
|
vision_outputs = self.paligemma.model.vision_tower(pixel_values)
|
|||
|
|
image_features = vision_outputs.last_hidden_state
|
|||
|
|
return self.paligemma.model.multi_modal_projector(image_features)
|
|||
|
|
|
|||
|
|
def embed_language_tokens(self, tokens: torch.Tensor) -> torch.Tensor:
|
|||
|
|
"""Embed language tokens with PaliGemma's embedding table, returning the
|
|||
|
|
``* sqrt(hidden)``-scaled embedding (PaliGemma/Gemma convention).
|
|||
|
|
|
|||
|
|
The scaling location moved across transformers releases:
|
|||
|
|
* ≤ 5.3: ``embed_tokens`` is a plain ``nn.Embedding`` and the
|
|||
|
|
``* sqrt(hidden)`` normalizer lives inside ``GemmaModel.forward``
|
|||
|
|
(which we bypass) — so we apply it explicitly here.
|
|||
|
|
* ≥ 5.4: ``embed_tokens`` is a ``GemmaTextScaledWordEmbedding`` that
|
|||
|
|
self-applies ``embed_scale = hidden_size ** 0.5`` — applying it again
|
|||
|
|
would double-scale (≈45×). We detect this and skip the manual scale.
|
|||
|
|
This makes ``embed_language_tokens`` return the canonical scaled embedding
|
|||
|
|
on every transformers version.
|
|||
|
|
"""
|
|||
|
|
embed_tokens = self.paligemma.model.language_model.embed_tokens
|
|||
|
|
lang_emb = embed_tokens(tokens)
|
|||
|
|
# If the embedding already self-scales (transformers ≥ 5.4), don't repeat it.
|
|||
|
|
if getattr(embed_tokens, "embed_scale", None) is None:
|
|||
|
|
lang_emb = lang_emb * math.sqrt(lang_emb.shape[-1])
|
|||
|
|
return lang_emb
|
|||
|
|
|
|||
|
|
def forward(
|
|||
|
|
self,
|
|||
|
|
attention_mask: torch.Tensor | None = None,
|
|||
|
|
position_ids: torch.LongTensor | None = None,
|
|||
|
|
past_key_values=None,
|
|||
|
|
inputs_embeds: list[torch.Tensor | None] | None = None,
|
|||
|
|
use_cache: bool = False,
|
|||
|
|
):
|
|||
|
|
"""Dispatch to prefix_only / suffix_only and return
|
|||
|
|
``([prefix_out, suffix_out], past_key_values_or_None)``.
|
|||
|
|
"""
|
|||
|
|
num_layers = self.paligemma.config.text_config.num_hidden_layers
|
|||
|
|
pali_lm = self.paligemma.model.language_model
|
|||
|
|
expert_lm = self.gemma_expert.model
|
|||
|
|
|
|||
|
|
if inputs_embeds[1] is None:
|
|||
|
|
# Prefix-only: PaliGemma LM on (images + language) tokens; the
|
|||
|
|
# per-layer post-RoPE K/V is collected into a list that the
|
|||
|
|
# suffix pass will consume directly.
|
|||
|
|
hidden_states = inputs_embeds[0]
|
|||
|
|
kv_list: list[tuple[torch.Tensor, torch.Tensor]] = []
|
|||
|
|
for layer_idx in range(num_layers):
|
|||
|
|
hidden_states, kv = _compute_layer_prefix_only(
|
|||
|
|
layer_idx,
|
|||
|
|
hidden_states,
|
|||
|
|
attention_mask,
|
|||
|
|
position_ids,
|
|||
|
|
paligemma=self.paligemma,
|
|||
|
|
)
|
|||
|
|
kv_list.append(kv)
|
|||
|
|
hidden_states = pali_lm.norm(hidden_states)
|
|||
|
|
return [hidden_states, None], (kv_list if use_cache else None)
|
|||
|
|
|
|||
|
|
if inputs_embeds[0] is not None:
|
|||
|
|
raise ValueError(
|
|||
|
|
"PaliGemmaWithActionExpert.forward only supports prefix-only "
|
|||
|
|
"or suffix-only dispatch; got both inputs_embeds populated."
|
|||
|
|
)
|
|||
|
|
# Suffix-only: action expert on (state + noisy_actions + time),
|
|||
|
|
# attending to the cached prefix K/V.
|
|||
|
|
if not isinstance(past_key_values, list):
|
|||
|
|
raise TypeError(
|
|||
|
|
"suffix_only forward expects past_key_values to be the "
|
|||
|
|
"list[(k, v)] produced by a previous prefix_only forward; "
|
|||
|
|
f"got {type(past_key_values)}"
|
|||
|
|
)
|
|||
|
|
hidden_states = inputs_embeds[1]
|
|||
|
|
for layer_idx in range(num_layers):
|
|||
|
|
hidden_states = _compute_layer_suffix_only(
|
|||
|
|
layer_idx,
|
|||
|
|
hidden_states,
|
|||
|
|
past_key_values[layer_idx],
|
|||
|
|
attention_mask,
|
|||
|
|
position_ids,
|
|||
|
|
paligemma=self.paligemma,
|
|||
|
|
gemma_expert=self.gemma_expert,
|
|||
|
|
)
|
|||
|
|
hidden_states = expert_lm.norm(hidden_states)
|
|||
|
|
return [None, hidden_states], None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ──────────────────────────────────────────────────────────────────────
|
|||
|
|
# Main π0 Model
|
|||
|
|
# ──────────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Pi0ForActionPrediction(nn.Module):
|
|||
|
|
"""π0 VLA model for robot action prediction via flow matching.
|
|||
|
|
|
|||
|
|
Inference flow:
|
|||
|
|
1. Embed prefix (images + language) → prefix tokens.
|
|||
|
|
2. Forward prefix through PaliGemma → layer-wise KV cache.
|
|||
|
|
3. For each denoising step ``t = 1.0, 1-dt, ..., 0``:
|
|||
|
|
a. Embed suffix (state + x_t + timestep) → suffix tokens.
|
|||
|
|
b. Forward suffix through the action expert with the prefix cache.
|
|||
|
|
c. ``x_t = x_t + dt * v_t`` (Euler integration).
|
|||
|
|
4. Return ``x_0`` as the predicted action chunk.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(
|
|||
|
|
self,
|
|||
|
|
config,
|
|||
|
|
quant_config=None,
|
|||
|
|
prefix: str = "",
|
|||
|
|
):
|
|||
|
|
super().__init__()
|
|||
|
|
# ``quant_config`` is accepted for interface compatibility but unused —
|
|||
|
|
# π0 runs in full precision for flow-matching parity.
|
|||
|
|
del quant_config
|
|||
|
|
self.config = config
|
|||
|
|
|
|||
|
|
self.action_dim = getattr(config, "max_action_dim", DEFAULT_ACTION_DIM)
|
|||
|
|
# ``max_state_dim`` is independent from ``max_action_dim`` — e.g. Aloha
|
|||
|
|
# has state_dim=14, action_dim=32, both padded to their own max. Fall
|
|||
|
|
# back to action_dim for older configs that only expose a single size.
|
|||
|
|
self.max_state_dim = getattr(config, "max_state_dim", self.action_dim)
|
|||
|
|
self.action_horizon = getattr(config, "chunk_size", DEFAULT_ACTION_HORIZON)
|
|||
|
|
self.num_inference_steps = getattr(config, "num_inference_steps", DEFAULT_NUM_INFERENCE_STEPS)
|
|||
|
|
|
|||
|
|
paligemma_variant = getattr(config, "paligemma_variant", "gemma_2b")
|
|||
|
|
action_expert_variant = getattr(config, "action_expert_variant", "gemma_300m")
|
|||
|
|
vlm_config = get_gemma_config(paligemma_variant)
|
|||
|
|
expert_config = get_gemma_config(action_expert_variant)
|
|||
|
|
self.vlm_width = vlm_config.width
|
|||
|
|
self.expert_width = expert_config.width
|
|||
|
|
|
|||
|
|
# Dual backbone
|
|||
|
|
self.paligemma_with_expert = PaliGemmaWithActionExpert(vlm_config, expert_config)
|
|||
|
|
|
|||
|
|
# Action chunk projections (openpi pi0_pytorch.py).
|
|||
|
|
self.action_in_proj = nn.Linear(self.action_dim, self.expert_width)
|
|||
|
|
self.action_out_proj = nn.Linear(self.expert_width, self.action_dim)
|
|||
|
|
|
|||
|
|
# State is projected to the expert dim once (π0 only, not π0.5).
|
|||
|
|
# Ref: lerobot modeling_pi0.py ``nn.Linear(config.max_state_dim, ...)``
|
|||
|
|
self.state_proj = nn.Linear(self.max_state_dim, self.expert_width)
|
|||
|
|
|
|||
|
|
# Timestep + action fusion MLP (2W → W → W).
|
|||
|
|
self.action_time_mlp_in = nn.Linear(2 * self.expert_width, self.expert_width)
|
|||
|
|
self.action_time_mlp_out = nn.Linear(self.expert_width, self.expert_width)
|
|||
|
|
|
|||
|
|
# Optional mean/std normalization stats (LeRobot NormalizerProcessorStep
|
|||
|
|
# semantics). Format on the config (all optional):
|
|||
|
|
# norm_stats = {
|
|||
|
|
# "state": {"mode": "mean_std", "mean": [...], "std": [...]},
|
|||
|
|
# "action": {"mode": "mean_std", "mean": [...], "std": [...]},
|
|||
|
|
# }
|
|||
|
|
# ``mode`` may also be ``"min_max"``.
|
|||
|
|
#
|
|||
|
|
# Missing norm_stats is logged at INFO (not WARNING) because the most
|
|||
|
|
# common setup is "client pre-normalizes state and post-normalizes
|
|||
|
|
# actions using dataset stats" — that's a supported mode, not an error.
|
|||
|
|
self._state_norm = _build_norm_buffers(getattr(config, "norm_stats", None), "state")
|
|||
|
|
self._action_norm = _build_norm_buffers(getattr(config, "norm_stats", None), "action")
|
|||
|
|
if self._state_norm is None:
|
|||
|
|
logger.info(
|
|||
|
|
"π0: no state normalization stats on config.norm_stats — state values will pass through unchanged."
|
|||
|
|
)
|
|||
|
|
if self._action_norm is None:
|
|||
|
|
logger.info(
|
|||
|
|
"π0: no action normalization stats on config.norm_stats — "
|
|||
|
|
"returned actions are in the model's normalized space."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ── State / action normalization ─────────────────────────────────
|
|||
|
|
|
|||
|
|
def _normalize_state(self, state: torch.Tensor) -> torch.Tensor:
|
|||
|
|
return _apply_norm(state, self._state_norm, inverse=False)
|
|||
|
|
|
|||
|
|
def _unnormalize_actions(self, actions: torch.Tensor) -> torch.Tensor:
|
|||
|
|
return _apply_norm(actions, self._action_norm, inverse=True)
|
|||
|
|
|
|||
|
|
# ── Prefix embedding ─────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def embed_prefix(
|
|||
|
|
self,
|
|||
|
|
images: list[torch.Tensor],
|
|||
|
|
image_masks: list[torch.Tensor],
|
|||
|
|
lang_tokens: torch.Tensor,
|
|||
|
|
lang_masks: torch.Tensor,
|
|||
|
|
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
|||
|
|
"""Build the prefix embeddings, per-token padding mask, and AR mask.
|
|||
|
|
|
|||
|
|
Prefix tokens form a contiguous sequence
|
|||
|
|
``[img_cam_0..., img_cam_1..., ..., lang_tokens...]`` with
|
|||
|
|
bidirectional attention; the returned ``att_masks`` are all zeros
|
|||
|
|
because each token is free to attend to every other prefix token (the
|
|||
|
|
suffix pass will put a causal boundary right before the state token).
|
|||
|
|
|
|||
|
|
Ref: openpi PI0Pytorch.embed_prefix
|
|||
|
|
"""
|
|||
|
|
embs: list[torch.Tensor] = []
|
|||
|
|
pad_masks: list[torch.Tensor] = []
|
|||
|
|
att_masks: list[int] = []
|
|||
|
|
|
|||
|
|
for img, img_mask in zip(images, image_masks):
|
|||
|
|
img_emb = self.paligemma_with_expert.embed_image(img)
|
|||
|
|
bsize, num_img_embs = img_emb.shape[:2]
|
|||
|
|
embs.append(img_emb)
|
|||
|
|
pad_masks.append(img_mask[:, None].expand(bsize, num_img_embs))
|
|||
|
|
att_masks += [0] * num_img_embs
|
|||
|
|
|
|||
|
|
# ``embed_language_tokens`` already returns the ``* sqrt(hidden)``-scaled
|
|||
|
|
# embedding (version-robust: self-scaled at transformers ≥ 5.4, manually
|
|||
|
|
# scaled at ≤ 5.3). The image slice went through the projector and is
|
|||
|
|
# NOT scaled. See embed_language_tokens for the version details.
|
|||
|
|
lang_emb = self.paligemma_with_expert.embed_language_tokens(lang_tokens)
|
|||
|
|
|
|||
|
|
embs.append(lang_emb)
|
|||
|
|
pad_masks.append(lang_masks)
|
|||
|
|
att_masks += [0] * lang_emb.shape[1]
|
|||
|
|
|
|||
|
|
embs = torch.cat(embs, dim=1)
|
|||
|
|
pad_masks = torch.cat(pad_masks, dim=1)
|
|||
|
|
att_masks = torch.tensor(att_masks, dtype=torch.bool, device=embs.device)
|
|||
|
|
att_masks = att_masks[None, :].expand(pad_masks.shape[0], -1)
|
|||
|
|
|
|||
|
|
return embs, pad_masks, att_masks
|
|||
|
|
|
|||
|
|
# ── Suffix embedding ─────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def embed_suffix(
|
|||
|
|
self,
|
|||
|
|
state: torch.Tensor,
|
|||
|
|
noisy_actions: torch.Tensor,
|
|||
|
|
timestep: torch.Tensor,
|
|||
|
|
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
|||
|
|
"""Build the suffix embeddings + masks: ``[state_token, action_tokens×H]``.
|
|||
|
|
|
|||
|
|
AR mask layout: ``[1, 1, 0, 0, ..., 0]`` — the state token is a causal
|
|||
|
|
boundary (no later token attends backwards through it onto prefix
|
|||
|
|
*by mistake*), the first action token starts a new causal block, and
|
|||
|
|
the rest of the action tokens attend to each other bidirectionally.
|
|||
|
|
|
|||
|
|
Ref: openpi PI0Pytorch.embed_suffix
|
|||
|
|
"""
|
|||
|
|
model_dtype = self.state_proj.weight.dtype
|
|||
|
|
state = state.to(dtype=model_dtype)
|
|||
|
|
noisy_actions = noisy_actions.to(dtype=model_dtype)
|
|||
|
|
device = state.device
|
|||
|
|
|
|||
|
|
# State → (B, 1, W)
|
|||
|
|
state_emb = self.state_proj(state)[:, None, :]
|
|||
|
|
bsize = state_emb.shape[0]
|
|||
|
|
|
|||
|
|
# Sinusoidal timestep → (B, W) → expand across the horizon.
|
|||
|
|
time_emb = create_sinusoidal_pos_embedding(
|
|||
|
|
timestep,
|
|||
|
|
self.action_in_proj.out_features,
|
|||
|
|
min_period=4e-3,
|
|||
|
|
max_period=4.0,
|
|||
|
|
device=device,
|
|||
|
|
).to(dtype=model_dtype)
|
|||
|
|
|
|||
|
|
# Fuse action and time via (2W → W → W) MLP with SiLU.
|
|||
|
|
action_emb = self.action_in_proj(noisy_actions) # (B, H, W)
|
|||
|
|
action_time_emb = torch.cat([action_emb, time_emb[:, None, :].expand_as(action_emb)], dim=2)
|
|||
|
|
action_time_emb = self.action_time_mlp_in(action_time_emb)
|
|||
|
|
action_time_emb = F.silu(action_time_emb)
|
|||
|
|
action_time_emb = self.action_time_mlp_out(action_time_emb)
|
|||
|
|
|
|||
|
|
embs = torch.cat([state_emb, action_time_emb], dim=1)
|
|||
|
|
pad_masks = torch.ones(bsize, embs.shape[1], dtype=torch.bool, device=device)
|
|||
|
|
att_masks = torch.tensor(
|
|||
|
|
[1] + [1] + [0] * (self.action_horizon - 1),
|
|||
|
|
dtype=embs.dtype,
|
|||
|
|
device=device,
|
|||
|
|
)[None, :].expand(bsize, -1)
|
|||
|
|
return embs, pad_masks, att_masks
|
|||
|
|
|
|||
|
|
# ── Denoising step ───────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def denoise_step(
|
|||
|
|
self,
|
|||
|
|
state: torch.Tensor,
|
|||
|
|
prefix_pad_masks: torch.Tensor,
|
|||
|
|
past_key_values,
|
|||
|
|
x_t: torch.Tensor,
|
|||
|
|
timestep: torch.Tensor,
|
|||
|
|
) -> torch.Tensor:
|
|||
|
|
"""Apply one flow-matching denoising step: predict ``v_t`` from ``x_t``.
|
|||
|
|
|
|||
|
|
Uses the prefix KV cache from ``sample_actions`` and only runs the
|
|||
|
|
action expert. Ref: openpi PI0Pytorch.denoise_step.
|
|||
|
|
"""
|
|||
|
|
suffix_embs, suffix_pad_masks, suffix_att_masks = self.embed_suffix(state, x_t, timestep)
|
|||
|
|
|
|||
|
|
# Build the full (B, suffix_len, prefix_len + suffix_len) boolean mask:
|
|||
|
|
# * suffix queries can see every *valid* prefix key (padded camera
|
|||
|
|
# slots and padded language tokens are blocked).
|
|||
|
|
# * within the suffix, the state token is a causal boundary; action
|
|||
|
|
# tokens attend to each other bidirectionally.
|
|||
|
|
batch_size = prefix_pad_masks.shape[0]
|
|||
|
|
suffix_len = suffix_pad_masks.shape[1]
|
|||
|
|
prefix_len = prefix_pad_masks.shape[1]
|
|||
|
|
|
|||
|
|
prefix_pad_2d_masks = prefix_pad_masks[:, None, :].expand(batch_size, suffix_len, prefix_len)
|
|||
|
|
suffix_att_2d_masks = make_att_2d_masks(suffix_pad_masks, suffix_att_masks)
|
|||
|
|
full_att_2d_masks = torch.cat([prefix_pad_2d_masks, suffix_att_2d_masks], dim=2)
|
|||
|
|
|
|||
|
|
# Position IDs continue from where the prefix's last valid token left off.
|
|||
|
|
prefix_offsets = torch.sum(prefix_pad_masks, dim=-1)[:, None]
|
|||
|
|
position_ids = prefix_offsets + torch.cumsum(suffix_pad_masks, dim=1) - 1
|
|||
|
|
|
|||
|
|
full_att_2d_masks_4d = prepare_attention_masks_4d(full_att_2d_masks)
|
|||
|
|
|
|||
|
|
outputs_embeds, _ = self.paligemma_with_expert.forward(
|
|||
|
|
attention_mask=full_att_2d_masks_4d,
|
|||
|
|
position_ids=position_ids,
|
|||
|
|
past_key_values=past_key_values,
|
|||
|
|
inputs_embeds=[None, suffix_embs],
|
|||
|
|
use_cache=False,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Drop the state token, keep only the action tokens, project down.
|
|||
|
|
suffix_out = outputs_embeds[1][:, -self.action_horizon :]
|
|||
|
|
suffix_out = suffix_out.to(dtype=self.action_out_proj.weight.dtype)
|
|||
|
|
return self.action_out_proj(suffix_out)
|
|||
|
|
|
|||
|
|
# ── Full action generation ───────────────────────────────────────
|
|||
|
|
|
|||
|
|
@torch.no_grad()
|
|||
|
|
def sample_actions(
|
|||
|
|
self,
|
|||
|
|
images: list[torch.Tensor],
|
|||
|
|
image_masks: list[torch.Tensor],
|
|||
|
|
lang_tokens: torch.Tensor,
|
|||
|
|
lang_masks: torch.Tensor,
|
|||
|
|
state: torch.Tensor,
|
|||
|
|
noise: torch.Tensor | None = None,
|
|||
|
|
num_steps: int | None = None,
|
|||
|
|
) -> torch.Tensor:
|
|||
|
|
"""Generate an action chunk via iterative flow-matching denoising.
|
|||
|
|
|
|||
|
|
Convention: ``t=1`` is noise, ``t=0`` is the target — opposite of the
|
|||
|
|
published π0 paper but matches both OpenPI and LeRobot.
|
|||
|
|
Ref: openpi PI0Pytorch.sample_actions
|
|||
|
|
"""
|
|||
|
|
if num_steps is None:
|
|||
|
|
num_steps = self.num_inference_steps
|
|||
|
|
|
|||
|
|
bsize = state.shape[0]
|
|||
|
|
device = state.device
|
|||
|
|
if noise is None:
|
|||
|
|
noise = torch.randn(
|
|||
|
|
bsize,
|
|||
|
|
self.action_horizon,
|
|||
|
|
self.action_dim,
|
|||
|
|
dtype=torch.float32,
|
|||
|
|
device=device,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 1. Prefix embeddings + mask building.
|
|||
|
|
prefix_embs, prefix_pad_masks, prefix_att_masks = self.embed_prefix(
|
|||
|
|
images, image_masks, lang_tokens, lang_masks
|
|||
|
|
)
|
|||
|
|
prefix_att_2d_masks = make_att_2d_masks(prefix_pad_masks, prefix_att_masks)
|
|||
|
|
prefix_position_ids = torch.cumsum(prefix_pad_masks, dim=1) - 1
|
|||
|
|
prefix_att_2d_masks_4d = prepare_attention_masks_4d(prefix_att_2d_masks)
|
|||
|
|
|
|||
|
|
# 2. Forward prefix through PaliGemma LM, producing a list[(k, v)] cache.
|
|||
|
|
_, past_key_values = self.paligemma_with_expert.forward(
|
|||
|
|
attention_mask=prefix_att_2d_masks_4d,
|
|||
|
|
position_ids=prefix_position_ids,
|
|||
|
|
past_key_values=None,
|
|||
|
|
inputs_embeds=[prefix_embs, None],
|
|||
|
|
use_cache=True,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 3. Euler-integrated denoising from t=1 down to t=0.
|
|||
|
|
dt = -1.0 / num_steps
|
|||
|
|
x_t = noise
|
|||
|
|
for step in range(num_steps):
|
|||
|
|
t = 1.0 + step * dt
|
|||
|
|
time_tensor = torch.full((bsize,), t, dtype=torch.float32, device=device)
|
|||
|
|
v_t = self.denoise_step(
|
|||
|
|
state=state,
|
|||
|
|
prefix_pad_masks=prefix_pad_masks,
|
|||
|
|
past_key_values=past_key_values,
|
|||
|
|
x_t=x_t,
|
|||
|
|
timestep=time_tensor,
|
|||
|
|
)
|
|||
|
|
x_t = x_t + dt * v_t
|
|||
|
|
return x_t
|
|||
|
|
|
|||
|
|
# ── Weight loading ───────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]):
|
|||
|
|
"""Load weights from an ``lerobot/pi0_base`` safetensors checkpoint.
|
|||
|
|
|
|||
|
|
Checkpoint keys (after stripping the leading ``model.`` prefix) look
|
|||
|
|
like ``paligemma_with_expert.paligemma.<sub>.*``. Modern transformers
|
|||
|
|
nests those sub-modules one level deeper (``paligemma.model.<sub>``),
|
|||
|
|
and PaliGemma ties ``lm_head.weight`` with ``embed_tokens.weight`` at
|
|||
|
|
``post_init`` time. Two remap rules make the load lossless:
|
|||
|
|
|
|||
|
|
1. ``paligemma.{vision_tower,multi_modal_projector,language_model}.*``
|
|||
|
|
→ ``paligemma.model.{...}.*``
|
|||
|
|
2. ``paligemma.lm_head.weight``
|
|||
|
|
→ ``paligemma.model.language_model.embed_tokens.weight``
|
|||
|
|
(the checkpoint does not store ``embed_tokens.weight`` on its
|
|||
|
|
own — only the tied ``lm_head.weight`` copy — and
|
|||
|
|
``PaliGemmaForConditionalGeneration`` does not register
|
|||
|
|
``lm_head.weight`` as a Parameter when tied, so without this
|
|||
|
|
rule the language embedding would silently remain at its
|
|||
|
|
random init.)
|
|||
|
|
|
|||
|
|
On any remaining mismatch the loader logs a warning listing a sample
|
|||
|
|
of the unmatched checkpoint keys and model params; downstream parity
|
|||
|
|
tests surface these as a hard failure.
|
|||
|
|
"""
|
|||
|
|
params_dict = dict(self.named_parameters())
|
|||
|
|
buffers_dict = dict(self.named_buffers())
|
|||
|
|
|
|||
|
|
_PALIGEMMA_SUBMODULES = (
|
|||
|
|
"vision_tower",
|
|||
|
|
"multi_modal_projector",
|
|||
|
|
"language_model",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def _remap(name: str) -> str:
|
|||
|
|
# Strip the leading "model." that LeRobot's PI0Policy wrapper adds.
|
|||
|
|
if name.startswith("model."):
|
|||
|
|
name = name[len("model.") :]
|
|||
|
|
|
|||
|
|
# Historical MLP alias used by some early π0 checkpoints.
|
|||
|
|
if name.startswith("time_mlp_in."):
|
|||
|
|
name = "action_time_mlp_in." + name[len("time_mlp_in.") :]
|
|||
|
|
elif name.startswith("time_mlp_out."):
|
|||
|
|
name = "action_time_mlp_out." + name[len("time_mlp_out.") :]
|
|||
|
|
|
|||
|
|
# Nested PaliGemma layout.
|
|||
|
|
for sub in _PALIGEMMA_SUBMODULES:
|
|||
|
|
flat = f"paligemma_with_expert.paligemma.{sub}."
|
|||
|
|
nested = f"paligemma_with_expert.paligemma.model.{sub}."
|
|||
|
|
if name.startswith(flat) and not name.startswith(nested):
|
|||
|
|
return nested + name[len(flat) :]
|
|||
|
|
|
|||
|
|
# Tied lm_head → embed_tokens redirect (see docstring).
|
|||
|
|
if name == "paligemma_with_expert.paligemma.lm_head.weight":
|
|||
|
|
return "paligemma_with_expert.paligemma.model.language_model.embed_tokens.weight"
|
|||
|
|
|
|||
|
|
return name
|
|||
|
|
|
|||
|
|
def _fix_vision_tower(name: str) -> str:
|
|||
|
|
"""Reconcile the SigLIP vision-tower nesting across transformers
|
|||
|
|
versions. transformers ≤5.3 wraps the encoder in an extra
|
|||
|
|
``vision_tower.vision_model.*`` module; ≥5.4 flattens it to
|
|||
|
|
``vision_tower.*``. The ``lerobot/pi0_base`` checkpoint uses the
|
|||
|
|
``vision_model`` layout, so on newer transformers we must drop that
|
|||
|
|
infix (else 437 vision params silently load nothing). Adaptive:
|
|||
|
|
only rewrite when the rewritten key actually exists on the model.
|
|||
|
|
"""
|
|||
|
|
vt = "paligemma_with_expert.paligemma.model.vision_tower."
|
|||
|
|
if not name.startswith(vt):
|
|||
|
|
return name
|
|||
|
|
rest = name[len(vt) :]
|
|||
|
|
if name in params_dict or name in buffers_dict:
|
|||
|
|
return name # model already expects this exact layout
|
|||
|
|
if rest.startswith("vision_model."):
|
|||
|
|
# Checkpoint has the infix; model (≥5.4) doesn't → strip it.
|
|||
|
|
candidate = vt + rest[len("vision_model.") :]
|
|||
|
|
else:
|
|||
|
|
# Checkpoint lacks the infix; model (≤5.3) has it → insert it.
|
|||
|
|
candidate = vt + "vision_model." + rest
|
|||
|
|
return candidate if (candidate in params_dict or candidate in buffers_dict) else name
|
|||
|
|
|
|||
|
|
loaded = 0
|
|||
|
|
skipped: list[str] = []
|
|||
|
|
filled_params: set = set()
|
|||
|
|
for name, loaded_weight in weights:
|
|||
|
|
mapped = _fix_vision_tower(_remap(name))
|
|||
|
|
if mapped in params_dict:
|
|||
|
|
param = params_dict[mapped]
|
|||
|
|
weight_loader = getattr(param, "weight_loader", default_weight_loader)
|
|||
|
|
weight_loader(param, loaded_weight)
|
|||
|
|
loaded += 1
|
|||
|
|
filled_params.add(mapped)
|
|||
|
|
elif mapped in buffers_dict:
|
|||
|
|
buffers_dict[mapped].copy_(loaded_weight)
|
|||
|
|
loaded += 1
|
|||
|
|
filled_params.add(mapped)
|
|||
|
|
else:
|
|||
|
|
skipped.append(mapped)
|
|||
|
|
|
|||
|
|
# Reverse audit: any model param that got no checkpoint tensor at all
|
|||
|
|
# would be running with random init. ``rotary_emb.inv_freq`` and
|
|||
|
|
# friends are config-derived buffers, not stored in the checkpoint.
|
|||
|
|
missing_params: list[str] = []
|
|||
|
|
for pname in params_dict:
|
|||
|
|
if pname in filled_params:
|
|||
|
|
continue
|
|||
|
|
if "rotary_emb" in pname or pname.endswith(".inv_freq"):
|
|||
|
|
continue
|
|||
|
|
missing_params.append(pname)
|
|||
|
|
|
|||
|
|
if missing_params or skipped:
|
|||
|
|
parts: list[str] = []
|
|||
|
|
if skipped:
|
|||
|
|
parts.append(f"{len(skipped)} checkpoint key(s) had no home in the model (first 5: {skipped[:5]})")
|
|||
|
|
if missing_params:
|
|||
|
|
parts.append(f"{len(missing_params)} model param(s) received NO weight (first 5: {missing_params[:5]})")
|
|||
|
|
logger.warning(
|
|||
|
|
"π0 load_weights: %d tensors loaded — %s.",
|
|||
|
|
loaded,
|
|||
|
|
"; ".join(parts),
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
logger.info(
|
|||
|
|
"π0 load_weights: %d tensors loaded, 0 skipped, 0 missing.",
|
|||
|
|
loaded,
|
|||
|
|
)
|
|||
|
|
return filled_params
|