LeRobot: π0 Policy 在 RDK S600 上的训练、量化全流程文档

本文复现仓库GitHub - D-Robotics/rdk_LeRobot_tools at s600 · GitHub

机器人操作策略过去大多围绕单一机器人和单一任务训练:模型根据相机图像与关节状态预测动作,任务或硬件发生变化后通常需要重新采集数据。视觉-语言-动作模型(Vision-Language-Action model,VLA)则尝试把视觉理解、语言指令和机器人控制统一起来,使一个模型能够根据自然语言任务处理不同场景,并复用从大规模图文数据和多机器人数据中获得的表示能力。

π0 是 Physical Intelligence 提出的一种 flow-matching VLA。它以 PaliGemma 视觉语言模型为基础,引入专门处理机器人状态和连续动作的 Action Expert,并通过跨本体预训练与高质量 post-training 获得通用能力和下游任务执行能力。与将动作离散为文本 token 的自回归 VLA 不同,π0 从随机噪声出发迭代生成整段连续动作,更适合高频和精细操作。

本文关注 π0 的下游适配与端侧部署,完整流程从 LeRobot 兼容的 π0 base checkpoint 开始,使用 SO100 双相机数据进行全参数 post-training,建立服务器 BF16 基线,再完成 SigLIP、PaliGemma 与 Action Expert 的分段量化、HBM 编译和 S600 真机推理。


机械臂接收自然语言指令“Place the RDK camera box on top of the black MCU box. ”并执行

一、π0论文解读

部署之前先把论文中的模型关系讲清楚。因为后面所有量化边界、Tensor shape 和 KV Cache,都是从这一结构推导出来。

VLA 的问题定义

传统模仿学习通常解决一个很具体的问题:给定相机图像和关节状态,预测下一步动作。

(image, state) → action

这种策略可以在固定场景里做得很好,但任务一变、物体一换,往往就需要重新定义数据和重新训练。VLM 则走向另一个极端:它能理解图像和语言,却通常只会输出文本,不能直接给舵机发送连续关节目标。

VLA,也就是 Vision-Language-Action model,把两者接在了一起:

多路图像 + 语言指令 + 机器人本体状态
                    ↓
                   VLA
                    ↓
              一段连续机器人动作

可以把三类模型粗略对比成:

模型 主要输入 主要输出 擅长解决的问题
视觉策略 图像、state 单步或短动作 固定任务的闭环控制
VLM 图像、语言 文本 token 语义理解和视觉问答
VLA 图像、语言、state 连续动作 chunk 根据自然语言执行机器人任务

这里的关键不是在 VLM 后面简单接一个 6 维回归头。机器人动作具有连续、高频和多解的特点:同一个抓取任务可能存在多条合理轨迹,而且相邻控制步必须足够平滑。π0 因此选择一次生成一个 action chunk,并用 flow matching 表达连续动作分布。

用论文里的符号,一次观测是:

o_t = [I_t^1, ..., I_t^n, language, q_t]
  • I_t^1...I_t^n 是多路 RGB 图像。
  • language 是任务指令。
  • q_t 是机器人当前 proprioceptive state,也就是关节位置等本体状态。

模型预测的不是一个动作,而是未来 H 步动作块:

A_t = [a_t, a_t+1, ..., a_t+H-1]

π0 论文使用 H=50。这意味着一次模型推理可以给出 50 个控制目标,控制器不必每发送一步都重新运行完整 VLM。

图:π0 把视觉、语言与本体状态汇入同一策略,再输出连续动作块。

从单步动作扩展到带语言条件的动作块之后,问题就不再只是训练一个回归网络。模型既要继承 VLM 的视觉语义,又要为连续控制增加新的状态与动作表示。π0 的设计正是围绕这两个目标展开。

预训练背景与模型架构

π0 的论文标题是 π0: A Vision-Language-Action Flow Model for General Robot Control论文主页)。作者希望构建的是 generalist robot policy,也就是机器人基础模型,而不是某台机械臂上的单任务网络。

论文的整体路线有四个关键词:

  1. VLM initialization:从已经具备互联网图文知识的 PaliGemma 出发。
  2. Cross-embodiment data:混合不同机器人、不同动作空间的数据训练同一个模型。
  3. Flow matching action expert:不用文本式离散 action token,而是生成高精度连续动作。
  4. Pre-training + post-training:先用大而杂的数据获得通用能力,再用高质量任务数据把行为收敛到稳定策略。

论文报告的预训练数据超过 10,000 小时,包含自有的 7 种机器人配置、68 类任务,以及来自 22 种机器人的 OXE 数据。它想证明的是:来自其他任务和其他机器人的经验,也能成为下游任务的初始化。

图源:Physical Intelligence,π0: A Vision-Language-Action Flow Model for General Robot Control,Figure 1

这张图最值得注意的不是任务数量,而是中间的训练逻辑:大规模预训练负责给模型提供广泛的语义和操作先验,高质量 post-training 数据负责告诉模型“这个具体任务应该怎样稳定地做”。

我们采集的 SO100 数据属于后者。100 个 episode 不可能重新造出论文中的通用 π0,但可以把已有 base model 适配到固定相机、固定机械臂和固定摆放任务。

VLM Backbone 与 Action Expert

论文从逻辑上把 π0 分成两部分:

较大的 VLM backbone
+
较小的 action expert

VLM backbone 初始化自 PaliGemma。论文 Figure 3 将它标为约 3B 参数,其中包含 SigLIP 图像编码器和 Gemma 语言模型;action expert 约 300M 参数,总体约 3.3B

图源:Physical Intelligence,π0 论文 Figure 3。

这一点很重要:论文中的 action expert 不是独立运行、完全看不到 VLM 内部表示的“动作头”。π0 被实现为一个具有两套权重的 transformer:

  • 图像和语言 token 路由到较大的 PaliGemma/VLM 权重。
  • robot state 和 noisy action token 路由到较小的 action expert 权重。
  • 两套权重主要通过 transformer self-attention 发生信息交互。

为了让 action expert 的 10 次迭代更快,它比 VLM 小很多。论文附录给出的 VLM width 是 2048,action expert width 是 1024,action expert 的 MLP dimension 是 4096

输入、Token 路由与注意力

把细节暂时压缩,一次 π0 推理可以理解成:

RGB images
   ↓ SigLIP / ViT
visual embeddings
   ┐
   ├─ language tokens ─→ PaliGemma VLM context
   │                           ↓ KV/context
   └─ state q_t ─────────→ Action Expert
                               ↑
                         noisy action chunk
                               ↓ 10 次 flow matching
                         future actions [50,d]

论文使用 blockwise causal attention,把输入分为三个 block:

Block 1: [images, language]
Block 2: [robot state]
Block 3: [50 noisy action tokens]

每个 block 内部是双向 attention,但前面的 block 不能看未来 block:

  • 图像和语言不去看 robot state/action,尽量保持 PaliGemma 预训练时的输入分布。
  • state 不看 noisy actions,因此它对应的 KV 在去噪过程中可以缓存。
  • noisy action 可以看前面的全部条件,也可以在 50 个 action token 之间互相注意。

这就是后面端侧部署里出现 attention mask、position IDs 和 36 组 KV 的根源。它们并非工具链附加的无关输入,而是在保持论文定义的 Token 连接关系。

到这里可以得到一个关键结论:论文中的 π0 是 VLM Backbone 与 Action Expert 共同参与注意力计算的模型。S600 上看到的三份 HBM 是为了编译和调度形成的工程切分,不是三个彼此独立的策略。

Flow Matching 与动作块生成

如果直接用 MSE 回归一条动作轨迹,模型容易把多条合理轨迹平均成一条缺乏可执行性的轨迹。π0 使用 conditional flow matching,把动作生成写成一个从随机噪声逐渐流向真实动作分布的过程。

训练时,先从真实动作块 A 和高斯噪声 ε 之间取一个中间点:

A^τ = τA + (1-τ)ε

然后让 action expert 预测此时应该沿哪个方向移动,目标向量是:

u = A - ε

模型优化的是预测向量场与目标向量场之间的平方误差。推理时没有真实动作 A,所以从纯随机噪声开始,用 Euler integration 连续更新:

A^0 ~ N(0,I)

A^(τ+δ) = A^τ + δ · vθ(A^τ, observation)

论文采用 10 个 integration steps,也就是 δ=0.1

随机 50-step noise
      ↓ step 0
较粗的动作形状
      ↓ step 1...8
逐渐符合图像、语言和当前 state
      ↓ step 9
最终 50-step continuous action chunk

所以后文看到 Expert HBM 连续运行 10 次,不是错误地重复推理同一个模型,而是在数值积分 flow matching 学到的向量场。

图:Action Expert 从整段随机噪声出发,经过 10 次迭代得到 [50,6] 连续动作。

与自回归动作生成的差异

一些 VLA 会把动作离散化为 token,再像语言模型一样一个 token 接一个 token 地生成。π0 选择 flow matching,主要面向以下需求:

  • 直接表示连续动作,不需要先把角度量化成离散词表。
  • 一次联合生成整段 action chunk,50 个动作 token 可以互相 attention。
  • 能表达同一观测下多种合理动作模式。
  • 适合高频、精细操作,而不是等几十个 action token 逐个自回归解码。

这也解释了为什么“只在开头算一次语言,后面永远绕过 PaliGemma”并不完全成立。语言 embedding 可以固定,但 PaliGemma context 还融合了当前图像;图像变了,提供给 action expert 的视觉 KV 也应该更新。同一个动作块内部的 10 次去噪可以复用 KV,跨动作块则不能盲目复用旧视觉 KV。

Flow Matching 解释了为什么 Expert 必须连续运行 10 次,也解释了为什么 Expert 的数值误差会被反复带入下一步。这个性质将在下一章直接决定 Expert 的量化策略。

从论文配置映射到 SO100 与 S600

论文在 RTX 4090 上报告三相机完整推理约 73 ms,其中包括图像编码、observation forward 和 10 次 action forward。对于 50 Hz 运动的机械臂,论文不是等到 50 步全部执行完,而是执行 25 步后重新推理下一块;作者还提到 temporal ensembling 在早期实验中反而降低了效果,因此采用 open-loop action chunks。

我们当前更关注量化精度定位,选择了更保守的严格同步基线:

项目 π0 论文示例 当前 SO100/S600
相机 每台机器人 2~3 路 front + side + 1 masked empty
控制频率 20 Hz 或 50 Hz 30 Hz,与采集数据一致
action horizon 50 50
flow steps 10 10
重推理时机 执行 16 或 25 步后 严格同步执行完整 50 步后
推理设备 RTX 4090 S600 BPU
单次整链耗时 论文约 73 ms 当前约 1.24 s

这不是说当前执行的策略已经最优。它的意义是先让每个 action chunk 对应一组明确的新观测,方便比较服务器 BF16 与板端 HBM。等精度稳定后,再讨论 receding horizon、提前重规划或更严谨的异步队列。

面向 S600 的三段式计算图

论文是“VLM backbone + action expert”的逻辑结构;为了在 S600 上编译和调度,我们把推理图工程化拆成三段:

SigLIP HBM
  ↓ 生成每路视觉 embedding
PaliGemma HBM
  ↓ 生成融合图像与 prompt 的 prefix KV
Expert HBM × 10
  ↓
[50,6] SO100 actions

这是端侧编译与 runtime 的切分,不应该反过来误解成论文原本就是三个互不相干的网络。后面的量化也必须按这条真实依赖链逐段校准:下游模型要看到上游 HBM 的真实输出,而不是只看 PyTorch 浮点中间量。

SO100 双相机任务配置

我们的任务是:

Place the RDK camera box on top of the black MCU box.

SO100 follower 的真实 state/action 都是 6 维绝对角度:

shoulder_pan
shoulder_lift
elbow_flex
wrist_flex
wrist_roll
gripper

模型内部 state/action tensor 宽度保持 32,真实 6 维写在前面,其余维度补零;最终只反归一化前 6 维。

真实相机只有两路,但编译图保留三个物理槽:

slot 0: front,真实图像
slot 1: side,真实图像
slot 2: empty,空图像并在 attention 中 mask

三个物理槽,不等于三路真实相机。

最终视觉布局是:

每路 SigLIP 输出:256 个视觉 token
物理视觉 token:3 × 256 = 768
有效视觉 token:2 × 256 = 512
语言容量:48 token
物理 prefix tensor:768 + 48 = 816

物理 HBM tensor 保持 816 长度,但语言的逻辑 position 必须接在 512 个有效视觉 token 后,第三槽和未使用 prompt capacity 通过 mask 失效。这个“物理 shape”和“逻辑有效位置”的区别,后面会直接影响 RoPE、attention mask 和 Expert KV。

最初的数据只有 front。增加 side 后,不能只在 deployment JSON 里把 real_camera_num 从 1 改成 2,因为有效视觉 token、position、mask 和 Expert 收到的 KV 分布都变了。因此最终重新采集双相机数据,并同时解冻 SigLIP、PaliGemma 和 Expert。

LeRobot 的 policy.type=pi0 是 OpenPI 实现的 PyTorch 移植。训练和服务器 BF16 基线使用 LeRobot policy,不是直接运行原始 OpenPI JAX 服务。本文假设已经准备好 LeRobot 兼容的 π0 base checkpoint,并放在本地 pi0_base

论文结构与本项目接口现在已经对应起来:两路真实图像先经过 SigLIP,PaliGemma 生成视觉语言 KV,Expert 再结合 6 维 SO100 状态生成 50 步动作。下一步需要回答的是,为什么这三段不能使用同一套 INT8 配置。

二、量化原理

π0 的量化难点不在于模型数量,而在于它既是级联系统,又包含 10 步迭代。上游误差会成为下游输入,Expert 的输出还会回灌到下一次 Flow Matching。因此,量化策略必须从数据流和误差传播来设计。

图:越靠近 position、attention、KV 与最终动作输出,越需要保留高精度。

模型结构决定精度分配

π0 不能把所有算子统一设成 W8A8,再只用“是否成功编译”判断量化是否可用。

根本原因是这条链路同时具有两个特点:

SigLIP → PaliGemma → Expert
          级联依赖

Expert step 0 → step 1 → ... → step 9
                 迭代依赖

上游产生的误差会成为下游的真实输入,Expert 自己的误差还会在 10 次 flow matching 中反复反馈。三个部分对数值误差的敏感位置也不同:

部分 在 π0 中负责什么 最危险的量化误差 后文采用的方向
SigLIP 把图像变成 256 个视觉 token position embedding 偏移后,物体空间位置被扭曲 patch embedding 可 quant8,position embedding 保留 FP16,其余关键线性路径用 dynamic
PaliGemma 融合图像、prompt,并生成 36 组 prefix KV RoPE、QK score、attention 累加误差会污染整段 KV attention matmul 使用 fixed16、FP32 accumulation、FP16 output,线性层采用 dynamic
Action Expert 用 state、KV 和 noise 迭代生成 50 步动作 单步小误差经过 10 次反馈,并在反归一化后放大成关节角偏移 transformer、projection、attention 和 output linear 均采用 dynamic,输出 tensor 保持 FP16

这张图是本文根据最终 S600 推理链路绘制的工程示意图。上半部分是误差传播路径,下半部分是必须与之对应的真实 HBM 校准路径。

为什么 SigLIP 的位置误差特别致命

图像 patch 数值有一点误差,通常只是视觉 embedding 轻微变化;但 position embedding 告诉模型“这个 patch 在画面中的哪里”。抓取依赖的恰好是空间位置,因此位置向量受损后,常见表现不是模型崩溃,而是落点稳定地偏前、偏后或偏向一侧。

为什么 PaliGemma 的 attention 不能太激进

PaliGemma 要在长 prefix 上融合视觉和语言。本项目物理 prefix 长度为 816,最终还要输出 36 组 KV。Q/K、RoPE 或 attention score 的微小偏差,会同时改变很多 token 的加权关系;这些 KV 随后又被 Expert 的 50 个 action token 使用,所以误差覆盖面比普通分类头大得多。

为什么 Expert 比较小,反而更值得保精度

Expert 每次只输出一个新的向量场更新,但这个输出会立刻复制回输入,继续参加下一步去噪。它不是 10 次彼此独立的预测,而是一个数值迭代过程:

第 0 步误差
  ↓ 成为第 1 步输入的一部分
第 1 步继续计算
  ↓
...
  ↓
第 9 步输出再经过 action_std/action_mean 反归一化

归一化空间里几百分之一的偏差,换回 degree 后就可能成为肉眼可见的关节偏移。Expert 只有约 300M 参数,比 PaliGemma 小得多,因此这里用模型大小和速度换精度通常更划算。

为什么下一级必须用上一级真实 HBM 输出校准

如果 PaliGemma 在校准时看的是 float SigLIP,而部署时看的是 quantized SigLIP HBM,它学到的激活范围并不对应真实运行分布。Expert 也一样:用 float PaliGemma KV 校准,无法覆盖板端 PaliGemma HBM 已经带入的误差。

因此最终校准顺序必须与端侧推理顺序一致:

真实双相机图片
  ↓
SigLIP HBM 实际输出
  ↓ 用它校准 PaliGemma
PaliGemma HBM 实际 36 组 KV
  ↓ 用它校准 Expert
Expert HBM × 10

这就是后文采用混合精度和“链路感知校准”的结构原因。具体的 W8A8、W8A16、dynamic、fixed16 含义以及完整命令,会在量化章节展开。

从 PyTorch Checkpoint 到 S600 HBM

服务器上的 Pi0 checkpoint 大约包含 40 亿参数。S600 BPU 不是 CUDA GPU,不能直接加载 PyTorch BF16 权重。

模型需要经过 S600 工具链:

PyTorch checkpoint
  ↓ export
BC / MLIR
  ↓ convert + compile
HBO
  ↓ link
HBM

HBM 不只是“压缩后的权重文件”。它同时固定了:

  • 输入输出 tensor shape。
  • attention mask 和 position IDs 的计算图。
  • 每层的量化策略。
  • BPU march 和编译调度。
  • 部分固定 prompt 输入形式。

因此不能把 HBM 当成普通 checkpoint,靠改 JSON 就改变相机数或 token 布局。

HBM 固定了图结构以后,下一步才是选择每条计算路径使用哪一种精度。这里的 W8A8、W8A16、Dynamic 和 Fixed16 不是四个模型版本,而是可以分配到不同算子上的执行模式。

前面的结构分析已经说明 π0 不能统一量化。本节先把后面会反复出现的几种工具链精度模式讲清楚,再进入三段模型的具体配置。

W8A8

Weight 8-bit
Activation 8-bit

它最省空间、速度通常也快,但对视觉位置、attention score 和动作回归都比较激进。

W8A16

Weight 8-bit
Activation 16-bit

激活保留更高精度,通常比 W8A8 更适合回归任务,但权重误差仍然存在。

Dynamic

本文中的 dynamic 是 S600 SDK/Leap 导出脚本中的精度模式,表示不强制把对应路径固定成统一的 W8A8 或 W8A16,而是保留工具链支持的动态量化执行路径。

它不能简单等同于 PyTorch 的 torch.quantization.quantize_dynamic()

Fixed16 Attention

最终 PaliGemma attention matmul 使用:

Q/K:16×16 fixed
S/V:16×16 fixed
accumulation:FP32
output:FP16

这样做的代价是模型更大,但能明显降低 attention 和 RoPE 相关误差。

链路感知校准

前面说的“级联误差”在第一次量化中真实发生了。问题不只是某一层 INT8 误差偏大,而是校准时使用的中间分布与板端实际分布不一致。

最开始尝试过“每个模型单独用浮点校准数据量化”的思路:

真实图片 → float SigLIP → 校准 PaliGemma
float PaliGemma KV → 校准 Expert

但部署时实际链路是:

真实图片 → quantized SigLIP HBM → quantized PaliGemma HBM → quantized Expert HBM

两个分布不一样。

如果量化误差在前级产生,下游模型在校准时却从未见过这种误差,误差就会沿链路放大:

图像位置误差
  ↓
SigLIP token 偏移
  ↓
PaliGemma attention/KV 偏移
  ↓
Expert 动作偏移
  ↓
夹爪反复开合、关节试探或 chunk 边界跳变

最终采用的是链路感知校准:后一级模型直接使用前一级真实 HBM 的输出做校准。

因此,完整量化不能在服务器上把三个模型各自独立转换后直接拼接。正确顺序必须与端侧推理一致:先量化 SigLIP,用它的真实 HBM 输出校准 PaliGemma;再运行 PaliGemma HBM,使用真实 KV 校准 Expert。理解这一点以后,下面的实操步骤就不再是孤立的命令,而是一条有前后依赖的流水线。

三、量化实操

量化的起点不是工具链,而是一个已经被 BF16 真机验证过的任务模型。本章先得到可靠的 SO100 Checkpoint,再按上一章的依赖顺序生成三份 HBM。

实验平台与软件版本

训练服务器:NVIDIA RTX 4090
训练框架:Hugging Face LeRobot v0.5.2
训练精度:BF16
板端:RDK S600
板端工具链:D-Robotics LLM S600 SDK 1.0.2
BPU march:nash-p
机器人:LeRobot SO100 follower + SO100 leader
相机:两路 USB UVC camera

代码位于 rdk_LeRobot_toolss600 分支,Pi0 的正式实现统一放在 models/pi0/。板端 LeRobot Python 必须使用已经安装依赖的虚拟环境:

/home/sunrise/lerobot/.venv/bin/python

直接使用系统 python3,常见结果是缺少 torchdraccus 或 LeRobot 模块。

数据采集与全参数 Post-training

本项目增加了第二路相机,因此不能只训练 Expert。视觉输入域、有效 Token 数和后续 KV 分布都发生了变化,数据采集、输入 Schema 与训练冻结策略必须一起调整。

硬件连接与设备映射

本次设备映射为:

SO100 follower:/dev/ttyACM0
SO100 leader:  /dev/ttyACM1
front 相机:    /dev/video0
side 相机:     /dev/video2

设备号不是永久固定的。每次采集前都应该检查:

ls -l /dev/ttyACM* /dev/serial/by-id/*
v4l2-ctl --list-devices

机械臂标定文件(此处应该替换成你自己标定过的):

/root/rdk_LeRobot_tools/models/pi0/calibration/robots/so_follower/so100_follower.json
/root/rdk_LeRobot_tools/models/pi0/calibration/teleoperators/so_leader/so100_leader.json

标定文件与具体机械臂绑定,不应该复制给另一套硬件直接使用。

数据录制命令

本次训练集配置:

100 episodes
每个 episode 10 秒
reset 5 秒
30 FPS
双相机 640×480 MJPG
max_relative_target=20

完整命令:

cd /home/sunrise/lerobot
.venv/bin/lerobot-record \
  --robot.type=so100_follower \
  --robot.port=/dev/ttyACM0 \
  --robot.id=so100_follower \
  --robot.calibration_dir=/root/rdk_LeRobot_tools/models/pi0/calibration/robots/so_follower \
  --robot.max_relative_target=20 \
  --robot.cameras='{front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30, warmup_s: 10, fourcc: MJPG}, side: {type: opencv, index_or_path: 2, width: 640, height: 480, fps: 30, warmup_s: 10, fourcc: MJPG}}' \
  --teleop.type=so100_leader \
  --teleop.port=/dev/ttyACM1 \
  --teleop.id=so100_leader \
  --teleop.calibration_dir=/root/rdk_LeRobot_tools/models/pi0/calibration/teleoperators/so_leader \
  --display_data=false \
  --play_sounds=false \
  --dataset.fps=30 \
  --dataset.repo_id=local/so100_rdk_camera_box_on_black_mcu_pi0_v5_2cam_100ep_10s \
  --dataset.root=/root/rdk_LeRobot_tools/models/pi0/datasets/so100_rdk_camera_box_on_black_mcu_pi0_v5_2cam_100ep_10s \
  --dataset.num_episodes=100 \
  --dataset.single_task='Place the RDK camera box on top of the black MCU box.' \
  --dataset.episode_time_s=10 \
  --dataset.reset_time_s=5 \
  --dataset.video=true \
  --dataset.push_to_hub=false \
  --dataset.streaming_encoding=true \
  --dataset.encoder_threads=2

数据质量检查

Pi0 学到的是动作分布,不只是“最后有没有抓成功”。同一任务的数据最好保持:

  • 相机位置和焦距固定。
  • RDK camera box 与黑色 MCU box 的初始分布合理。
  • 动作尽量连贯,不要大量犹豫、反复开合夹爪。
  • 失败 episode 要么删除,要么明确作为恢复行为保留,不能无差别混入。
  • reset 后的初始状态要覆盖实际部署时可能出现的位置。

之前一版数据虽然数量不少,但动作风格混乱。服务器浮点模型也出现试探性下探、夹爪反复开合,最后证明这部分并不是量化能够修复的问题。

数据质量检查通过后,才进入全参数 Post-training。这里解冻 SigLIP、PaliGemma 和 Expert,是为了让新增 side 视角真正进入视觉表示,而不是把适配压力全部留给最后的动作网络。

可训练模块与冻结策略

只训练 Expert 的优点是显存占用低、训练快。但本次任务增加了第二路相机,并且相机视角与 Pi0 基座数据不同。

如果冻结 SigLIP 和 PaliGemma,Expert 只能被迫适应一套没有针对当前相机域优化的视觉表示。它可能在训练 loss 上下降,但对物体位置、深度和遮挡的使用仍然不稳定。

最终使用:

freeze_vision_encoder=false
freeze_language_model=false
train_expert_only=false

也就是三段全部训练。最终可训练参数约 40.28 亿。

三相机槽输入 Schema

训练时使用以下映射:

dataset front → observation.images.base_0_rgb
dataset side  → observation.images.left_wrist_0_rgb
empty slot    → observation.images.right_wrist_0_rgb

关键配置:

policy.input_features =
  base_0_rgb
  left_wrist_0_rgb
  right_wrist_0_rgb
  observation.state[32]

rename_map =
  front → base_0_rgb
  side  → left_wrist_0_rgb

训练数据的真实 state/action 只有 6 维,Pi0 内部 tensor 保持 32 维;未使用部分补零。部署后只反归一化并取前 6 维发送给 SO100。

脚本中的 policy.empty_cameras=0 表示不再额外追加第四、第五个空槽。第三槽已经通过声明的 right_wrist_0_rgb feature 保留;数据集中没有对应真实相机,预处理阶段按缺失相机处理为空输入。

训练配置与启动命令

先把数据集同步到训练服务器,然后执行仓库中的训练脚本:

cd /path/to/lerobot/rdk_LeRobot_tools
git checkout s600

LEROBOT_ROOT=/path/to/lerobot \
DATASET_ROOT=/path/to/datasets/so100_rdk_camera_box_on_black_mcu_pi0_v5_2cam_100ep_10s \
BASE_MODEL=/path/to/pi0_base \
OUTPUT_DIR=/path/to/outputs/pi0_so100_2cam_full_b1_15k \
STEPS=15000 \
BATCH_SIZE=1 \
WANDB_ENABLE=true \
WANDB_ENTITY=<your_wandb_entity> \
bash models/pi0/tools/train_pi0_full_v5_2cam_100ep.sh

这份脚本的核心参数是:

BF16
batch size 1
15,000 steps
gradient checkpointing
learning rate 1e-5
1,000 warmup steps
cosine decay
memory-efficient Adafactor

最终 checkpoint 结构示例:

outputs/train/<run_name>/checkpoints/015000/pretrained_model

训练监控与结果判断

训练时建议同时看:

  • loss 曲线是否持续下降。
  • learning rate 是否按 warmup 和 cosine schedule 变化。
  • gradient norm 是否持续爆炸。
  • GPU 显存和吞吐是否稳定。
  • checkpoint 的真实推理视频,而不是只看 loss。

本次最终记录 loss 约为 0.077,但这个数字不能单独代表抓取成功率。

服务器 BF16 基线与真实校准集

浮点基线的验证作用

如果直接把模型量化到 S600,真机效果不好时会同时存在四类嫌疑:

数据问题
训练问题
量化问题
板端控制问题

先在 GPU 服务器运行 BF16 checkpoint,可以把问题拆开:

服务器 BF16 也差
  → 优先检查数据和训练

服务器 BF16 正常,S600 HBM 差
  → 优先检查量化、position/mask、前后处理

两者离线动作都正常,真机仍异常
  → 优先检查控制频率、标定、PID、相机位置和异步策略

BF16 推理服务与板端客户端

在 GPU 服务器:

HF_HUB_OFFLINE=1 \
TRANSFORMERS_OFFLINE=1 \
HF_DATASETS_OFFLINE=1 \
conda run --no-capture-output -n lerobot \
python -u models/pi0/pi0_torch_server.py \
  --checkpoint /path/to/checkpoints/015000/pretrained_model \
  --host 0.0.0.0 \
  --port 31001 \
  --device cuda \
  --dtype bfloat16 \
  --camera-keys front side \
  --token-file /secure/path/pi0_token

板端先做只读请求,不发送动作:

cd /root/rdk_LeRobot_tools/models/pi0
/home/sunrise/lerobot/.venv/bin/python -u pi0_remote_pipeline.py \
  --server-url http://<GPU_SERVER_IP>:31001 \
  --token-file /secure/path/pi0_token \
  --robot-port /dev/ttyACM0 \
  --camera /dev/video0 \
  --side-camera /dev/video2 \
  --max-chunks 1 \
  --prefetch-remaining-steps 0

不加 --execute 时只读取机械臂状态和相机,不调用 send_action()

浮点基线确认正常以后,量化结果才有明确的参考对象。

只有 BF16 版本在相同相机、相同标定和同步控制下表现正常,量化误差才有可比较的基准。确认这一点后,从训练数据中抽取覆盖完整任务阶段的真实校准样本。

真实任务校准集

从训练数据中按 episode 和阶段均匀抽取 50 条样本:

conda run --no-capture-output -n lerobot \
python models/pi0/tools/prepare_pi0_calibration_v5_2cam.py \
  --dataset-root /path/to/so100_rdk_camera_box_on_black_mcu_pi0_v5_2cam_100ep_10s \
  --checkpoint /path/to/checkpoints/015000/pretrained_model \
  --output-dir /path/to/calibration/pi0_full_v5_2cam_100ep_015000_real50 \
  --repo-id local/so100_rdk_camera_box_on_black_mcu_pi0_v5_2cam_100ep_10s \
  --samples 50 \
  --camera-keys front side \
  --camera-slots 3 \
  --task 'Place the RDK camera box on top of the black MCU box.'

校准样本不应该只取 episode 开头。抓取任务不同阶段的激活分布差别很大:

接近物体
夹爪闭合
抬起
移动
放下

如果校准集只覆盖静止初始帧,动作阶段的激活范围仍可能溢出或被压缩。

所有量化步骤都设置:

export PI0_VALID_CAMERA_SLOTS=2

它告诉 position/mask patch:三个物理槽中只有两路真实相机有效。

SigLIP 与 PaliGemma 的级联量化

SigLIP:保护空间位置并采集真实视觉特征

SigLIP 对应整条链路的第一个敏感点:视觉 patch 可以适当压缩,但负责空间定位的 position embedding 需要优先保护。

最终 SigLIP 策略:

patch embedding:quant8
position embedding:FP16
attention linear:dynamic
MLP linear:dynamic
attention matmul:dynamic
projector:dynamic
LayerNorm:standard

工具链容器中执行:

export PI0_VALID_CAMERA_SLOTS=2

python3 models/pi0/tools/quantize_siglip_real_calib.py \
  --model-dir /host/gemma/input/pi0_full_v5_2cam_100ep_015000 \
  --calibration-dir /host/gemma/calibration_data/pi0_full_v5_2cam_100ep_015000_real50 \
  --output-dir /host/gemma/output/siglip_positionfp16_dynamic \
  --device cuda:0 \
  --vision-tokens-num 256 \
  --valid-camera-slots 2 \
  --max-samples 50 \
  --jobs 20 \
  --march nash-p \
  --max-l2m-size 0 \
  --patch-embedding-mode quant8 \
  --position-embedding-mode fp16 \
  --attention-linear-mode dynamic \
  --mlp-linear-mode dynamic \
  --attention-matmul-mode dynamic \
  --projector-linear-mode dynamic \
  --layernorm-mode standard

Position Embedding 的空间敏感性

SigLIP 的图像 patch 本身不包含“我在图像哪个位置”。位置向量负责告诉模型每个视觉 token 对应的空间位置。

如果 position embedding 量化误差过大,模型看到的不是简单的颜色误差,而是空间关系被扰动。对于抓取任务,这会直接表现为落点偏前、偏后或左右不稳定。

我们最终保留 position embedding FP16,而 patch embedding 仍使用 quant8,在模型大小和空间精度之间折中。

SigLIP HBM 编译完成后,先不要继续量化 PaliGemma。应把它放到 S600 上运行真实双相机样本,导出 PaliGemma 实际会收到的 FP16 视觉特征。

SigLIP 编译完成后,不直接用服务器 float SigLIP 输出校准 PaliGemma。

正确流程是:

  1. 把 SigLIP HBM 上传到 S600。
  2. 把 50 条真实校准图片上传到 S600。
  3. 在 S600 上运行 SigLIP HBM。
  4. 保存实际 paligemma_vision_fp16.bin
  5. 把这些 HBM 输出传回量化服务器。

板端终端 A:

cd /root/rdk_LeRobot_tools/models/pi0
/home/sunrise/lerobot/.venv/bin/python -u dump_siglip_hbm_calibration.py \
  --calibration-dir calibration_data/pi0_full_v5_2cam_100ep_015000_real50 \
  --engine-dump-dir /root/pi0_calibration/engine_siglip_real50 \
  --output-dir calibration_data/pi0_full_v5_2cam_100ep_015000_siglip_hbm_real50 \
  --prompt 'Place the RDK camera box on top of the black MCU box.'

板端终端 B:

cd /root/rdk_LeRobot_tools/models/pi0
PI0_STANDALONE_DUMP_DIR=/root/pi0_calibration/engine_siglip_real50 \
./run_pi0_standalone_config.sh configs/deployments/<temporary_config>.json

此时 PaliGemma 校准看到的视觉 embedding,已经包含 SigLIP HBM 的真实量化误差。

PaliGemma:使用真实 SigLIP HBM 输出

PaliGemma 对应第二个敏感点。PaliGemma 会把 816 长度 prefix 融合成 36 组 KV,因此 attention 数值误差会扩散到整个 Expert 条件,而不是只影响一个分类结果。

最终 PaliGemma 策略:

attention linear:dynamic
MLP linear:dynamic
MLP down projection:dynamic
attention matmul:fixed16
attention accumulation:FP32
attention output:FP16
18 layers

量化命令:

export PI0_VALID_CAMERA_SLOTS=2

python3 models/pi0/tools/quantize_paligemma_real_calib.py \
  --model-dir /host/gemma/input/pi0_full_v5_2cam_100ep_015000 \
  --calibration-dir /host/gemma/calibration_data/pi0_full_v5_2cam_100ep_015000_real50 \
  --vision-embeddings-dir /host/gemma/calibration_data/pi0_full_v5_2cam_100ep_015000_siglip_hbm_real50 \
  --output-dir /host/gemma/output/paligemma_siglip_hbmcalib_fixed16/paligemma \
  --device cuda:0 \
  --vision-tokens-num 256 \
  --max-samples 50 \
  --num-hidden-layers 18 \
  --jobs 20 \
  --march nash-p \
  --compile-opt 2 \
  --compile-balance 2 \
  --compile-max-l2m-size 0 \
  --max-hbm-bytes 2140000000 \
  --attention-linear-mode dynamic \
  --mlp-linear-mode dynamic \
  --mlp-down-linear-mode dynamic \
  --attention-matmul-mode fixed16 \
  --fixed-prompt 'Place the RDK camera box on top of the black MCU box.' \
  --prompt-embedding-input

RoPE、Mask 与逻辑 Token 位置

RoPE 是 Rotary Position Embedding。它会根据 token 的 position ID 旋转 attention 中的 Q/K,让模型感知 token 的相对位置。

如果训练时两路真实相机后面接语言 token,而部署时却把一个空相机的 256 token 也当成有效位置,语言和状态 token 的 position ID 会整体后移。

模型仍然能输出数值,但它理解的 token 关系已经错了。这类错误通常不会产生明显的 NaN,而是产生“看起来像模型在思考,但动作总是不对”的输出。

最终布局保留:

physical prefix tensor = 816
valid vision tokens = 512
valid prompt tokens = 16
logical Expert state position = 528
logical Expert action positions = 529...578

也就是说,物理 tensor 保持兼容,逻辑 position 和 mask 按两路真实相机计算。

新的 PaliGemma HBM 已经吸收了 SigLIP 的真实量化分布。接下来再把它放回 S600,导出 36 组 KV;这些 KV 才是 Expert 在最终部署中真正面对的条件。

Expert:使用真实 PaliGemma KV

Expert 对应第三个敏感点:Expert 的输出会回灌到下一次 flow step。校准输入必须来自最终 PaliGemma HBM,Expert 自身也不再追求激进的统一 INT8。

PaliGemma 编译完成后,再次把新 HBM 上传 S600。这次保存的不只是 vision embedding,而是 36 个 Expert KV:

expert_kv_00_fp16.bin
...
expert_kv_35_fp16.bin

每个 KV shape:

[1, 816, 256]

50 条样本共:

50 × 36 = 1800 个 KV 文件

板端终端 A:

cd /root/rdk_LeRobot_tools/models/pi0
/home/sunrise/lerobot/.venv/bin/python -u dump_siglip_hbm_calibration.py \
  --calibration-dir calibration_data/pi0_full_v5_2cam_100ep_015000_real50 \
  --engine-dump-dir /root/pi0_calibration/engine_paligemma_kv_real50 \
  --output-dir calibration_data/pi0_full_v5_2cam_100ep_015000_siglip_fixed16_paligemma_hbm_kv_real50 \
  --prompt 'Place the RDK camera box on top of the black MCU box.' \
  --save-expert-kv

板端终端 B 使用包含最终 SigLIP 和 PaliGemma 的临时配置启动 standalone engine。

把 KV 传回服务器后量化 Expert:

export PI0_VALID_CAMERA_SLOTS=2

python3 models/pi0/tools/quantize_expert_real_calib.py \
  --model-dir /host/gemma/input/pi0_full_v5_2cam_100ep_015000 \
  --calibration-dir /host/gemma/calibration_data/pi0_full_v5_2cam_100ep_015000_real50 \
  --paligemma-kv-dir /host/gemma/calibration_data/pi0_full_v5_2cam_100ep_015000_siglip_fixed16_paligemma_hbm_kv_real50 \
  --output-dir /host/gemma/output/expert_fixed16_paligemma_hbmkv_dynamic_real50 \
  --device cuda:0 \
  --vision-tokens-num 256 \
  --max-samples 50 \
  --jobs 20 \
  --march nash-p \
  --transformer-linear-mode dynamic \
  --projection-linear-mode dynamic \
  --attention-matmul-mode dynamic \
  --output-linear-mode dynamic

Expert 最终选择 dynamic,而不是继续强制 W8A8/W8A16。原因是 Expert 的输出会直接映射成 degree,几十分之一的归一化误差反归一化后就可能变成明显的关节偏移。

Expert 本身比 PaliGemma 小,因此这里更值得用模型大小换精度。

至此量化流水线完成,得到 SigLIP、PaliGemma、Expert 三份 HBM,以及与它们绑定的 Prompt Embedding、Normalization Stats 和 SHA256。模型文件本身还不能驱动机械臂,下一章需要用端侧 Runtime 把三段计算、相机输入和 SO100 控制循环连成闭环。

四、部署实操

部署阶段解决的不是“如何加载三个文件”,而是一次请求中每个 Tensor 由谁创建、在哪个进程处理、怎样送入 BPU、如何在 30 Hz 下执行,以及异常退出时怎样可靠关闭力矩。

推理架构

最初使用过 SDK 自带 XLM Pi0 示例,但它有几个限制:

  • 相机槽位和输入协议假设较固定。
  • SDK 旧版本存在硬编码延时。
  • 中间 tensor、position IDs 和 KV 不方便导出与替换。
  • 很难精确控制同步/异步动作队列。
  • 调试时不容易把 SigLIP、PaliGemma 和 Expert 分段比较。

最终实现了自己的 Pi0 standalone runtime,但底层仍然使用相同的 S600 DNN SDK 和模型管理接口。

主要文件:

models/pi0/native/standalone/pi0_standalone.cc
models/pi0/pi0_full_pipeline.py
models/pi0/run_pi0_standalone_config.sh

图:Python 管相机、SO100 与控制生命周期,C++ 管协议和 Tensor,BPU Runtime 执行三段 HBM。

运行时职责与数据接口

Python controller
  ├─ LeRobot SO100 接口
  ├─ 读取 front/side/state
  ├─ 30 Hz 动作发送
  ├─ 日志、torque 清理和安全确认
  └─ TCP Protobuf server

C++ standalone engine
  ├─ TCP client
  ├─ 图像前处理
  ├─ 第三空相机槽和 mask
  ├─ SigLIP/PaliGemma/Expert HBM
  ├─ 10 次 Expert denoise
  └─ state/action 归一化与反归一化

Protobuf 请求

Python 每次发送:

2× UINT8 CHW image
1× task string
1× FLOAT64 state[6]

C++ 返回:

FLOAT64 actions[50,6]

前处理

每路图像:

640×480 RGB
  ↓ resize with pad
224×224
  ↓ CHW FP16
[-1,1]

第三空槽填充为归一化后的 -1 图像,并由 PaliGemma mask 屏蔽。

后处理

Expert FP16 output[50,32]
  ↓ action_mean/action_std 反归一化
只取前 6 维
  ↓
夹爪裁剪到 [0,100]
  ↓
SO100 absolute degree target

推理阶段没有额外的单步 1° 限幅,也没有 LeRobot max_relative_target=2020 只用于数据采集时保护遥操作。

进程与 Runtime 分层

量化完成只代表得到了三个 HBM。真正把它们变成可控制 SO100 的 runtime,还要解决进程边界、模型加载、BPU 调度、张量内存、三段模型衔接、去噪循环、控制频率和异常退出。

LeRobot SO100 + cameras
        │
        ▼
pi0_full_pipeline.py
  Python 控制器 / TCP server
        │ Protobuf over 127.0.0.1:30001
        ▼
pi0_standalone_sdk102
  C++ TCP client / Pi0 engine
        │
        ▼
ModelRunner
        │
        ▼
XLM ModelManager + hbDNN/hbUCP
        │
        ├─ SigLIP HBM × 3 runner
        ├─ PaliGemma HBM × 1 runner
        └─ Expert HBM × 1 runner

这里没有重新实现 BPU 驱动。底层仍然使用 SDK 1.0.2 的 hbDNNhbUCP 和 XLM ModelManager,只是把原本受限的高层流程替换成可调试的 Pi0 编排层。

只搬入 runtime所需的最小 XLM utility 源码:

models/pi0/native/vendor/openexplorer_llm/xlm/src/utils/model_manager.cc
models/pi0/native/vendor/openexplorer_llm/xlm/src/utils/xlm_utils.cc

Python Controller 与 C++ Engine 生命周期

真实启动顺序:

1. Python 校验模型、统计量和硬件标定 SHA256
2. Python 连接 SO100 bus 和两路相机,但不使能 torque
3. Python bind 127.0.0.1:30001 并 listen
4. Python subprocess 启动 run_pi0_standalone_config.sh
5. shell 设置 SDK 动态库和 L2M 环境变量
6. shell exec C++ standalone engine
7. C++ 加载 HBM 并分配 tensor 内存
8. C++ 主动连接 Python 的 30001 端口
9. Python 接受连接并开始发送观测

所以:

Python = controller + socket server
C++ = inference engine + socket client

这种进程隔离让 Python 保留 LeRobot 生态,C++ 独立链接 S600 SDK。engine 崩溃时,Python 可以读取 engine.log、关闭 torque,并且同一个 C++ engine 可以被离线 smoke、真实只读和真机控制复用。

Runtime 启动脚本与环境变量

run_pi0_standalone_config.sh 不包含神经网络逻辑,它只准备 runtime:

export LD_LIBRARY_PATH=/root/D-Robotics_LLM_S600_1.0.2_SDK/oellm_runtime/lib:$LD_LIBRARY_PATH
export HB_DNN_USER_DEFINED_L2M_SIZES=6:6:6:6
exec pi0_standalone_sdk102 --config <deployment.json>
  • LD_LIBRARY_PATH 用于找到 SDK 1.0.2 的 DNN、UCP 和 protobuf 动态库。
  • HB_DNN_USER_DEFINED_L2M_SIZES 设置四个 BPU core 的 runtime L2M 预算。
  • exec 让 shell 被 C++ 替换,Python 可以可靠管理整个 engine 进程组。

旧 SDK 示例中的固定 sleep(5000 ms) 不属于模型计算,standalone 路径没有保留这类等待。

Python 与 C++ 的边界确定以后,下一步沿一次真实请求向下看。Deployment JSON 首先绑定三份 HBM 的 ABI,随后 C++ Engine 完成缓存同步、模型调用和 Tensor 搬运。

单次请求的数据流与 Tensor ABI

Deployment JSON 与模型 ABI

关键字段:

{
  "runtime": "standalone_dnn",
  "siglip_hbm_path": "/root/pi0_models/.../pi0_siglip_ptq.hbm",
  "paligemma_hbm_path": "/root/pi0_models/.../pi0_gemma_llm_ptq.hbm",
  "action_hbm_path": "/root/pi0_models/.../pi0_gemma_expert_ptq.hbm",
  "prompt_embedding_path": "/root/pi0_models/.../fixed_prompt_embedding.bin",
  "norm_stats_path": "/root/rdk_LeRobot_tools/models/pi0/configs/norm_stats_....json",
  "siglip_bpu_core": [0, 1, 2],
  "paligemma_bpu_core": [0],
  "action_bpu_core": [0, 1, 2, 3],
  "real_camera_num": 2,
  "exec_size": 6,
  "denoise_num": 10,
  "parallel_siglip": true,
  "action_mode": "absolute",
  "server_ip": "127.0.0.1",
  "server_port": 30001
}

C++ 会硬校验 real_camera_num=2exec_size=6denoise_num=10,并要求 siglip_bpu_core 正好有三项。Python 还会校验三份 HBM、prompt embedding、norm stats 和 follower calibration 的 SHA256。

因此 JSON 只能绑定一个已经编译好的模型组合,不能动态改变 HBM 的相机数、token 数或 tensor shape。

ModelRunner 的 BPU 执行流程

每个 HBM graph 被包装成统一的 ModelRunner

ModelManager::Init()
  ↓ 加载 HBM pack
按名字找到 siglip / gemma / gemma_expert graph
  ↓
获取 hbDNNTensor 输入输出数组
  ↓
保存允许使用的 InferBackend/BPU core

一次 Run() 的内部顺序:

FlushInputMem(CLEAN)
InferTaskSync()
FlushOutputMem(INVALIDATE)
ReleaseTaskHandle()

CPU 通过 tensor.sysMem.virAddr 写 input。执行前必须 CLEAN cache,BPU 输出后必须 INVALIDATE cache,否则两边都可能读到旧数据。

三路 SigLIP Runner 并行调度

runner 0 → BPU core 0 → front
runner 1 → BPU core 1 → side
runner 2 → BPU core 2 → empty

三个 runner 共享同一个 packed SigLIP HBM handle,但各自拥有独立输入输出 tensor。parallel_siglip=true 时使用三个 std::async 并行提交。

empty 槽仍然运行 SigLIP,是因为 PaliGemma 的 vision input 物理 shape 固定为三槽。它的输出会进入物理 tensor,但对应 prefix token 会被 attention mask 屏蔽。

HBM Tensor ABI 校验

ValidateLayout() 在接收第一张图之前检查每个 input/output 的数量、shape 和 dtype。

SigLIP:

image input:   [1,3,224,224] FP16
position IDs:  [1,256] S64
vision output: [1,256,2048] FP16

PaliGemma:

prompt embedding:[1,48,2048] FP16
vision embedding:[1,768,2048] FP16
attention mask:  [1,1,816,816] FP16
KV output × 36:  [1,816,256] FP16

Expert:

state:          [1,32] FP16
noise/action:   [1,50,32] FP16
denoise index:  [1] S32
attention mask: [1,1,51,867] FP16
position IDs:   [1,51] S32
KV input × 36:  [1,816,256] FP16
output:         [1,50,32] FP16

任意一项不一致,engine 直接退出。这能阻止“每个 HBM 单独能加载,但三段接口实际对不上”的组合进入真机。

静态 Tensor 初始化

固定 prompt embedding:

[1,48,2048] FP16
48 × 2048 × 2 = 196,608 bytes

三路 SigLIP position IDs 都是:

0,1,2,...,255

PaliGemma 物理 prefix 为:

0...767:三路 vision tensor
768...815:48 个 prompt 容量

真正可见的 token:

0...511:front + side
768...783:16 个真实 prompt token

被 mask 的位置:

512...767:empty camera
784...815:未使用 prompt capacity

Expert suffix 为 1 state + 50 actions = 51。它的逻辑 position 从 512 + 16 = 528 开始:

state position = 528
action positions = 529...578

这解释了为什么物理 KV 长度保持 816,但 Expert position 不能从 816 开始。

Protobuf 请求与响应协议

Python 每次发送:

images[0]:front,UINT8 CHW [3,H,W]
images[1]:side,UINT8 CHW [3,H,W]
languages[0]:task STRING
states[0]:FLOAT64 [6]

C++ 会拒绝:

  • 不是正好两张图。
  • 图像不是 CHW UINT8 RGB。
  • state 不是正好 6 个 FLOAT64。
  • task 与固定 prompt 配置不一致。
  • message 大于 64 MiB。

返回的 wire tensor 是 [1,50,6] FLOAT64,Python 去掉 batch 维后得到 [50,6]

C++ 图像前处理

Python 只发送原始 RGB。C++ 直接把结果写入 SigLIP 的 BPU input tensor:

输入 H×W
  ↓ 按长边等比例 resize
最长边 224
  ↓ 四周补 0
224×224
  ↓ value = uint8 × 2/255 - 1
CHW FP16
  ↓
hbDNNTensor.sysMem.virAddr

第三槽不经过 Protobuf,C++ 直接填充 FP16 -1.0

前处理只实现一份,可以避免 Python、XLM 示例和量化校准分别实现 resize/normalize 后出现细微差异。

SigLIP 到 PaliGemma 的 Tensor 传递

每路 SigLIP 输出 [1,256,2048] FP16。C++ 不转 NumPy、不经过 socket,而是直接 memcpy 到 PaliGemma vision input 的连续区域:

offset 0 × 256 × 2048 → front
offset 1 × 256 × 2048 → side
offset 2 × 256 × 2048 → empty

最终得到 [1,768,2048] FP16。这既减少了序列化,也保证 PaliGemma 看到的就是实际 SigLIP HBM 输出。

PaliGemma KV 到 Expert 的 Tensor 传递

PaliGemma 不是直接输出机械臂动作。它输出融合了语言与当前视觉的 prefix KV,Expert 再基于这些 KV、当前关节状态和扩散噪声生成动作。

当前 HBM ABI 中:

PaliGemma outputs[1...36]
    每个都是 [1,816,256] FP16
              ↓ 逐块 memcpy
Expert inputs[5...40]

单个 KV tensor 为:

816 × 256 × 2 bytes = 417,792 bytes

36 个总计约 14.35 MiB。它们只在 C++ 进程内部从一个 HBM tensor buffer 搬到另一个 HBM tensor buffer,不经过 Python,也不会被序列化成 Protobuf。

这里有两个容易混淆的“复用”:

  1. 同一次请求内复用:PaliGemma 只运行一次,生成的 36 个 KV 被 Expert 的 10 次去噪共同使用。
  2. 跨请求不复用:下一动作块换了相机图像后,必须重新运行三路 SigLIP 和 PaliGemma,生成新的视觉条件 KV。

如果跨动作块盲目复用旧 KV,速度会更快,但 Expert 实际依据的是旧画面,抓取阶段很容易出现“物体已经移动,模型还按上一帧修正”的问题。

State 归一化与噪声初始化

SO100 只提供 6 维关节状态,而 Expert 的内部动作宽度是 32。因此 C++ engine 会先做:

raw state [6] FLOAT64
  ↓ (x - state_mean) / (state_std + 1e-6)
normalized state [6]
  ↓ 后 26 维补 0
Expert state [1,32] FP16

这里使用的 state_mean/state_std 必须来自训练数据集对应 checkpoint 的统计量。把另一个数据集的 stats 填进来,即使所有 HBM 都能正常运行,机械臂落点也会整体偏移。

Expert 的初始 action/noise 为 [1,50,32] FP16

正常运行:每次请求生成 N(0,1) 随机噪声
精度对比:读取固定 3,200-byte noise 文件

固定噪声只用于 CPU/BF16/HBM 的逐层误差复现。真机长期运行时若永远固定噪声,会消除采样随机性,但它不是解决动作抖动的办法。

Expert 十步 Flow Matching

Expert HBM 不是调用一次就结束,而是连续运行 10 次:

初始 noise/action x0
      ↓ Expert(step=0, state, mask, position, 36 KV)
      x1
      ↓ Expert(step=1, 同一 state/mask/position/KV)
      x2
      ↓ ...
      x10
      ↓
最终 [1,50,32] normalized actions

每一步的 HBM 输出都会直接复制回 Expert input[1],成为下一步的 action/noise 输入。Expert input[2] 则写入当前去噪步 0...9

为了减少 CPU cache flush:

step 0:flush 全部 Expert inputs
step 1...9:只 flush input[1] 与 input[2]

原因是 state、attention mask、position IDs 和 36 个 KV 在同一次请求的 10 步里都不变。只刷新真正变化的 buffer,既保持语义正确,也避免反复清理十几 MiB 的 KV。

这也是端侧 runtime 与普通“按顺序调用三个模型”脚本最大的区别之一:它必须理解 Expert 的迭代状态,并显式管理每一步输入输出 buffer 的生命周期。

动作反归一化与夹爪范围处理

第 10 步输出仍是 [1,50,32] FP16 的归一化动作。C++ engine 做的后处理是:

FP16 → FP32
  ↓ 取前 6 维
action = normalized × (action_std + 1e-6) + action_mean
  ↓
[50,6] FLOAT64

仅对第 6 维夹爪目标做物理范围裁剪:

gripper = clamp(gripper, 0, 100)

这里没有默认做单步 20° 的关节限幅,也没有把动作替换成 PID 插值轨迹。这样做是为了尽量保留模型原始的 50 步输出。

要区分三种东西:

  • 反归一化:模型定义的一部分,必须做。
  • 夹爪物理范围 clamp:防止写入无意义位置,必须做。
  • 额外平滑、blend、相对角度裁剪:控制策略,不属于模型本身;默认应关闭,只有明确验证后才启用。

C++ 返回 [50,6] 后,神经网络计算已经结束;后续行为由 Python 控制器决定。它必须把动作块、真实关节状态、Prompt/KV 生命周期和同步策略放在同一个控制时序中管理。

SO100 控制、Prompt 与同步执行

SO100 控制生命周期

Python 层并不参与神经网络计算,但负责所有与真机安全和时序有关的工作:

LeRobot SO100Follower
  ├─ 读取 6 维当前位置
  ├─ 读取 front / side RGB
  ├─ 向本地 C++ engine 发请求
  ├─ 接收 [50,6] 绝对位置动作
  └─ 以 30 Hz 调用 send_action()

启动时的顺序很重要:

  1. 先校验 deployment JSON、文件存在性和三个 HBM 的 SHA256。
  2. 连接串口和相机,但不立即开力矩。
  3. 读取所有电机 Torque_Enable;只要有一个已经开启就拒绝启动,避免继承未知进程的控制状态。
  4. 默认进入 dry-run,只推理、保存日志,不发送动作。
  5. 只有同时给出 --execute --confirm ENABLE_SO100_MOTORS 才可能开启力矩;正常安全模式还要求首块视觉/动作检查通过。已经完成只读验证后,可显式加 --force-model-actions 跳过这两个启发式门,但它不会跳过 hash、串口、力矩所有权和确认字符串检查。
  6. 每个目标发送前重新读取真实关节状态,记录跟踪误差和指令跳变量。

控制周期固定为:

30 Hz = 每 33.33 ms 发送一个目标
50 步动作块 = 约 1.67 s

如果 --max-relative-target 不传,LeRobot 不再替模型做额外相对目标裁剪。SO100 底层舵机仍会进行自己的位置闭环,但它不应该被误解成“Pi0 runtime 在额外跑一个软件 PID”。

严格同步控制时序

本项目正式验证默认使用:

--prefetch-steps 0

其时序是:

读取当前 state + 两张当前图像
        ↓
完整运行 SigLIP ×3 → PaliGemma → Expert ×10
        ↓
按 30 Hz 执行这 50 个动作
        ↓
动作块执行完,再读取新的 state + 新图像
        ↓
推理下一块

优点是每个动作块严格对应动作块开始时的真实观测,不会混入提前采样的状态,也便于和服务器 BF16 同步推理做 A/B 对比。

代价是动作块边界会等待下一次约 1.2 秒的端侧推理,因此肉眼可能看到短暂停顿。异步 prefetch 可以隐藏这段延迟,但它会引入“用哪一个未来 state、哪一帧图像推下一块”的策略问题。为了先验证模型精度,本教程不把异步推理作为默认方案。

运行日志与控制遥测

每次运行都会创建独立输出目录,核心文件包括:

pipeline.log          Python 生命周期、安全门和异常
engine.log            C++ 中三个 HBM 的耗时与错误
result.json           本次运行配置和最终汇总
chunks.jsonl          每个 50-step 动作块的输入/输出摘要
control_steps.jsonl   每个 30 Hz 控制步的 current/target/lag

control_steps.jsonl 至少记录:

current                 发送前的真实关节位置
target                  模型目标位置
max_abs_delta           target 与真实位置的最大差
max_abs_command_delta   当前指令与上一指令的最大跳变
control_lag_ms          相对 30 Hz deadline 的延迟
gripper telemetry       周期性读取力矩等寄存器

因此“模型动作怪”不能只凭视频判断。先看 engine.log 是否推理异常,再区分:模型输出跳变、舵机跟踪不上、夹爪力矩掉线,还是控制线程错过 deadline。

中间 Tensor Dump 与精度定位

设置环境变量:

export PI0_STANDALONE_DUMP_DIR=/root/pi0_debug_dump

C++ engine 会为每次请求建立:

request_000000/
request_000001/
...

并保存关键中间量:

三路 SigLIP 预处理图像与输出
PaliGemma prompt / vision / attention mask
36 组 Expert KV
Expert state / noise / mask / position IDs
Expert step_00...step_09 输出
最终 normalized 与反归一化动作

这使误差定位可以按链路逐层进行:

相机前处理是否一致?
  ↓
SigLIP HBM 是否先偏?
  ↓
PaliGemma KV 是否放大误差?
  ↓
Expert 从第几个去噪 step 开始偏?
  ↓
还是 stats / 后处理才出错?

连续真机运行时必须关闭 dump。单次请求会产生大量二进制文件,长期打开会迅速占满 S600 磁盘。

BPU Core 配置与调度

当前正式配置为:

{
  "siglip_bpu_core": [0, 1, 2],
  "paligemma_bpu_core": [0],
  "action_bpu_core": [0, 1, 2, 3],
  "parallel_siglip": true
}
  • 三个独立 SigLIP runner 分别绑定一个 core,并由三个 C++ future 并发提交。
  • PaliGemma 的 backend 候选为 core 0。
  • Expert 把 0~3 都交给 SDK backend;具体算子如何落核仍由编译结果和 runtime 调度决定,并不等于“一次 Expert 同时平均占满四核”。

可用下面命令持续观察:

watch -n 1 hrut_somstatus

bpu0...bpu3 ratio 是采样窗口内利用率,不是模型是否成功的唯一判断。Pi0 是阶段式执行:SigLIP、PaliGemma、Expert 依次运行,所以各核利用率天然会随阶段波动。

进程退出与力矩清理

Python 捕获 SIGINT/SIGTERM 后按以下顺序退出:

写回 result.json
  ↓
若本进程开启过力矩,则 disable_torque
  ↓
断开两路相机
  ↓
断开 SO100 bus
  ↓
向 C++ engine 进程组发送 SIGTERM

C++ client 自身带断线重连循环,方便 Python controller 重启;但父级 Python 退出时会杀掉整个 engine 进程组,避免后台残留继续占用 HBM、端口或 BPU。

所以手动结束时应对前台主命令按一次 Ctrl+C,而不是只杀 C++ 子进程。若发生异常退出,也要通过 result.json 和电机 Torque_Enable 再确认一次最终状态。

Prompt Embedding 与 KV Cache

当前部署 artifact 使用固定任务 prompt embedding:

Place the RDK camera box on top of the black MCU box.

固定 embedding 可以省掉板端 tokenizer 和语言 embedding 查找,但不代表 PaliGemma 可以永远只跑一次。

原因是 PaliGemma 的 KV 同时依赖:

固定 prompt
+
当前 front 图像
+
当前 side 图像

图像改变后,融合后的 KV 也应该改变。

最终策略:

  • 每个新动作 chunk 都重新运行 SigLIP 和 PaliGemma。
  • 同一个 chunk 的 10 次 Expert denoise 复用本次 PaliGemma KV。
  • 不跨 chunk 复用旧 KV。

如果只复用旧 PaliGemma KV,模型相当于一直根据旧画面生成动作。

同步执行与异步预取

异步预取的行为与风险

异步模式会在上一块 50 步动作还没执行完时,提前读取图像或预测下一块动作。

优点:

  • chunk 之间停顿小。
  • 视觉吞吐更高。

风险:

  • 采图时的机械臂 state 与真正执行下一块时不同。
  • 上一块动作尚未结束,下一块已经基于中间状态生成。
  • 夹爪可能在两个 chunk 间反复开合。
  • 机械臂可能表现为抬起、下压之间抽搐。

严格同步执行配置

采集最新 state/front/side
  ↓
完整推理约 1.24 秒
  ↓
执行 50 步,30 Hz,约 1.67 秒
  ↓
重新采集

配置:

prefetch_steps=0
visual_refresh_chunks=1
control_hz=30

这里的 30 Hz 是动作发送频率,不是模型每秒看 30 张新图。

严格同步时,一次视觉闭环周期约为:

1.24 + 1.67 ≈ 2.91 秒

它牺牲了视觉刷新率,但先保证动作 chunk 和观测对应关系正确。后续如果重新做异步,需要使用更严谨的 state prediction、chunk overlap 或 action replanning,而不是简单提前启动线程。

图:prefetch_steps=0 时,推理与动作执行串行;块结束后才采集下一组真实观测。

运行逻辑确定以后,再进行 SDK 安装、C++ 编译和模型部署。日常使用只启动 Python 顶层 Pipeline,由它创建本地 TCP Server 并拉起 C++ Engine,不需要手工维护两个前台进程。

编译、模型部署与启动

端侧源码目录

models/pi0/
├─ pi0_full_pipeline.py
│    LeRobot、相机、TCP server、30 Hz 控制和日志
├─ run_pi0_standalone_config.sh
│    校验 JSON、配置动态库与 L2M、启动 C++ engine
├─ run_live_sync.sh
│    固化最终 artifact hash 与同步真机参数
├─ configs/deployments/*.json
│    绑定三份 HBM、prompt、stats、core 和协议参数
└─ native/
   ├─ build_standalone_pi0.sh
   ├─ standalone/pi0_standalone.cc
   ├─ standalone/dnn_sdk102_compat.h
   ├─ sdk_1.0.2/pi0_demo/msg.proto
   └─ vendor/openexplorer_llm/xlm/
      ├─ include/utils/{model_manager,xlm_utils}.h
      └─ src/utils/{model_manager,xlm_utils}.cc

这里特意把必要的 XLM utility 源码 vendoring 到 rdk_LeRobot_tools,避免运行时依赖服务器上的源码目录或某个临时 SDK demo 工作区。最终分支自身就包含可审查、可重新编译的端侧框架。

SDK 1.0.2 安装

cd /root
wget https://d-robotics-aitoolchain.oss-cn-beijing.aliyuncs.com/llm_s600/1.0.2/D-Robotics_LLM_S600_1.0.2_SDK.tar.gz
tar -xzf D-Robotics_LLM_S600_1.0.2_SDK.tar.gz

本项目默认 SDK runtime 位于:

/root/D-Robotics_LLM_S600_1.0.2_SDK/oellm_runtime

编译和运行必须使用同一套 1.0.2 headers/libraries。只替换 .so 或只替换头文件,可能产生能编译却在 tensor/cache API 上行为不一致的问题。

Standalone Engine 编译

cd /root/rdk_LeRobot_tools
git checkout s600
bash models/pi0/native/build_standalone_pi0.sh

构建脚本本质上执行一条 C++17 链接:

pi0_standalone.cc
+ msg.pb.cc
+ model_manager.cc
+ xlm_utils.cc
+ SDK 1.0.2 libprotobuf/libdnn/libhbucp/libhlog
+ OpenCV

关键编译参数是:

-std=c++17 -O3 -pthread
-Wl,-rpath,/root/D-Robotics_LLM_S600_1.0.2_SDK/oellm_runtime/lib

最终二进制:

/root/rdk_LeRobot_tools/models/pi0/native/install/bin/pi0_standalone_sdk102

构建先写入 .tmp,成功后再原子替换正式二进制,避免编译中断留下半个可执行文件。

HBM Artifact 与版本绑定

最终使用:

SigLIP:    445,286,384 bytes
PaliGemma:2,047,778,016 bytes
Expert:    344,902,928 bytes

正式 deployment JSON 绑定:

siglip_hbm_path
paligemma_hbm_path
action_hbm_path
prompt_embedding_path
norm_stats_path
task
real_camera_num / exec_size / denoise_num
BPU core 配置

Python controller 还在启动 C++ 前核对三份 HBM、prompt、stats 和机械臂 calibration 的 SHA256。不能根据目录名中的 latest 猜模型,也不能只看文件大小,因为不同量化策略可能生成相同 ABI、相近大小,但数值行为完全不同。

Runtime 环境变量

run_pi0_standalone_config.sh 在执行二进制前设置:

export LD_LIBRARY_PATH=/root/D-Robotics_LLM_S600_1.0.2_SDK/oellm_runtime/lib:${LD_LIBRARY_PATH:-}
export HB_DNN_USER_DEFINED_L2M_SIZES=6:6:6:6
  • LD_LIBRARY_PATH 保证加载 SDK 1.0.2 的 runtime 库,而不是系统里另一版同名库。
  • HB_DNN_USER_DEFINED_L2M_SIZES 为四个 BPU core 配置本任务需要的 L2M 空间。缺失或配置不匹配时,可能在 HBM load/infer 阶段失败,而不是在 Python 层报错。

runner 还会先执行 validate_pi0_config.py,然后用 exec 启动 C++。这样 shell 不残留额外父进程,Python 可以可靠地管理整个 engine 进程组。

Controller 与 Engine 启动顺序

本项目的方向是:

Python controller = TCP server
C++ engine        = TCP client

正式启动顺序由 pi0_full_pipeline.py 自动完成:

Python bind/listen 127.0.0.1:30001
  ↓
Python subprocess 启动 run_pi0_standalone_config.sh
  ↓
C++ load 三份 HBM
  ↓
C++ connect 127.0.0.1:30001
  ↓
Python accept 后进入请求循环

单独运行 C++ 时看到反复 reconnect 并不代表 HBM 坏了,通常只是 Python server 尚未监听。日常使用应启动 Python 顶层命令,而不是分别手工维护两个进程。

连续同步推理启动

分支已经提供固定最终模型和 hash 的启动器:

cd /root/rdk_LeRobot_tools/models/pi0
./run_live_sync.sh

它等价于以这些关键参数启动 Python controller:

max_chunks=0              无限动作块,直到 Ctrl+C
prefetch_steps=0          严格同步
fixed_noise=false         每个请求使用随机噪声
chunk_blend_steps=0       不修改动作块开头
max_relative_target=None  不做额外相对角度裁剪
save_artifact_every=0     连续运行不保存大图和 npy
execute=true
confirm=ENABLE_SO100_MOTORS

常见启动故障

Address already in use

通常是旧 Python controller 仍占用 30001

ss -ltnp | grep ':30001'

确认 PID 属于旧 Pi0 进程后再正常发送 SIGTERM,不要无差别杀 Python。

C++ 日志一直显示 reconnect

检查 Python 是否已成功 bind、是否因 HBM hash 或 torque 状态在 accept 前退出,并先读 pipeline.log

缺少 libdnnlibhbucp 或 protobuf .so

不要直接裸跑 binary,使用 run_pi0_standalone_config.sh,并确认 SDK 目录没有被移动。

启动时报 tensor count/shape/type mismatch

这不是 JSON 小问题,而是装载了错误 HBM 或使用了不兼容 SDK 编译产物。停止运行,按 manifest 和 SHA256 重新核对三段模型。

No module named torch 或缺少 LeRobot 包

说明误用了系统 python3。控制器应使用:

/home/sunrise/lerobot/.venv/bin/python

提示 torque 已开启而拒绝启动

先确认是否还有旧控制进程。如果没有,再单独检查电机状态;不要为了“能跑”直接删除这个所有权检查。

完成部署后不要立即无限运行机械臂。验证应从配置、离线 Tensor、真实只读观测逐层推进;某一层失败时,只检查该层新增的变量。

分层验证、故障定位与结果

真机验证不应从连续运动开始,而应按以下顺序逐层增加变量:

静态配置检查

cd /root/rdk_LeRobot_tools/models/pi0
/home/sunrise/lerobot/.venv/bin/python validate_pi0_config.py \
  configs/deployments/pi0_full_v5_2cam_positionfp16_siglip_fixed16_paligemma_hbmkv_expert_20260801.json

离线双图 Smoke Test

不访问机械臂串口:

/home/sunrise/lerobot/.venv/bin/python -u pi0_standalone_offline.py \
  --front /path/to/front.jpg \
  --side /path/to/side.jpg \
  --state 0 0 0 0 0 0 \
  --output-dir diagnostics/offline_smoke

预期输出:

[50,6]

真实观测只读推理

/home/sunrise/lerobot/.venv/bin/python -u pi0_full_pipeline.py \
  --config configs/deployments/pi0_full_v5_2cam_positionfp16_siglip_fixed16_paligemma_hbmkv_expert_20260801.json \
  --max-chunks 1 \
  --prefetch-steps 0 \
  --no-fixed-noise \
  --output-dir diagnostics/readonly_smoke

不加 --execute,不会发送机械臂动作。

有限动作块真机验证

先运行 1~3 个 chunk,确认电机顺序、夹爪和 torque 正常。

连续同步真机推理

cd /root/rdk_LeRobot_tools/models/pi0
./run_live_sync.sh

附录:

1.开发阶段的关键故障与根因

JSON 配置无法改变已编译输入 ABI

HBM 的 tensor shape、mask 和 position layout 在编译时已经固定。JSON 只能选择模型和运行参数,不能把一相机 HBM 变成双相机 HBM。

真实相机数与物理槽位混淆

最终是两路真实相机、三个物理槽、一个 masked empty。把第三槽当真实 token,会让语言和 Expert position 整体错位。

训练与部署的相机 Schema 不一致

早期训练配置曾出现五槽 schema,而部署只有三槽。即使 Expert 输入 shape 最终能补齐,模型学习的 token 语义仍然不一致。最终重新固定为三槽训练和三槽部署。

Position IDs 与 RoPE 错位

这类错误不会轻易报 shape mismatch,也不一定产生 NaN。模型会正常输出一串动作,但图像位置理解错误,最典型表现是抓取点持续偏移。

三段模型统一激进量化

W8A8/W8A16 不能机械地应用到所有层。视觉位置、PaliGemma attention 和 Expert 输出对精度的敏感度不同。

最终采用分段策略:

SigLIP:position embedding FP16
PaliGemma:attention matmul fixed16
Expert:关键路径 dynamic

使用浮点上游输出校准下游

这是最关键的问题之一。部署时 Expert 接收的是量化 PaliGemma KV,校准时也必须使用同一 HBM 的真实 KV。

混用不同版本的 PaliGemma Artifact

三段模型可以分别成功加载,并不代表它们属于同一训练和 position layout。部署时必须通过路径、size、SHA256 和 manifest 绑定成一个组合。

正式推理误用固定噪声

固定 noise 适合做 BF16/HBM 可重复精度对比,但正式 Pi0 推理应该使用随机 noise。否则容易把某个固定轨迹误认为模型的完整分布。

异步预取使用过期观测

动作块边界不连续、夹爪反复开合,很多时候不是 PID,而是下一块动作对应的观测已经过期。最终先关闭预取,改成严格同步。

将跟踪误差误判为指令跳变

舵机存在 PID 跟踪滞后。若安全门用“当前实际位置 vs 下一目标”判断跳变,正常的跟踪误差也可能被误判成模型输出跳变。

更合理的是比较:

当前 command target
vs
上一条 command target

实际位置误差仍然记录用于诊断,但不应该直接等同于模型 jump。

额外动作限幅改变模型轨迹

这会改变训练得到的动作轨迹,尤其会破坏抓取阶段的快速闭合和抬升。最终推理不再添加这种人工限幅,只保留夹爪物理范围裁剪。

夹爪力矩异常与模型输出混淆

夹爪异常还可能来自:

  • Torque_Enable 被保护机制关闭。
  • 电流、负载或温度保护。
  • 进程退出时自动 disable torque。
  • USB 串口瞬断。
  • PID/舵机参数与训练机械臂不同。

因此 pipeline 同时记录夹爪 telemetry,而不是只看模型 action。

相机外参变化导致视觉分布漂移

Pi0 能泛化,但不意味着对完全不同的相机外参不敏感。抓取点总偏前时,首先要比较训练图像和当前图像,而不是立即修改后处理偏置。

SDK 示例固定等待掩盖真实性能

旧 SDK 示例请求路径中存在硬编码 sleep(5000 ms)。它不是 SigLIP、PaliGemma 或 Expert 的真实计算时间,却会让机械臂表现为“动一下、停很久”。

最终升级到 SDK 1.0.2,并重新编译修改后的 standalone runtime,删除了这类固定等待。判断性能时应分别记录 SigLIP、PaliGemma、Expert 和整链耗时,而不是只看动作之间的墙钟时间。

最终精度、性能与真机结果

最终链路已经完成:

  • 双相机真实输入。
  • 输出 [50,6]
  • 严格同步连续运行。
  • 每个 chunk 更新 front 和 side。
  • 单次整链推理约 1.24 秒。
  • 动作发送频率 30 Hz。

在一个固定真实输入上,与服务器 BF16 动作对比:

MAE:0.4405°
RMSE:0.5903°
最大绝对误差:1.6888°
relative L2:0.852%
cosine similarity:0.999981858

2.Quick Start 命令清单

下面只保留本文双相机方案的最短操作路径,方便复现实验时直接查命令。它对应 front + side + 1 masked empty、SO100 六维绝对关节动作、100 个 episode、全参数训练和 SDK 1.0.2。若相机数量、任务文本或 checkpoint 改变,需要重新生成校准集、三份 HBM、deployment manifest 和 hash,不能直接复用本文最终 artifact。

A.1 在 S600 上检查设备

cd /root/rdk_LeRobot_tools
git checkout s600

ls -l /dev/ttyACM* /dev/serial/by-id/*
v4l2-ctl --list-devices

本文设备映射为:

follower:/dev/ttyACM0
leader:  /dev/ttyACM1
front:   /dev/video0
side:    /dev/video2

A.2 采集 100 组双相机数据

cd /home/sunrise/lerobot
.venv/bin/lerobot-record \
  --robot.type=so100_follower \
  --robot.port=/dev/ttyACM0 \
  --robot.id=so100_follower \
  --robot.calibration_dir=/root/rdk_LeRobot_tools/models/pi0/calibration/robots/so_follower \
  --robot.max_relative_target=20 \
  --robot.cameras='{front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30, warmup_s: 10, fourcc: MJPG}, side: {type: opencv, index_or_path: 2, width: 640, height: 480, fps: 30, warmup_s: 10, fourcc: MJPG}}' \
  --teleop.type=so100_leader \
  --teleop.port=/dev/ttyACM1 \
  --teleop.id=so100_leader \
  --teleop.calibration_dir=/root/rdk_LeRobot_tools/models/pi0/calibration/teleoperators/so_leader \
  --display_data=false \
  --play_sounds=false \
  --dataset.fps=30 \
  --dataset.repo_id=local/so100_rdk_camera_box_on_black_mcu_pi0_v5_2cam_100ep_10s \
  --dataset.root=/root/rdk_LeRobot_tools/models/pi0/datasets/so100_rdk_camera_box_on_black_mcu_pi0_v5_2cam_100ep_10s \
  --dataset.num_episodes=100 \
  --dataset.single_task='Place the RDK camera box on top of the black MCU box.' \
  --dataset.episode_time_s=10 \
  --dataset.reset_time_s=5 \
  --dataset.video=true \
  --dataset.push_to_hub=false \
  --dataset.streaming_encoding=true \
  --dataset.encoder_threads=2

采集完成后,把整个数据集目录同步到训练服务器;meta/data/videos/ 必须一起传输:

rsync -aP \
  /root/rdk_LeRobot_tools/models/pi0/datasets/so100_rdk_camera_box_on_black_mcu_pi0_v5_2cam_100ep_10s/ \
  <GPU_USER>@<GPU_SERVER_IP>:/home/<GPU_USER>/datasets/so100_rdk_camera_box_on_black_mcu_pi0_v5_2cam_100ep_10s/

A.3 在 GPU 服务器全参数训练

export LEROBOT_ROOT=/home/<GPU_USER>/lerobot
export RDK_TOOLS=$LEROBOT_ROOT/rdk_LeRobot_tools
export CONDA=/home/<GPU_USER>/miniforge3/bin/conda
export DATASET_NAME=so100_rdk_camera_box_on_black_mcu_pi0_v5_2cam_100ep_10s
export DATASET_ROOT=/home/<GPU_USER>/datasets/$DATASET_NAME
export BASE_MODEL=/home/<GPU_USER>/gemma/pi0_base
export OUTPUT_DIR=$LEROBOT_ROOT/outputs/train/pi0_so100_2cam_full_b1_15k

cd "$RDK_TOOLS"
git checkout s600

LEROBOT_ROOT="$LEROBOT_ROOT" \
DATASET_NAME="$DATASET_NAME" \
DATASET_ROOT="$DATASET_ROOT" \
BASE_MODEL="$BASE_MODEL" \
OUTPUT_DIR="$OUTPUT_DIR" \
STEPS=15000 \
BATCH_SIZE=1 \
WANDB_ENABLE=true \
WANDB_PROJECT=lerobot-pi0-so100 \
WANDB_ENTITY=<WANDB_ENTITY> \
bash models/pi0/tools/train_pi0_full_v5_2cam_100ep.sh

最终 checkpoint:

export CHECKPOINT=$OUTPUT_DIR/checkpoints/015000/pretrained_model
test -f "$CHECKPOINT/config.json"

同时从该训练集导出 standalone runtime 使用的六维 state/action 统计量:

export NORM_STATS=$OUTPUT_DIR/norm_stats.json

$CONDA run --no-capture-output -n lerobot python -c '
import json, os
from pathlib import Path

stats = json.loads((Path(os.environ["DATASET_ROOT"]) / "meta/stats.json").read_text())
keys = ("mean", "std", "q01", "q99")
result = {
    "norm_stats": {
        "state": {key: stats["observation.state"][key] for key in keys},
        "actions": {key: stats["action"][key] for key in keys},
    }
}
Path(os.environ["NORM_STATS"]).write_text(json.dumps(result, indent=2) + "\n")
'

A.4 生成 50 条真实校准样本

export CALIB_DIR=/home/<GPU_USER>/gemma/calibration_data/pi0_full_v5_2cam_100ep_015000_real50

cd "$RDK_TOOLS"
$CONDA run --no-capture-output -n lerobot \
python models/pi0/tools/prepare_pi0_calibration_v5_2cam.py \
  --dataset-root "$DATASET_ROOT" \
  --checkpoint "$CHECKPOINT" \
  --output-dir "$CALIB_DIR" \
  --repo-id "local/$DATASET_NAME" \
  --samples 50 \
  --camera-keys front side \
  --camera-slots 3 \
  --task 'Place the RDK camera box on top of the black MCU box.'

A.5 在 SDK 1.0.2 工具链中级联量化

先把 checkpoint 和真实校准集映射到工具链容器,并统一设置两路有效相机:

export PI0_VALID_CAMERA_SLOTS=2
export RDK_TOOLS=/host/lerobot/rdk_LeRobot_tools
export MODEL_DIR=/host/gemma/input/pi0_full_v5_2cam_100ep_015000
export CALIB_DIR=/host/gemma/calibration_data/pi0_full_v5_2cam_100ep_015000_real50
export QUANT_OUT=/host/gemma/output/pi0_full_v5_2cam_100ep_015000_sdk102
mkdir -p "$QUANT_OUT"
cd "$RDK_TOOLS"

1. SigLIP

python3 models/pi0/tools/quantize_siglip_real_calib.py \
  --model-dir "$MODEL_DIR" \
  --calibration-dir "$CALIB_DIR" \
  --output-dir "$QUANT_OUT/siglip" \
  --device cuda:0 \
  --vision-tokens-num 256 \
  --valid-camera-slots 2 \
  --max-samples 50 \
  --jobs 20 \
  --march nash-p \
  --max-l2m-size 0 \
  --patch-embedding-mode quant8 \
  --position-embedding-mode fp16 \
  --attention-linear-mode dynamic \
  --mlp-linear-mode dynamic \
  --attention-matmul-mode dynamic \
  --projector-linear-mode dynamic \
  --layernorm-mode standard

把新 SigLIP HBM 和 50 条校准样本放到 S600,并准备只绑定该阶段 artifact 的临时 deployment JSON。两个板端终端分别执行:

# 终端 A:等待 C++ engine,并保存真实 SigLIP HBM 输出
cd /root/rdk_LeRobot_tools/models/pi0
/home/sunrise/lerobot/.venv/bin/python -u dump_siglip_hbm_calibration.py \
  --calibration-dir calibration_data/pi0_full_v5_2cam_100ep_015000_real50 \
  --engine-dump-dir /root/pi0_calibration/engine_siglip_real50 \
  --output-dir calibration_data/pi0_full_v5_2cam_100ep_015000_siglip_hbm_real50 \
  --prompt 'Place the RDK camera box on top of the black MCU box.'
# 终端 B:使用新 SigLIP HBM 的临时配置启动 engine
cd /root/rdk_LeRobot_tools/models/pi0
PI0_STANDALONE_DUMP_DIR=/root/pi0_calibration/engine_siglip_real50 \
./run_pi0_standalone_config.sh configs/deployments/<SIGLIP_STAGE_CONFIG>.json

把板端生成的 pi0_full_v5_2cam_100ep_015000_siglip_hbm_real50 目录传回量化服务器,供 PaliGemma 校准使用。

2. PaliGemma

export SIGLIP_HBM_CALIB=/host/gemma/calibration_data/pi0_full_v5_2cam_100ep_015000_siglip_hbm_real50

python3 models/pi0/tools/quantize_paligemma_real_calib.py \
  --model-dir "$MODEL_DIR" \
  --calibration-dir "$CALIB_DIR" \
  --vision-embeddings-dir "$SIGLIP_HBM_CALIB" \
  --output-dir "$QUANT_OUT/paligemma" \
  --device cuda:0 \
  --vision-tokens-num 256 \
  --max-samples 50 \
  --num-hidden-layers 18 \
  --jobs 20 \
  --march nash-p \
  --compile-opt 2 \
  --compile-balance 2 \
  --compile-max-l2m-size 0 \
  --max-hbm-bytes 2140000000 \
  --attention-linear-mode dynamic \
  --mlp-linear-mode dynamic \
  --mlp-down-linear-mode dynamic \
  --attention-matmul-mode fixed16 \
  --fixed-prompt 'Place the RDK camera box on top of the black MCU box.' \
  --prompt-embedding-input

把新 PaliGemma HBM 和 SigLIP HBM 一起部署到 S600,再导出 Expert 实际收到的 36 组 KV:

# 终端 A
cd /root/rdk_LeRobot_tools/models/pi0
/home/sunrise/lerobot/.venv/bin/python -u dump_siglip_hbm_calibration.py \
  --calibration-dir calibration_data/pi0_full_v5_2cam_100ep_015000_real50 \
  --engine-dump-dir /root/pi0_calibration/engine_paligemma_kv_real50 \
  --output-dir calibration_data/pi0_full_v5_2cam_100ep_015000_siglip_fixed16_paligemma_hbm_kv_real50 \
  --prompt 'Place the RDK camera box on top of the black MCU box.' \
  --save-expert-kv
# 终端 B
cd /root/rdk_LeRobot_tools/models/pi0
PI0_STANDALONE_DUMP_DIR=/root/pi0_calibration/engine_paligemma_kv_real50 \
./run_pi0_standalone_config.sh configs/deployments/<PALIGEMMA_STAGE_CONFIG>.json

把生成的 50×36 个 KV 文件传回量化服务器。

3. Expert

export PALIGEMMA_HBM_KV=/host/gemma/calibration_data/pi0_full_v5_2cam_100ep_015000_siglip_fixed16_paligemma_hbm_kv_real50

python3 models/pi0/tools/quantize_expert_real_calib.py \
  --model-dir "$MODEL_DIR" \
  --calibration-dir "$CALIB_DIR" \
  --paligemma-kv-dir "$PALIGEMMA_HBM_KV" \
  --output-dir "$QUANT_OUT/expert" \
  --device cuda:0 \
  --vision-tokens-num 256 \
  --max-samples 50 \
  --jobs 20 \
  --march nash-p \
  --transformer-linear-mode dynamic \
  --projection-linear-mode dynamic \
  --attention-matmul-mode dynamic \
  --output-linear-mode dynamic

量化顺序不能交换:

SigLIP HBM
  → 板端真实 vision embedding
  → PaliGemma HBM
  → 板端真实 36 组 KV
  → Expert HBM

A.6 部署并验证新 HBM

将三份 HBM、PaliGemma 生成的 prompt embedding、checkpoint 对应的 normalization stats 放入新的版本目录,不覆盖旧版本:

ssh root@<S600_IP> 'mkdir -p /root/pi0_models/versions/<BUNDLE_NAME>/{siglip,paligemma,expert}'

scp "$QUANT_OUT/siglip/pi0_siglip_ptq.hbm" \
  root@<S600_IP>:/root/pi0_models/versions/<BUNDLE_NAME>/siglip/
scp "$QUANT_OUT/paligemma/pi0_gemma_llm_ptq.hbm" \
  root@<S600_IP>:/root/pi0_models/versions/<BUNDLE_NAME>/paligemma/
scp "$QUANT_OUT/paligemma/fixed_prompt_embedding.bin" \
  root@<S600_IP>:/root/pi0_models/versions/<BUNDLE_NAME>/paligemma/
scp "$QUANT_OUT/expert/pi0_gemma_expert_ptq.hbm" \
  root@<S600_IP>:/root/pi0_models/versions/<BUNDLE_NAME>/expert/
scp "$NORM_STATS" \
  root@<S600_IP>:/root/rdk_LeRobot_tools/models/pi0/configs/norm_stats_<BUNDLE_NAME>.json

为新 bundle 新建 deployment JSON 与 manifest,写入实际路径、文件大小和 SHA256;若要复用 run_live_sync.sh,还必须同步更新其中的 expected hashes。先做静态检查:

cd /root/rdk_LeRobot_tools/models/pi0
/home/sunrise/lerobot/.venv/bin/python validate_pi0_config.py \
  configs/deployments/<NEW_DEPLOYMENT_CONFIG>.json

再做不访问机械臂串口的双图整链 smoke test:

cd /root/rdk_LeRobot_tools/models/pi0
/home/sunrise/lerobot/.venv/bin/python -u pi0_standalone_offline.py \
  --config configs/deployments/<NEW_DEPLOYMENT_CONFIG>.json \
  --front /path/to/front.jpg \
  --side /path/to/side.jpg \
  --state 0 0 0 0 0 0 \
  --output-dir diagnostics/offline_smoke_$(date +%Y%m%d_%H%M%S)

预期动作输出 shape:

[50,6]

A.7 启动已验证的 S600 同步真机推理

cd /root/rdk_LeRobot_tools/models/pi0
./run_live_sync.sh