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

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

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

<div style={{ marginBottom: "2rem" }}>
  <div
    style={{
fontSize: "0.72rem",
fontWeight: 700,
color: "#31017D",
letterSpacing: "1.5px",
textTransform: "uppercase",
marginBottom: "0.5rem"
}}
  >
    机器人
  </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 上一步步搭建一个视觉-语言-动作（VLA）模型，直到它在开发板的 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 自由度） | **1111–1152 ms**，0.87–0.90 块/秒                        |
| 相对实时的速度              | **4.3–4.5×** — LIBERO 以 10 Hz 运行，因此 50 步即 5 秒的运动      |
| 正确性                  | **45 个输出张量中 45 个与 Qualcomm 参考运行器逐位一致**                |
| 闭环任务成功率              | 覆盖全部十个 LIBERO-10 任务的 **100 个回合中成功 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 上下文二进制文件（context binary）**，并通过 [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，视觉编码器和动作专家为 w8a16。

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

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

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

视觉编码器接受一张 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，然后是经过动作专家的十次去噪步骤。值得记住的常量：

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

除了顺序陷阱外还有两处重命名要注意：`token_emb` 输出 `prefix_emb`，但 `backbone` 称之为 `hidden_state`；`prefix_att_2d` 变成了 `prefix_att_2d_masks`。`suffix_sin`/`suffix_cos` 到达动作专家时叫 `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 解析再转换为图的数据类型。对于 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。没有会话设置，没有 delegate 注册，没有图构建器，也不用写任何设备管理代码。

<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 缓存一次性打包进专家的输入缓冲区，之后每步只重写 6404 字节。
* **专家直接返回 `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 地址空间，不是主机内存**——在系统内存还有 34 GB 空闲、以 root 身份运行时它照样失败，而每个二进制文件单独加载都完全正常。在设备 0 上的实测：

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

它也不是一个干净的容量上限：反复的 map/unmap 会使地址空间碎片化，因此相同的总量会因进程之前做过什么而时而成功、时而失败。

<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` 中崩溃。你的 overlay 的 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-prod/Y8M1yQTXhRrl0S0u/images/tutorials/pi05-vla/npu-scheduling.mp4?fit=max&auto=format&n=Y8M1yQTXhRrl0S0u&q=85&s=5a7cd5a31d81f2c45b32f4b40f8c05e7" 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 小时、经历了数千次上下文 map/unmap 循环的空闲板上重跑同一基准，得到的是 **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，必须读出并重新打包进专家的输入缓冲区。这是显而易见的下一个优化目标，而 [`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` 重放，然后做 diff：

```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 报告四个组件的 NPU 层放置率均为 100%（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 秒，对 executor 回调来说太长，所以在专用工作线程上运行它。并且丢弃推理期间到达的帧而不是排队——基于陈旧观测行动的 VLA，比以更低频率行动的 VLA 更糟。

随每个动作块一起发布各阶段延迟明细。这没有任何成本，并且意味着你以后做出的任何性能声明都不会与实时测量脱节。

## 输出到底好不好？

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

状态和动作都使用均值/标准差归一化，统计量随数据集一起发布在 `meta/stats.json` 中。在 20 步 horizon 上对 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>
  这是在单个回合上的开环 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* profile。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 步回合 |     策略 |     仿真器 |
| ---------- | -----: | ------: |
| 每步渲染       | 32.6 s | 105.7 s |
| 仅在重规划时渲染   | 32.6 s |  18.7 s |

所以只在重规划时渲染。这不是取巧——这恰恰是 50 步动作块带给你的东西，因为策略只在规划时才需要观测。它把一个 220 步的回合从 84.8 s 缩短到 15.3 s，并把瓶颈移回你预期中的 NPU 上。

<Frame caption="按实测开销绘制的 NPU 动作块生产与仿真器步进消耗对照。一个动作块以 1126 ms 换来 50 个动作；每个仿真步都渲染会让仿真器达到策略开销的 3.2 倍，而只在重规划边界渲染则把它压缩到 18.7 s，让 NPU 重新成为主导。重规划 horizon 是调节旋钮：H=1 每回合耗时 426 s，H=50 耗时 18 s。">
  <video src="https://mintcdn.com/qualcomm-prod/Y8M1yQTXhRrl0S0u/images/tutorials/pi05-vla/closed-loop-cost.mp4?fit=max&auto=format&n=Y8M1yQTXhRrl0S0u&q=85&s=04c278b5c56ca0c78048bcac4f902363" 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>

这个旋钮两端都有代价，而只有一端被测量过。较长的 horizon 便宜，但会基于更陈旧的观测行动，因为机械臂会沿着由更早图像制定的计划执行得更远。我们尚未测量这从哪里开始损害准确率，所以 `--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>
  **axis-angle 分支是个陷阱，而看似显然的修复恰恰是错的。** LIBERO 的初始姿态让夹爪竖直朝下，这使旋转角几乎恰好为 π——axis-angle 表示的不连续点。用四元数标量部分的符号来固定分支是自然的做法，但它会失败：一次 rollout 中途从 +3.14 翻转到 −3.14，跳变 2π，而描述的是*同一个*物理姿态。由于状态是以离散化分桶的形式拼进语言提示词的，这在策略看来就是手腕在两个控制步之间转了整整一圈。录制数据集从不发生环绕，所以正确的规则是**相对于前一状态的连续性**，而不是固定的符号测试。
</Warning>

### 结果

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

|               | 实测值                                        |
| ------------- | ------------------------------------------ |
| **成功率**       | **86/100 = 86%**，95% CI 77.9–91.5%（Wilson） |
| 重规划 horizon   | 每个 50 动作块中的 10 个                           |
| 步数上限          | 520                                        |
| 执行的动作块        | 3153 个，**全部**在 `libQnnHtp.so` 上            |
| 因时间戳不匹配丢弃的动作块 | 0                                          |
| NPU 延迟        | 平均 1128 ms，全部 100 个回合范围 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 解决。真正困难的任务是\*"把黄白相间的马克杯放进微波炉并关上门"*，得分 4/10——它是**两组**中唯一都得分很低的任务。*"把两个摩卡壶都放到炉子上"\*先是 2/5 后是 5/5，这有效地提醒我们五回合样本的分辨能力有多低。

<Frame caption="一个闭环回合：'拿起书并把它放进置物架的后隔层'，在 252 个仿真步和 26 个动作块内解决。视频下方的条带是 50 动作块——阴影格是下次重规划前将执行的十个动作，琥珀色格是此刻正在执行的动作，带轮廓的其余部分会被丢弃。每一帧都烧录了各阶段 NPU 延迟和加载的后端。">
  <video src="https://mintcdn.com/qualcomm-prod/Y8M1yQTXhRrl0S0u/images/tutorials/pi05-vla/closed-loop-rollout.mp4?fit=max&auto=format&n=Y8M1yQTXhRrl0S0u&q=85&s=12c7ec765f5a3befb3ef14cd5e5a2df9" 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` 的运行不是计时测量。视频由对已保存帧的独立离线处理合成，因此合成过程绝不落在被测量的回合内。

有两个细节比标题数字更重要：

**十四次失败每一次都恰好跑满 520 步。** 没有一次发散、抖动或产生胡乱输出——它们只是在任务中途耗尽了步数预算。失败模式是"对上限来说太慢"，而不是"错误"，这使得步数上限成为结果的一部分而非无关设置。

**空策略在同一测试框架上得分为 24 中的 0。** 全零动作和均匀随机动作都在每个任务上失败，且目标判定谓词在重置时从不预先满足。没有这项检查，一个高成功率同样可能只是在测量一个坏掉的判定谓词——而且从外部看完全一样。

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

每个回合都是一条 JSON 记录，包含套件、任务、初始状态索引、horizon、步数上限、库版本、每块的后端及延迟分布，因此这个数字是可审计的而非口头断言的。

## 诚实的局限性

* **任务成功率基于简化协议。** 完整的 LIBERO 评测是 4 个套件 × 10 个任务 × 50 个回合，在单块板上负担不起。此处的任何成功率都注明其回合数和置信区间，绝不作为套件级基准数字呈现。
* **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** profile 满足，这就是闭环演示能完全在板上运行、无需笔记本参与的原因。渲染仍然是软件的：没有硬件路径，因为 `/dev/dri/renderD128` 是显示控制器而不是 GPU，所以 Mesa 的 `freedreno` 无法绑定它，而 `zink` 被两个 Vulkan ICD 都拒绝。
* **动作语义与具体机器人形态绑定。** 已发布的导出面向 LIBERO 的 7 自由度 Franka Panda。其输出对于另一种机械臂不是有效的关节指令。
* **归一化统计量是外部的。** 原始关节值会产生无意义的提示词；你需要策略训练时使用的统计量。
* **没有功耗或热学测量**，也没有持续负载的浸泡测试。所有数字来自热学上未受压的板上的短时运行。
* **从未用实时相机测量过。** 输入是合成的或从磁盘重放的。
* **NPU 是独占资源。** 两个进程各自映射完整模型包会争用同一 CDSP 预算，双双变慢。测量时确保 NPU 上没有其他任务。
* 我们采用的每设备约 `~1600 MiB` 上限是经验性且保守的，不是文档记载的限制。

## 这对平台意味着什么

100 TOPS 使 30 亿参数的视觉-语言-动作模型变得*可能*。这次实践表明它同样是*实用的*——达到 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 安装。
