> ## Documentation Index
> Fetch the complete documentation index at: https://dragonwingdocs-staging.qualcomm.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 在 Dragonwing IQ-9075 NPU 上运行 Pi0.5 VLA 模型

> 将 Pi0.5 这个 30 亿参数的视觉-语言-动作模型，从下载到生成机器人运动，完全在 Dragonwing IQ-9075 EVK 的 Hexagon NPU 上运行：1.1 s 动作块，4.3 倍实时速度，无 CPU 回退。

<div style={{ marginBottom: "2rem" }}>
  <div
    style={{
fontSize: "0.72rem",
fontWeight: 700,
color: "#31017D",
letterSpacing: "1.5px",
textTransform: "uppercase",
marginBottom: "0.5rem"
}}
  >
    Robotics
  </div>

  <div style={{ fontSize: "0.85rem", color: "#888", display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
    <a href="https://www.linkedin.com/in/rami-mouro/" target="_blank" rel="noopener noreferrer" style={{ color: "#888", textDecoration: "none" }}>Rami Mouro</a>
    <span>·</span>
    <span>2026 年 7 月 29 日</span>
    <span>·</span>
    <a href="/zh/tutorial" style={{ color: "#31017D", fontWeight: 600, textDecoration: "none" }}>← 全部文章</a>
  </div>
</div>

<hr style={{ border: "none", borderTop: "1px solid #eee", margin: "0 0 2rem" }} />

在 Qualcomm Dragonwing IQ-9075 EVK 上一步一步地搭建一个视觉-语言-动作模型，直到它在开发板的 Hexagon NPU 上生成机器人运动。这里的一切都是自包含的 — 你需要的每一条命令和每一行代码都在本页上，而且每个步骤都以可核对的输出结尾。

我们使用 [Pi0.5](https://aihub.qualcomm.com/models/pi05)，一个 30 亿参数的机器人基础模型：给它摄像头图像和一条普通英语指令，它就输出关节运动。到达第一次 NPU 推理大约需要十分钟。本页的最后三分之一才是有趣的部分 — 这个模型的四个组成部分*为什么*会以那样的方式分布在硬件上。

<Note>
  本页上的每个数字都是在运行 Ubuntu 24.04 Server 的真实 IQ-9075 EVK 上测量的。取自 Qualcomm AI Hub 自身发布的性能分析数据均已标注，绝不会与我们的数据混在一起。
</Note>

## 你最终会得到什么

|                      | 测量值                                                         |
| -------------------- | ----------------------------------------------------------- |
| 动作块（50 个时间步 × 7 DoF） | **1111–1152 ms**，0.87–0.90 块/秒                              |
| 相对实时的速度              | **4.3–4.5×** — LIBERO 以 10 Hz 运行，因此 50 步等于 5 s 的运动          |
| 正确性                  | **45 个输出张量中的 45 个与 Qualcomm 的参考运行器逐位一致**                    |
| 闭环任务成功率              | 在全部十个 LIBERO-10 任务上 **100 个 episode 中成功 86 个**，同一块开发板上驱动模拟器 |
| CPU 回退               | 无；100% NPU                                                  |

## 开始之前

| 要求                     | 说明                                                                                                                                                                  |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dragonwing IQ-9075 EVK | 启动 **Ubuntu 24.04 Server** 或更新版本，arm64。无头模式即可 — 这里没有任何东西需要显示器                                                                                                       |
| 一块你已经完成启动的开发板          | 先完成 Ubuntu 文档中的[通用前提条件](/zh/Ubuntu/ubuntu-supported-hardware#common-prerequisites)，然后[设置设备](/zh/Ubuntu/devices/iq9075-evk/set-up-the-device)，使开发板已刷机、已联网并可通过 SSH 访问 |
| 约 35 GB 可用磁盘空间         | 模型为 2.9 GB；其余用于构建和转储                                                                                                                                                |
| 网络                     | 约 3 GB 的下载量                                                                                                                                                         |

下面的每条命令都**在开发板上**运行，通过 SSH 或串口控制台。不需要 Qualcomm AI Hub 账户。没有云端编译作业。不需要浏览器。

设置一个本页其余部分都会引用的工作目录：

```bash theme={null} theme={null}
export WORK=$HOME/pi05
mkdir -p $WORK && cd $WORK
```

## 1. 安装 ROS 2 Jazzy 和 Qualcomm AI 运行时

从 ROS 2 开始。这些是[软件设置](/zh/Ubuntu/robotics-workflows/software-setup)页面上的命令：

```bash theme={null} theme={null}
sudo apt-get update
sudo apt-get install -y curl gnupg2 lsb-release ca-certificates software-properties-common locales
sudo locale-gen en_US en_US.UTF-8
sudo add-apt-repository universe -y
sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key \
  -o /usr/share/keyrings/ros-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] \
http://packages.ros.org/ros2/ubuntu $(. /etc/os-release && echo $UBUNTU_CODENAME) main" \
  | sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null
sudo apt-get update
sudo apt-get install -y ros-jazzy-ros-base python3-colcon-common-extensions ros-dev-tools
```

<Warning>
  **使用 `http://` 而不是 `https://` 是刻意的。** `packages.ros.org` 是指向 `ftp.osuosl.org` 的 CNAME，后者的 TLS 证书只覆盖 `*.osuosl.org`。在没有 CDN 掩盖这一不匹配的网络上，`https://` 会导致证书验证失败。软件包完整性来自 GPG 签名，这才是 `apt` 实际验证的内容 — Qualcomm 官方设置页面出于同样的原因使用 `http://`。
</Warning>

接下来是 Qualcomm AI Runtime（QAIRT），它包含与 Hexagon NPU 通信的 QNN 库：

```bash theme={null} theme={null}
sudo apt-get install -y qairt-libs qairt-tools qairt-headers
```

<Note>
  `ppa:ubuntu-qcom-iot/qcom-ppa` **在 IQ-9075 的出厂镜像上已经存在**，所以你不需要添加它。第二次添加会产生致命错误 `E: Conflicting values set for option Trusted`。如果遇到这个问题，列出 `/etc/apt/sources.list.d/` 并删除重复项。
</Note>

确认 NPU 可访问，并且你拥有正确的 Hexagon 架构。IQ-9075 是 **v73**：

```bash theme={null} theme={null}
ls /dev/fastrpc-cdsp /dev/fastrpc-cdsp1
ls /usr/lib/libQnnHtpV73Stub.so
```

**预期输出：**

```
/dev/fastrpc-cdsp  /dev/fastrpc-cdsp1
/usr/lib/libQnnHtpV73Stub.so
```

注意有**两个** CDSP 设备节点。记住这一点 — 它稍后会成为整个故事的核心。

<Tip>
  推理以普通用户身份即可运行；你不需要 root。如果在 `/dev/fastrpc-cdsp` 上遇到权限错误，用 `sudo usermod -aG fastrpc $(id -un)` 将自己加入 `fastrpc` 组，然后注销并重新登录。
</Tip>

## 2. 无需 AI Hub 账户获取 Pi0.5

要将*你自己的*模型带到 NPU 上，需要在 AI Hub 中运行按用户的云端编译作业。而对于已发布的模型则不需要：**Qualcomm** 为其众多芯片预编译了 **QNN 上下文二进制文件**，并通过 [AI Hub Models](https://aihub.qualcomm.com/models) 提供，任何人无需账户即可下载。`qualcomm-qcs9075` — 即 IQ-9075 EVK — 是 Pi0.5 的 `default_device`，因此获取模型只是一次普通的 HTTPS 下载：

```bash theme={null} theme={null}
pip install --user qai-hub-models
cd $WORK
qai-hub-models fetch Pi0.5 --runtime qnn_context_binary --precision mixed --chipset qualcomm-qcs9075
export BUNDLE=$WORK/pi05-qnn_context_binary-mixed-qualcomm_qcs9075
ls -l $BUNDLE
```

**预期输出** — 四个 `.bin` 文件和一个元数据文件，共 2.9 GB：

```
action_expert.bin     439205888
backbone.bin          979836928
metadata.json             25536
token_emb.bin        1055449088
vision_encoder.bin    540233728
```

**Pi0.5 不是一个模型 — 它是四个。** 这决定了后面的一切：

| 组件                   |  MiB | 它做什么                       |
| -------------------- | ---: | -------------------------- |
| `vision_encoder.bin` |  515 | 将一张摄像头图像转换为 256 个嵌入 token  |
| `token_emb.bin`      | 1007 | 嵌入语言 token，构建注意力掩码和 RoPE 表 |
| `backbone.bin`       |  934 | 语言模型 prefill；输出 18 层 KV 缓存 |
| `action_expert.bin`  |  419 | 将带噪声的动作块去噪为真实的动作块          |

量化是混合的：backbone 使用 w4a16，vision encoder 和 action expert 使用 w8a16。

<Note>
  该 bundle 记录了 `SDK build=v2.45.0.260326154327`。它可以在 apt 提供的 QAIRT **2.46.0** 上原样运行，因此你不需要精确匹配 SDK 版本。
</Note>

## 3. 在 NPU 上运行模型的第一个部分

在编写任何代码之前，先用随 `qairt-tools` 提供的 `qnn-net-run` 证明硬件正常工作。

vision encoder 接收一张 RGB 图像，224×224，通道优先，float32，归一化到 \[-1, 1]。制作一张：

```bash theme={null} theme={null}
mkdir -p $WORK/run && cd $WORK/run
python3 -c "
import numpy as np
np.random.seed(0)
np.random.uniform(-1, 1, (1, 3, 224, 224)).astype('float32').tofile('image.raw')
open('input_list.txt', 'w').write('image:=image.raw\n')
"
qnn-net-run --backend /usr/lib/libQnnHtp.so \
  --retrieve_context $BUNDLE/vision_encoder.bin \
  --input_list input_list.txt --output_dir out
```

**预期输出**以以下内容结尾：

```
Executing Graphs
Finished Executing Graphs
```

并且你得到了一个真实的张量：

```bash theme={null} theme={null}
ls -l out/Result_0/img_embed.raw
```

**预期输出：** `2097152` 字节 — 恰好是 `1 × 256 × 2048` 个 float32 值。

## 4. 弄清楚你到底要连接什么

你现在有四个模型，却不知道它们如何连接。不要猜，也不要相信 `metadata.json` 的键顺序 — 直接问二进制文件本身。`qnn-context-binary-utility` 会转储上下文二进制文件声明的输入和输出，**且顺序正是编译后的计算图所期望的**：

```bash theme={null} theme={null}
cd $WORK/run
for m in vision_encoder token_emb backbone action_expert; do
  qnn-context-binary-utility --context_binary=$BUNDLE/$m.bin --json_file=$m.json >/dev/null 2>&1
done
```

然后打印计算图顺序：

```bash theme={null} theme={null}
python3 - <<'PY'
import json
for m in ("vision_encoder", "token_emb", "backbone", "action_expert"):
    info = json.load(open(f"{m}.json"))["info"]["graphs"][0]["info"]
    print(f"\n=== {m} ===")
    for kind in ("graphInputs", "graphOutputs"):
        names = [t["info"]["name"] for t in info.get(kind) or []]
        print(f"  {kind} ({len(names)}):")
        print("   ", ", ".join(names))
PY
```

**预期输出**（节选 — `action_expert` 有 41 个输入）：

```
=== vision_encoder ===
  graphInputs (1):   image
  graphOutputs (1):  img_embed

=== token_emb ===
  graphInputs (5):   lang_tokens, img_embed1, img_embed2, img_embed3, lang_mask
  graphOutputs (7):  prefix_emb, prefix_att_2d, prefix_sin, prefix_cos, suffix_sin, suffix_cos, full_att_4d

=== backbone ===
  graphInputs (4):   prefix_att_2d_masks, hidden_state, rope_emb_cos, rope_emb_sin
  graphOutputs (36): k_cache_l0, k_cache_l1, ... k_cache_l17, v_cache_l0, ... v_cache_l17

=== action_expert ===
  graphInputs (41):  x_t, time_step, key_cache_l0, key_cache_l1, key_cache_l10, key_cache_l11,
                     ... key_cache_l17, key_cache_l2, ... key_cache_l9, value_cache_l0, ...,
                     rope_emb_cos, rope_emb_sin, full_att_4d
  graphOutputs (1):  action_emb
```

**再读一遍 `action_expert` 的输入顺序。** 它是 `l0, l1, l10, l11 … l17, l2, l3 … l9` — 按字符串排序，而不是数字排序。生成这些缓存的 `backbone` 以 `l0, l1, l2 … l17` 的顺序输出它们。如果按生产者顺序接线，你会悄无声息地打乱 18 层中的 12 层。不会报错。模型照常运行。但动作是垃圾。

这就是为什么要从二进制文件生成排序，而不是手动输入。

## 流水线如何组合

```mermaid theme={null} theme={null}
flowchart LR
    IMG["3 × camera<br/>224×224"] --> VE["vision_encoder<br/>×3"]
    VE -->|"img_embed1..3"| TE["token_emb"]
    TOK["lang_tokens<br/>(task + state)"] --> TE
    TE -->|"prefix_emb"| BB["backbone"]
    BB -->|"18-layer KV cache"| AE["action_expert<br/>×10 denoise"]
    TE -->|"full_att_4d,<br/>suffix_sin/cos"| AE
    AE --> OUT["50 × 32<br/>action chunk"]
    classDef hero fill:#31017D,stroke:#31017D,color:#fff,stroke-width:1.5px;
    classDef pkg fill:#F4EFFA,stroke:#31017D,color:#31017D,stroke-width:1.5px;
    class VE,TE,BB,AE hero;
    class IMG,TOK,OUT pkg;
```

一个动作块是 **15 次 NPU 调用**：三次视觉推理（每个摄像头槽位一次）、一次 token 嵌入、一次 backbone prefill，然后是经过 action expert 的十次去噪步骤。值得了解的常量：

| 常量        | 值       | 来源                                      |
| --------- | ------- | --------------------------------------- |
| 摄像头       | 3       | 2 个真实 + 1 个 `empty_cameras` 槽位；未使用的槽位填零 |
| 语言 token  | 200     | 用 id 0 右侧填充                             |
| `src_len` | 968     | `256 × 3 个摄像头 + 200 个 token`            |
| 动作块       | 50 × 32 | 32 是最大动作维度；真实 DoF 是前缀（Franka 为 7）       |
| 去噪步数      | 10      | 策略配置中的 `num_inference_steps`            |

除了排序陷阱之外，还有两个重命名要注意：`token_emb` 输出 `prefix_emb`，但 `backbone` 称其为 `hidden_state`，而 `prefix_att_2d` 变成了 `prefix_att_2d_masks`。`suffix_sin`/`suffix_cos` 到达 action expert 时变为 `rope_emb_sin`/`rope_emb_cos`。

## 机器人的状态去了哪里

这四个计算图中没有任何状态输入张量。Pi0.5 将本体感觉离散化为 256 个桶，并将其拼接进**语言提示词**中。这是其格式，复制自上游 [openpi](https://github.com/Physical-Intelligence/openpi) 实现：

```python theme={null} theme={null}
cleaned = prompt.strip().replace("_", " ").replace("\n", " ")
discretized = np.digitize(state, np.linspace(-1, 1, 257)[:-1]) - 1
full = f"Task: {cleaned}, State: {' '.join(map(str, discretized))};\nAction: "
tokens = tokenizer.encode(full, add_bos=True)   # right-pad with 0 to 200
```

所以提示词 — 以及由此而来的 token id — 在**每个控制步**都会改变。你不能预先计算一张分词后指令的查找表并在闭环中使用；分词器必须内联运行。

分词器是标准的 PaliGemma SentencePiece 模型。Hugging Face 上的 `google/paligemma-3b-pt-224` 是受限的（未接受许可协议时返回 HTTP 401），但完全相同的文件在 Google 的 `big_vision` 存储桶中无需认证即可获取 — openpi 也是从那里获取的：

```bash theme={null} theme={null}
cd $WORK
curl -fSL -o paligemma_tokenizer.model \
  https://storage.googleapis.com/big_vision/paligemma_tokenizer.model
sha256sum paligemma_tokenizer.model
```

**预期输出：** `8986bb4f423f07f8c7f70d0dbe3526fb2316056c17bae71b1ea975e77a168fc6`。

为一个任务生成 token id，并折叠进一个状态向量：

```bash theme={null} theme={null}
pip install --user sentencepiece
cd $WORK/run
python3 - <<'PY'
import numpy as np, sentencepiece as spm
sp = spm.SentencePieceProcessor(model_file="../paligemma_tokenizer.model")
state = np.array([0.1, -0.2, 0.3, 0.0, 0.5, -0.9, 0.25, 0.0], dtype=np.float32)
bins = np.digitize(state, np.linspace(-1, 1, 257)[:-1]) - 1
prompt = f"Task: pick up the black bowl, State: {' '.join(map(str, bins))};\nAction: "
ids = sp.encode(prompt, add_bos=True)
print(f"{len(ids)} tokens:", ids[:12], "...")
tokens = np.zeros(200, dtype=np.int32); tokens[:len(ids)] = ids
mask = np.zeros(200, dtype=np.float32); mask[:len(ids)] = 1.0
tokens.tofile("lang_tokens.raw"); mask.tofile("lang_mask.raw")
PY
```

**预期输出：**

```
47 tokens: [2, 7071, 235292, 4788, 908, 573, 2656, 14581, 235269, 3040, 235292, 235248] ...
```

<Warning>
  如果将这些输入喂给 `qnn-net-run`，你**必须**传入 `--use_native_input_files`。默认情况下，它将每个输入文件解析为 float32，然后转换为计算图的 dtype。对于 int32 的 `lang_tokens`，这会把真实的 token id 变成零 — 也就是全部填充 — 于是模型悄无声息地在没有任何条件的情况下推理。症状很隐蔽：输出只在对应真实语言 token 的行上有差异，其值等于填充嵌入。
</Warning>

## 5. 从你自己的代码运行模型

`qnn-net-run` 是一个测试工具；它读文件、写文件。要串联四个模型，你需要它们在同一个进程中。做这件事的 QRB ROS 包是 `qrb_inference_manager`，它的整个 API 只有三个调用。

安装它，以及包装它的 ROS 节点：

```bash theme={null} theme={null}
sudo add-apt-repository -y ppa:ubuntu-qcom-iot/qirp
sudo apt-get update
sudo apt-get install -y ros-jazzy-qrb-ros-nn-inference libsentencepiece-dev
```

<Warning>
  **apt 版本无法运行这个模型。** apt 中的 `ros-jazzy-qrb-inference-manager` 是 1.1.1；上游是 2.2.0。版本 1.1.1 拒绝 **int32** 张量输入，而这正是 `token_emb` 的 `lang_tokens` 所需要的。我们在这里安装它是为了获取头文件和了解 API，然后在步骤 8 中从源码构建 — 那一步无论如何都是必需的。
</Warning>

将这个文件写为 `$WORK/minimal_npu.cpp`。这是完整的程序：

```cpp theme={null} theme={null}
// The entire QRB ROS NPU API: construct, execute, read.
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>

#include "qrb_inference_manager.hpp"

int main(int argc, char ** argv)
{
  if (argc < 2) { std::fprintf(stderr, "usage: minimal_npu <model.bin>\n"); return 2; }

  // vision_encoder wants one RGB image, 224x224, CHW, float32, in [-1, 1].
  constexpr int kElems = 3 * 224 * 224;
  std::vector<float> image(kElems, 0.5F);          // flat grey is enough to prove it runs
  std::vector<uint8_t> input(image.size() * sizeof(float));
  std::memcpy(input.data(), image.data(), input.size());

  // 1. load onto the NPU. "libQnnHtp.so" is what selects the Hexagon.
  qrb::inference_mgr::QrbInferenceManager mgr(argv[1], "libQnnHtp.so");

  // 2. run it. inference_execute() takes ONE flat buffer and slices it across
  //    the graph's inputs in compiled graph order.
  if (!mgr.inference_execute(input)) { std::fprintf(stderr, "execute failed\n"); return 1; }

  // 3. read the results back.
  for (const auto & t : mgr.get_output_tensors()) {
    std::printf("%s: %zu bytes, shape [", t.output_tensor_name.c_str(),
                t.output_tensor_data.size());
    for (size_t i = 0; i < t.output_tensor_shape.size(); ++i)
      std::printf("%s%u", i ? ", " : "", t.output_tensor_shape[i]);
    const auto * v = reinterpret_cast<const float *>(t.output_tensor_data.data());
    std::printf("]  first values: %.4f %.4f %.4f\n", v[0], v[1], v[2]);
  }
  return 0;
}
```

构建并运行它 — 一行 `g++`，无需 CMake：

```bash theme={null} theme={null}
cd $WORK
g++ -std=c++17 -O2 -I/opt/ros/jazzy/include minimal_npu.cpp \
  -L/opt/ros/jazzy/lib -lqrb_inference_manager \
  -Wl,-rpath,/opt/ros/jazzy/lib -o minimal_npu
QNN_HTP_BURST=1 ./minimal_npu $BUNDLE/vision_encoder.bin
```

**预期输出：**

```
img_embed: 2097152 bytes, shape [1, 256, 2048]  first values: 6.9219 -0.7668 -0.6043
```

三个 API 调用就把一个 30 亿参数模型的视觉编码器放到了 NPU 上。没有会话设置，没有委托注册，没有图构建器，也不需要编写设备管理。

<Tip>
  `QNN_HTP_BURST=1` 将 HTP 锁定在其 TURBO 性能级别。没有它，NPU 会以默认的 DCVS 设置运行，每个阶段都会明显变慢。它在上下文创建时被读取，因此必须在进程启动之前就位于环境中。
</Tip>

## 6. 串联四个模型

这是你需要自己编写的部分，而且大部分是簿记工作。它的形态如下：

```cpp theme={null} theme={null}
// Per action chunk, in order:
//
//  1. for cam in 0..2:  vision_encoder(image[cam])        -> img_embed[cam]
//     (zero-fill any camera slot you do not have; the graph arity is fixed at 3)
//
//  2. token_emb(lang_tokens, img_embed[0..2], lang_mask)
//       -> prefix_emb, prefix_att_2d, prefix_sin, prefix_cos,
//          suffix_sin, suffix_cos, full_att_4d
//
//  3. backbone(prefix_att_2d      -> prefix_att_2d_masks,
//              prefix_emb         -> hidden_state,
//              prefix_cos         -> rope_emb_cos,
//              prefix_sin         -> rope_emb_sin)
//       -> k_cache_l0..17, v_cache_l0..17          (each [1, 968, 1, 256])
//
//  4. x_t = gaussian noise, shape [1, 50, 32];  t = 1.0;  dt = -1.0 / 10
//     repeat 10 times:
//       action_expert(x_t, t,
//                     k_cache_l*  -> key_cache_l*     IN LEXICOGRAPHIC ORDER,
//                     v_cache_l*  -> value_cache_l*   IN LEXICOGRAPHIC ORDER,
//                     suffix_cos  -> rope_emb_cos,
//                     suffix_sin  -> rope_emb_sin,
//                     full_att_4d -> full_att_4d)
//         -> action_emb            // already the Euler-updated x_{t+dt}
//       x_t = action_emb;  t += dt
//
//  5. x_t is your action chunk: 50 timesteps x 32 dims. The real degrees of
//     freedom are the first `action_dof` columns (7 for a Franka Panda);
//     everything after that is padding and must be ignored.
```

两个能节省实际时间的实现说明：

* **除 `x_t` 和 `time_step` 之外的一切在十个去噪步骤中都是常量。** 将约 34 MB 的 KV 缓存打包进 expert 的输入缓冲区一次，之后每步只重写 6404 字节。
* **expert 直接返回 `x_{t+dt}`。** 它在内部应用了 Euler 更新，因此主机侧不需要 `x += dt * v`。

按照你在步骤 4 中转储的计算图顺序拼接张量来构建每个阶段的扁平输入缓冲区 — 这是 `inference_execute()` 强制执行的契约，如果总大小不对它会告诉你：

```
Total size of all input tensors should be 35945556 bytes, but receive 35945552 bytes
```

## 7. 撞墙：一个 NPU 装不下 Pi0.5

当四个上下文都在一个进程中构建后，你会得到：

```text theme={null} theme={null}
fastrpc memory map for fd: 39 with length: 434110464 failed with error: 0x1
SharedMemoryMod failed to Map Buffer to SMMU for domain 0
Failed to map weights buffer to device!
Failed to initialize graph with id 256 context 4 deviceId 0 ... with err 1002
```

这四个二进制文件包含 **2875 MiB** 的权重。这是 **DSP 地址空间，而不是主机 RAM** — 即使系统内存还有 34 GB 空闲、以 root 身份运行，它也会失败，而每个二进制文件单独加载都完全正常。在设备 0 上测量：

|       峰值并发映射权重 | 结果                     |
| -------------: | ---------------------- |
|       1523 MiB | 正常                     |
|       1941 MiB | `Failed to map buffer` |
| 2875 MiB（全部四个） | 硬性失败                   |

它也不是一个干净的容量限制：反复的映射/取消映射会使地址空间碎片化，因此相同的总量会因进程之前做了什么而成功或失败。

<Warning>
  **权重映射失败的上下文仍然报告成功。** 日志中会出现 `Initialize Qnn graph from binary file successfully`，而它交回的句柄会产生长度错乱的输出向量 — 这会在很晚之后以你自己代码中的内存错误浮现出来。防御方法：将你实际得到的输出张量数量与计算图在步骤 4 中声明的数量进行比较，任何不匹配都视为硬性错误。
</Warning>

显而易见的变通办法是在每次使用前后创建和销毁上下文。它能工作，但很慢 — 权重加载占主导：

| 配置                      | ms/块 | 其中权重换页 |
| ----------------------- | ---: | -----: |
| 每个上下文换入换出               | 4421 | \~3245 |
| 仅保持 `vision_encoder` 常驻 | 3277 | \~2258 |

三分之二的时间花在了不计算上。

## 8. 解锁第二个 NPU

还记得步骤 1 中的两个设备节点。问题是 QNN 是否允许你*指定*它们作为目标。直接问它 — 编写 `$WORK/devices.cpp`：

```cpp theme={null} theme={null}
#include <dlfcn.h>
#include <cstdint>
#include <cstdio>
#include "QnnDevice.h"
#include "QnnInterface.h"

int main()
{
  void * lib = dlopen("libQnnHtp.so", RTLD_NOW);
  auto get = reinterpret_cast<Qnn_ErrorHandle_t (*)(const QnnInterface_t ***, uint32_t *)>(
      dlsym(lib, "QnnInterface_getProviders"));
  const QnnInterface_t ** providers = nullptr;
  uint32_t n = 0;
  get(&providers, &n);
  const auto & api = providers[0]->QNN_INTERFACE_VER_NAME;

  const QnnDevice_PlatformInfo_t * info = nullptr;
  api.deviceGetPlatformInfo(nullptr, &info);
  std::printf("hardware devices: %u\n", info->v1.numHwDevices);
  for (uint32_t i = 0; i < info->v1.numHwDevices; ++i)
    std::printf("  device[%u] id=%u numCores=%u\n", i,
                info->v1.hwDevices[i].v1.deviceId, info->v1.hwDevices[i].v1.numCores);
  api.deviceFreePlatformInfo(nullptr, info);
  return 0;
}
```

```bash theme={null} theme={null}
cd $WORK
g++ -std=c++17 -O2 -I/usr/include/QNN devices.cpp -ldl -o devices && ./devices
```

**预期输出：**

```
hardware devices: 2
  device[0] id=0 numCores=1
  device[1] id=1 numCores=1
```

**两个可寻址的 HTP 设备，各自拥有自己的映射预算。** 将四个上下文分布到两者之上，它们就能全部保持常驻 — 换页问题彻底消失。

有一个障碍：`qrb_inference_manager` 调用 `deviceCreate(nullptr, nullptr, …)`，它总是落在设备 0 上。选择设备意味着向它传递一个单条目的 `QnnDevice_PlatformInfo_t`。从源码构建该库并添加它：

```bash theme={null} theme={null}
mkdir -p $WORK/ws/src && cd $WORK/ws/src
git clone https://github.com/qualcomm-qrb-ros/qrb_ros_nn_inference.git
```

无论如何都需要从源码构建 — 这是获得 int32 支持（`token_emb`）、多输入张量和 HTP burst 模式的途径，这些都不在 apt 的 1.1.1 版本中。

在 `qrb_ros_nn_inference/qrb_inference_manager/src/qnn_inference/qnn_inference.cpp` 中，找到 `create_device()` 并将 `deviceCreate` 调用替换为：

```cpp theme={null} theme={null}
// Scope this device handle -- and therefore every context created from it -- to
// one HTP core. device_id_ is a new member, plumbed in from the constructor.
QnnDevice_CoreInfo_t core = QNN_DEVICE_CORE_INFO_INIT;
QnnDevice_HardwareDeviceInfo_t hw = QNN_DEVICE_HARDWARE_DEVICE_INFO_INIT;
QnnDevice_PlatformInfo_t platform = QNN_DEVICE_PLATFORM_INFO_INIT;
QnnDevice_Config_t cfg = QNN_DEVICE_CONFIG_INIT;
const QnnDevice_Config_t * cfgs[] = { &cfg, nullptr };
const QnnDevice_Config_t ** configs = nullptr;

if (device_id_ != 0) {
  core.v1.coreId = 0;
  core.v1.coreType = 0;
  hw.v1.deviceId = device_id_;
  hw.v1.deviceType = 0;              // QNN_HTP_DEVICE_TYPE_ON_CHIP
  hw.v1.numCores = 1;
  hw.v1.cores = &core;
  platform.v1.numHwDevices = 1;
  platform.v1.hwDevices = &hw;
  cfg.option = QNN_DEVICE_CONFIG_OPTION_PLATFORM_INFO;
  cfg.hardwareInfo = &platform;
  configs = cfgs;                    // nullptr keeps upstream behaviour: device 0
}

auto qnn_status = qnn_interface_->interface_.deviceCreate(nullptr, configs, &(device_handle_));
```

在 `QnnInference` 和 `QrbInferenceManager` 上都添加一个 `const uint32_t device_id_ = 0;` 成员和一个 `device_id` 构造函数参数，然后构建：

```bash theme={null} theme={null}
cd $WORK/ws
source /opt/ros/jazzy/setup.bash
colcon build --packages-select qrb_inference_manager --cmake-args -DCMAKE_BUILD_TYPE=Release
```

<Warning>
  **`OutputTensor` 在 apt 1.1.1 和上游之间存在 ABI 破坏。** 上游添加了四个 DMA-buf 字段，改变了 `sizeof(OutputTensor)`。任何针对 apt 头文件编译但链接到重新构建的库的代码，都会以错误的步长读取 `std::vector<OutputTensor>` 并在 `std::bad_array_new_length` 中崩溃。你的覆盖层的 include 路径必须排在 `/opt/ros/jazzy/include` **之前**，并且依赖代码需要干净的重新构建而不是重新链接。
</Warning>

现在用显式的设备 id 构建四个管理器中的每一个，并让全部四个保持常驻。

## 为什么我们这样划分

在两个 NPU 都可用的情况下，有多种方式分配四个模型。显而易见的一种是按大小平衡 — 每个设备放大约 1.4 GB。这不是最佳答案。

<Frame caption="Pi0.5 的四个组件在 IQ-9075 的两个 Hexagon NPU 上的放置方式，按测量延迟绘制。一个 NPU 要支付 2258 ms 的权重换页（总计 3277 ms）。两个 NPU 让一切保持常驻（1323 ms）。将昂贵的阶段重新放置到更快的 NPU 上 — 每个设备的字节数相同 — 达到 1111 ms。">
  <video src="https://mintcdn.com/qualcomm-staging/au8A0spDAhIMkGMw/images/tutorials/pi05-vla/npu-scheduling.mp4?fit=max&auto=format&n=au8A0spDAhIMkGMw&q=85&s=515b34ff90a76b56751b9766f1bd356d" poster="/images/tutorials/pi05-vla/npu-scheduling-poster.png" controls muted loop playsInline data-path="images/tutorials/pi05-vla/npu-scheduling.mp4" />
</Frame>

这个动画让一件静态图无法呈现的事变得可见：**四个阶段是顺序依赖的。** `vision_encoder` 输出给 `token_emb`，后者输出给 `backbone`，再输出给 `action_expert`。没有任何东西并发运行。将模型分布到两个 NPU 上并没有并行化任何东西 — 它只是意味着每个组件都可以保持已映射状态，这样链条就不再为权重的换入换出付出代价。这就是第一个也是最大的收益的来源。

从各组件的成本开始，按每个动作块测量：

| 组件               | 调用次数/块 |       权重 | 设备 0 上的成本 | 设备 1 上的成本 |
| ---------------- | -----: | -------: | --------: | --------: |
| `vision_encoder` |      3 |  515 MiB |    140 ms |    194 ms |
| `token_emb`      |      1 | 1007 MiB |     22 ms |     26 ms |
| `backbone`       |      1 |  934 MiB |    509 ms |    656 ms |
| `action_expert`  |     10 |  419 MiB |    379 ms |    483 ms |

**这两个 NPU 的速度并不相同。** 每个组件在设备 1 上都比在设备 0 上慢 25–30%。我们反复且一致地测量了这一点；我们没有确认的原因，也不会去猜测。

这种不对称决定了放置方式。`backbone` 和 `action_expert` 合计约占约 1030 ms 计算中的约 888 ms — 所以它们属于快的设备，两个便宜的组件放到慢的设备上。**按成本平衡，而不是按大小：**

| 划分（vision, token\_emb, backbone, expert） | 每设备常驻               |     ms/块 |
| ---------------------------------------- | ------------------- | -------: |
| `0,0,1,1` — 按大小平衡                        | 1522 / 1353 MiB     |     1323 |
| `1,0,1,0`                                | 1449 / 1426 MiB     |     1258 |
| **`1,1,0,0` — 按成本平衡**                    | **1522 / 1353 MiB** | **1111** |

注意第一行和最后一行在每个设备上放置的*字节数相同*。它们之间的 212 ms 纯粹来自哪个设备执行昂贵的工作。

最终状态，在 3 次预热后测量 12 次迭代：

| 阶段               | 调用次数 |       平均 ms |      p95 ms | 设备  |
| ---------------- | ---: | ----------: | ----------: | --- |
| `vision_encoder` |    3 |      193.25 |      194.07 | 1   |
| `token_emb`      |    1 |       22.48 |       23.18 | 1   |
| `backbone`       |    1 |      509.32 |      510.39 | 0   |
| `action_expert`  |   10 |      379.26 |      382.17 | 0   |
| 主机张量打包           |    — |        6.24 |        6.58 | CPU |
| 上下文创建/释放         |    — |    **0.00** |    **0.00** | —   |
| **每块总计**         |      | **1110.73** | **1113.49** |     |

上下文换页现在恰好为零，整个流水线**比它起步时的单 NPU 路径快 4 倍** — 从 4421 ms 降至 1111 ms。

<Warning>
  **这块开发板上的延迟与历史相关，所以要在刚重启的板子上做基准测试。** 在一块空闲但已运行 25 小时、经历了数千次上下文映射/取消映射周期的开发板上重新运行同一基准测试，得到的是 **1152 ms** 而不是 1111 — 每个阶段都更慢，而 `token_emb`（最大的权重映射，1006 MiB）慢了 29%。这与消除竞争应有的效果恰恰相反。可能的机制是上面描述的地址空间碎片化，以性能下降而不是映射失败的形式表现出来。我们尚未确认 — 实验方法是重启后立即重新运行 — 因此请将此处任何单个数字视为 1111–1152 ms 范围的下限，并在给出你自己的数字时同时注明开发板的运行时间。
</Warning>

### 与 AI Hub 发布的数据相比如何

AI Hub 对每个组件进行**孤立的**性能分析。我们运行的是整条链，各阶段之间有主机侧的数据搬运，因此我们的数字必然更高。这是一个不对等的比较，展示它是为了定位开销，而不是宣称胜利：

| 组件               |     AI Hub 发布值 × 调用次数 |      我们的流水线内数据 |
| ---------------- | --------------------: | -------------: |
| `vision_encoder` |  40.68 × 3 = 122.0 ms |      193.25 ms |
| `token_emb`      |     4.24 × 1 = 4.2 ms |       22.48 ms |
| `backbone`       | 397.27 × 1 = 397.3 ms |      509.32 ms |
| `action_expert`  | 36.43 × 10 = 364.3 ms |      379.26 ms |
| **总计**           |          **887.8 ms** | **1110.73 ms** |

约 223 ms 的差距主要来自在 CPU 上于各阶段之间移动张量。仅 `backbone` 就输出 36 个 KV 张量、共约 34 MB，必须读出并重新打包进 expert 的输入缓冲区。这是显而易见的下一个优化目标，而 [`qrb_ros_transport`](/zh/Ubuntu/robotics-workflows/qrb-ros-transport) 的 DMA-buf fd 传递就是实现机制 — `qrb_inference_manager` 2.x 已经为此暴露了 `inference_execute_dmabuf()` 入口点。

## 9. 证明它在数值上正确

在这里构建"快但错"的东西很容易 — 打乱的 KV 缓存会产生自信、貌似合理却毫无用处的动作。所以要对照你在步骤 3 中已经使用过的参考实现进行检查。

从你的串联流水线转储每个阶段的确切扁平输入缓冲区及其输出，使用步骤 4 的计算图顺序将输入拆分回逐张量文件，通过 `qnn-net-run` 重放它们，然后对比：

```bash theme={null} theme={null}
# for one component, given <comp>_IN.raw written by your pipeline:
python3 - <<'PY'
import json, pathlib
comp = "backbone"
info = json.load(open(f"{comp}.json"))["info"]["graphs"][0]["info"]
SZ = {"QNN_DATATYPE_FLOAT_32": 4, "QNN_DATATYPE_INT_32": 4}
blob = pathlib.Path(f"{comp}_IN.raw").read_bytes()
off, entries = 0, []
for t in info["graphInputs"]:
    ti = t["info"]
    n = SZ[ti["dataType"]]
    for d in ti["dimensions"]:
        n *= d
    pathlib.Path(f"{ti['name']}.raw").write_bytes(blob[off:off + n])
    off += n
    entries.append(f"{ti['name']}:={ti['name']}.raw")
pathlib.Path("list.txt").write_text(" ".join(entries) + "\n")
print(f"split {off} bytes into {len(entries)} tensors")
PY

qnn-net-run --backend /usr/lib/libQnnHtp.so --retrieve_context $BUNDLE/backbone.bin \
  --input_list list.txt --output_dir ref --use_native_input_files
```

然后将 `ref/Result_0/<name>.raw` 与你的流水线对每个张量的输出进行比较。我们的结果**逐位**一致 — 全部四个组件的全部 45 个输出张量，而且在组件固定到不同 NPU 时仍然一致：

```
PASS vision_encoder   ( 1 output tensor )  bitwise identical
PASS token_emb        ( 7 output tensors)  bitwise identical
PASS backbone         (36 output tensors)  bitwise identical
PASS action_expert    ( 1 output tensor )  bitwise identical
```

四个独立的信号表明运行的是 NPU 而不是 CPU：AI Hub 报告全部四个组件 100% 的 NPU 层放置（3835/3835、2473/2473、1120/1120、34/34）；加载的后端是 `libQnnHtp.so`；输出与 HTP 后端上的 `qnn-net-run` 逐位一致；一旦一切常驻，上下文创建时间降为零。

## 用 ROS 2 节点封装它

一旦链条工作正常，ROS 层就平淡无奇 — 这正是重点：

```mermaid theme={null} theme={null}
flowchart LR
    CAM["sensor_msgs/Image<br/>× N"] --> NODE
    TASK["std_msgs/String<br/>~/task"] --> NODE
    ST["Float32MultiArray<br/>~/state"] --> NODE
    NODE["your VLA node<br/><b>NPU worker thread</b>"] --> AC["ActionChunk<br/>~/action_chunk"]
    NODE --> IS["InferenceStats<br/>~/stats"]
    classDef hero fill:#31017D,stroke:#31017D,color:#fff,stroke-width:1.5px;
    classDef pkg fill:#F4EFFA,stroke:#31017D,color:#31017D,stroke-width:1.5px;
    class NODE hero;
    class CAM,TASK,ST,AC,IS pkg;
```

两个值得借鉴的决策。推理耗时约 1.1 s，对于执行器回调来说太长了，所以在专用的工作线程上运行它。以及丢弃在推理中途到达的帧，而不是把它们排队 — 一个基于陈旧观测行动的 VLA 比一个以更低频率行动的 VLA 更糟糕。

在每个块旁边发布逐阶段延迟分解。这没有任何成本，而且意味着你之后做出的任何性能声明都不会脱离实时测量。

## 输出到底好不好？

延迟和逐位一致性证明了流水线是正确的。它们对动作是否*有用*只字未提。要在没有机器人的情况下检验这一点，可以重放 [LIBERO 数据集](https://huggingface.co/datasets/physical-intelligence/libero)中的真实 episode — Pi0.5 校准所用的数据 — 将人类演示者看到的观测原样喂给 NPU，并将预测的块与他们实际做的进行比较。

状态和动作都使用均值/标准差归一化，统计数据随数据集一起提供，位于 `meta/stats.json`。在 20 步范围上对 15 个重规划步骤进行评分：

| 维度                      |                 MAE |         MAE / 动作标准差 |
| ----------------------- | ------------------: | ------------------: |
| `dx`、`dy`、`dz`          | 0.040, 0.056, 0.060 | 0.119, 0.147, 0.134 |
| `droll`、`dpitch`、`dyaw` | 0.011, 0.013, 0.009 | 0.276, 0.201, 0.116 |
| `grip`                  |               0.022 |               0.022 |
| **全部**                  |           **0.030** |           **0.145** |

`MAE / 动作标准差` 是与尺度无关的数字：**0.145 意味着误差约为该数据集中动作自然离散程度的 15%。** 预测数据集均值的得分按定义恰好为 1.0，所以模型确实在跟踪演示者。夹爪 — 在 ±1 处实际上是二元的，也是唯一一个错误毫不含糊的维度 — 匹配到 0.022。

<Warning>
  **先发布状态再发布图像，否则提示词会悄悄过时。** 节点在每个摄像头都有新帧时立即触发；机器人状态*不*是该触发条件的一部分，而没有状态时，分词器构建的提示词在结构上就与 Pi0.5 校准时的不同。由于 DDS 保证每个话题内的顺序但不保证跨话题的顺序，先发布图像会让推理在帧 *k* 上运行，而提示词仍然编码着状态 *k−1* — 没有错误也没有日志。先发布状态、再发布任务、最后发布图像，使这张表的整体数字从 0.149 变为 0.145，夹爪从 0.036 变为 0.022。
</Warning>

<Note>
  这是在一个 episode 上的开环 teacher-forced 比较，**不是任务成功率**。策略从未看到自己动作的后果。这正是下一节要解决的问题。
</Note>

## 它真的能完成任务吗？

上一节有一个漏洞：接下来发生什么是由*人类的*动作决定的，所以策略从未为自己的动作负责。一个略有偏差的策略和一个好的策略得分大致相同，因为两者的错误都从不累积。

闭环消除了人类。模拟器渲染机械臂能看到的画面，策略做出决定，模拟器执行*那个*决定，任务自身的目标谓词判断是否成功。

结果证明这可以完全在开发板上运行 — 这出乎我们的意料，因为我们刚刚确认 Gazebo 无法在这里渲染。区别在于需求中的一个词：

|                   | Gazebo / Ogre2                       | LIBERO / MuJoCo                      |
| ----------------- | ------------------------------------ | ------------------------------------ |
| 需要                | OpenGL 3.3 **core**                  | OpenGL 3.3，**compatibility** 上下文即可满足 |
| 在 Mesa llvmpipe 上 | 在 `Ogre2RenderEngine::LoadImpl` 中段错误 | 获得 OpenGL **4.5 compatibility**，正常工作 |

Mesa 的软件光栅化器提供 4.5 *compatibility* 配置文件。Ogre2 要求 *core* 于是崩溃；MuJoCo 的经典渲染器则很满意。所以不需要笔记本电脑，不需要跨主机桥接，不需要跨机器的 DDS：

```bash theme={null} theme={null}
sudo apt-get install -y libosmesa6 libosmesa6-dev
export MUJOCO_GL=osmesa
```

### 渲染是昂贵的那一半，所以只在重规划时渲染

在那个软件光栅化器上，一次双摄像头 256×256 观测耗费 **340 ms**。一个物理步耗费 **31 ms**。NPU 在 **1126 ms** 内产生五十个动作。

如果每个模拟步都渲染，数字就会不可避免地反转 — 模拟器的成本是 **3B VLA 的 3.2 倍**：

| 每个 285 步的 episode |     策略 |     模拟器 |
| ----------------- | -----: | ------: |
| 每步渲染              | 32.6 s | 105.7 s |
| 仅在重规划时渲染          | 32.6 s |  18.7 s |

所以在重规划时渲染。这不是捷径 — 这正是 50 步动作块带给你的东西，因为策略只在规划时才需要观测。它将一个 220 步的 episode 从 84.8 s 变为 15.3 s，并将瓶颈移回到你所期望的 NPU 上。

<Frame caption="NPU 块生产与模拟器步消耗的对比，按测量成本绘制。一个块以 1126 ms 换来 50 个动作；每个模拟步都渲染会让模拟器成为策略的 3.2 倍，而只在重规划边界渲染则将其压缩到 18.7 s，让 NPU 重新掌控局面。重规划范围是调节旋钮：H=1 每个 episode 耗费 426 s，H=50 耗费 18 s。">
  <video src="https://mintcdn.com/qualcomm-staging/au8A0spDAhIMkGMw/images/tutorials/pi05-vla/closed-loop-cost.mp4?fit=max&auto=format&n=au8A0spDAhIMkGMw&q=85&s=bddb84121d6f3878c70476c1d7ac1af7" poster="/images/tutorials/pi05-vla/closed-loop-cost-poster.png" controls muted loop playsInline data-path="images/tutorials/pi05-vla/closed-loop-cost.mp4" />
</Frame>

这个旋钮两端都有成本，而只有一端被测量了。长的范围便宜，但会基于更陈旧的观测行动，因为机械臂会在一个由更旧图像制定的计划中执行得更深。我们尚未测量这从哪里开始损害准确性，所以 `--replan-horizon 10` 是一个可用的选择，而不是调优后的选择。

### 四个会悄悄毁掉结果的约定

这些都不会抛出错误。弄错任何一个，循环仍然运行、仍然渲染、仍然报告一个成功率 — 为零的成功率。每一个都是通过与录制的数据集对比而不是通过阅读文档解决的：

| 约定        | 正确值                                            | 我们如何得知                               |
| --------- | ---------------------------------------------- | ------------------------------------ |
| 摄像头方向     | **旋转 180°**，两个摄像头都是                            | 与数据集帧的相关性 +0.82，未旋转时为 −0.05          |
| 8 维状态布局   | `eef_pos(3) + axis-angle(3) + gripper_qpos(2)` | 与数据集自身的 `state[0]` 相比最大误差 0.0057     |
| 夹爪符号      | **−1 打开，+1 闭合**                                | 夹爪宽度在 −1 时 0.021→0.039，在 +1 时 →0.001 |
| 每个动作的模拟步数 | **恰好一步**                                       | 8.65 mm 跟踪误差，两步则为 217 mm             |

<Warning>
  **轴角分支是个陷阱，而显而易见的修复是错误的。** LIBERO 的初始姿态让夹爪竖直向下，这使旋转角几乎恰好位于 π — 轴角表示的不连续点。按四元数标量部分的符号固定分支是很自然的做法，但它会失败：一次 rollout 会在中途从 +3.14 翻转到 −3.14，跳变 2π，而描述的是*同一个*物理朝向。由于状态以离散化桶的形式拼接进语言提示词，这在策略看来就是手腕在两个控制步之间转了整整一圈。录制的数据集从不回绕，所以正确的规则是**相对于前一状态的连续性**，而不是固定的符号测试。
</Warning>

### 结果

全部十个 LIBERO-10 任务 — 长时程任务套件 — 每个任务十个初始状态：

|             | 测量值                                         |
| ----------- | ------------------------------------------- |
| **成功率**     | **86/100 = 86%**，95% CI 77.9–91.5%（Wilson）  |
| 重规划范围       | 每个 50 动作块中的 10 个                            |
| 步数上限        | 520                                         |
| 执行的块        | 3153，**全部**在 `libQnnHtp.so` 上               |
| 因时间戳不匹配丢弃的块 | 0                                           |
| NPU 延迟      | 平均 1128 ms，全部 100 个 episode 中为 1105–1149 ms |
| 总墙钟时间       | 90 分钟                                       |

这十个初始状态是两个独立的五个一组 — LIBERO 的索引 0–4 和 20–24 — 有意作为独立的扫描运行。单独一组无法告诉你碰巧选中的状态是否是简单的，而这个套件中难度差异很大：

| 组          |         成功率 | 95% CI     |
| ---------- | ----------: | ---------- |
| 初始状态 0–4   | 42/50 = 84% | 71.5–91.7% |
| 初始状态 20–24 | 44/50 = 88% | 76.2–94.4% |

两个区间舒适地重叠，所以两组是一致的，合并它们是合理的。十个任务中有一半以 10/10 解决。唯一真正困难的任务是 *"put the yellow and white mug in the microwave and close it"*，为 4/10 — 唯一在**两组**中得分都低的任务。*"Put both moka pots on the stove"* 先是 2/5 后是 5/5，这有力地提醒我们五个 episode 的样本能说明的东西何其有限。

<Frame caption="一个闭环 episode：'pick up the book and place it in the back compartment of the caddy'，在 252 个模拟步和 26 个块内解决。视频下方的条带是 50 动作块 — 有阴影的单元格是下次重规划前将被执行的十个，琥珀色单元格是此刻正在应用的动作，带轮廓的其余部分被丢弃。逐阶段 NPU 延迟和加载的后端烧录在每一帧上。">
  <video src="https://mintcdn.com/qualcomm-staging/au8A0spDAhIMkGMw/images/tutorials/pi05-vla/closed-loop-rollout.mp4?fit=max&auto=format&n=au8A0spDAhIMkGMw&q=85&s=d9f907b76b19e1a6fadb7722387ca447" poster="/images/tutorials/pi05-vla/closed-loop-rollout-poster.png" controls muted loop playsInline data-path="images/tutorials/pi05-vla/closed-loop-rollout.mp4" />
</Frame>

HUD 在做一件具体的事：`backend libQnnHtp.so` 出现在**每一帧**上，因此 NPU 运行的证明随视频一起传播，而不是在旁边被断言。动作条带的存在是为了让丢弃可见 — 每五十个预测动作中有四十个在下次重规划时被扔掉，这看起来很浪费，直到你注意到正是它让整个循环得以运行。

录制那段视频并非免费：额外的帧每对耗费 340 ms，因此测试工具会打印警告，说明带 `--save-frames` 的运行不是时间测量。视频由对已保存帧的单独离线过程合成，因此合成绝不会落在被测量的 episode 之内。

有两个细节比头条数字更重要：

**十四次失败中的每一次都恰好运行到 520 步。** 没有一次发散、抖动或产生胡言乱语 — 它们是在任务中途用完了步数预算。失败模式是"对上限来说太慢"，而不是"错误"，这使得步数上限成为结果的一部分，而不是一个附带设置。

**空策略在同一测试工具上的得分是 24 个中的 0 个。** 全零动作和均匀随机动作都在每个任务上失败，且目标谓词在重置时从未处于已满足状态。没有这个检查，高成功率同样可能只是在测量一个失效的谓词 — 而从外部看两者完全一样。

<Note>
  **这是一个精简的协议。** LIBERO 的完整评估是 4 个套件 × 10 个任务 × 50 个初始状态。这里是一个套件、每个任务十个初始状态，因此 86% 带有大约 ±7 个百分点的区间，不是基准数字。两个相互印证的组提高了所采样状态并非病态的信心，但可用状态的百分之二十仍然只是一个样本。我们也不与已发表的 pi0 级结果作任何比较 — 那需要它们的重规划范围和步数上限与我们的一致，而我们尚未核实。
</Note>

每个 episode 是一条 JSON 记录，包含套件、任务、初始状态索引、范围、步数上限、库版本、逐块后端和延迟分布，因此这个数字是可审计的，而不是被断言的。

## 坦诚的局限性

* **任务成功率来自精简的协议。** 完整的 LIBERO 评估是 4 个套件 × 10 个任务 × 50 个 episode，这在一块开发板上无法承受。这里的任何成功率都注明了其 episode 数量和置信区间，并且绝不作为套件级基准数字呈现。
* **Gazebo 无法在这块开发板上渲染，但 MuJoCo 可以。** `gz-harmonic` 在 arm64 上安装正常，但 Ogre2 需要桌面 OpenGL 3.3 **core**，而这里的 Adreno 驱动只暴露 OpenGL ES；Ogre v1 中止，强制 Mesa llvmpipe 会在 `Ogre2RenderEngine::LoadImpl` 内部段错误。LIBERO 的 MuJoCo 渲染器由同一 llvmpipe 提供的 OpenGL 4.5 **compatibility** 配置文件即可满足，这就是闭环演示完全在开发板上运行、不涉及笔记本电脑的原因。渲染仍是软件渲染：不存在硬件路径，因为 `/dev/dri/renderD128` 是显示控制器而不是 GPU，因此 Mesa 的 `freedreno` 无法绑定它，而 `zink` 被两个 Vulkan ICD 都拒绝。
* **动作语义是特定于具体机器人形态的。** 已发布的导出针对 LIBERO 的 7-DoF Franka Panda。其输出对不同的机械臂不是有效的关节命令。
* **归一化统计数据是外部的。** 原始关节值会产生无意义的提示词；你需要策略训练时使用的统计数据。
* **没有功耗或热测量**，也没有持续负载浸泡测试。所有数字都来自热应力未饱和的开发板上的短时运行。
* **从未用实时摄像头测量过。** 输入是合成的或从磁盘重放的。
* **NPU 是独占的。** 两个各自映射完整 bundle 的进程会争夺同一 CDSP 预算，双双变慢。测量时确保 NPU 上没有其他任务。
* 我们遵循的每设备约 `~1600 MiB` 上限是经验性且保守的，不是文档记载的限制。

## 这对平台意味着什么

100 TOPS 让一个 3B 视觉-语言-动作模型变得*可信*。这次实践表明它同样*实用* — 达到 4.3–4.5 倍实时 — 但前提是你把两个 NPU 当作需要调度的资源，而不是一个加速器。朴素路径与知情路径之间的差距是 4 倍，而且朴素路径还会悄悄吞掉一个致命的映射错误。

剩余的提升空间在 CPU 上，而不是 NPU 上：每块 223 ms 的主机侧张量搬运，零拷贝 DMA-buf 应能在很大程度上消除它。

## 相关内容

* [NPU 上的深度估计](/zh/Ubuntu/robotics-workflows/npu-workflows) — 这一模式的单模型版本，每根连线都清晰可见。
* [`qrb_ros_nn_inference`](/zh/Ubuntu/robotics-workflows/qrb-ros-nn-inference) — 对于普通的单输入单输出模型，这个通用节点甚至连三个 API 调用都省去了。
* [`qrb_ros_transport`](/zh/Ubuntu/robotics-workflows/qrb-ros-transport) — DMA-buf fd 传递，主机侧搬运开销的解决方案。
* [上下文二进制文件](/zh/Ubuntu/ai-workflows/context-binaries) — `.bin` 是什么以及它如何产生。
* [软件设置](/zh/Ubuntu/robotics-workflows/software-setup) — 本页起点的 ROS 2 Jazzy 安装。
