diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..fe9e1ab
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+patches/**/*.patch -whitespace
diff --git a/.gitignore b/.gitignore
index ca3edc6..c220675 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,6 +20,9 @@ dist/
checkpoint/
checkpoints/
*.safetensors
+eval/results/
+/goldens/
+/Testing/
# macOS resource forks
._*
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 28174de..7c6c01b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,9 +1,12 @@
cmake_minimum_required(VERSION 3.16)
project(robotcpp VERSION 0.1.0 LANGUAGES C CXX)
+include(CTest)
+
option(ROBOT_CPP_BUILD_ROBOT_SERVER "Build model-server target" ON)
option(ROBOT_CPP_BUILD_MODEL_CLI "Build model-cli target" OFF)
option(ROBOT_CPP_BUILD_ROBOT_CLIENT "Build C++ robot client targets" OFF)
+option(ROBOT_CPP_BUILD_STARVLA "Build the StarVLA runtime (requires llama.cpp overlay)" OFF)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -12,6 +15,27 @@ set(CMAKE_CXX_EXTENSIONS OFF)
if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/llama.cpp/CMakeLists.txt")
message(FATAL_ERROR "third_party/llama.cpp is required; run `git submodule update --init --recursive`")
endif()
+if(ROBOT_CPP_BUILD_STARVLA)
+ file(READ
+ "${CMAKE_CURRENT_SOURCE_DIR}/third_party/llama.cpp/tools/mtmd/models/qwen3vl.cpp"
+ ROBOT_CPP_QWEN3VL_MTMD_SOURCE)
+ file(READ
+ "${CMAKE_CURRENT_SOURCE_DIR}/third_party/llama.cpp/include/llama.h"
+ ROBOT_CPP_LLAMA_PUBLIC_HEADER)
+ string(FIND "${ROBOT_CPP_QWEN3VL_MTMD_SOURCE}"
+ "FFN_GELU_ERF" ROBOT_CPP_QWEN3VL_PARITY_PATCH_INDEX)
+ string(FIND "${ROBOT_CPP_LLAMA_PUBLIC_HEADER}"
+ "llama_set_backend_native_graphs_enabled" ROBOT_CPP_LLAMA_GRAPH_PATCH_INDEX)
+ if(ROBOT_CPP_QWEN3VL_PARITY_PATCH_INDEX EQUAL -1 OR
+ ROBOT_CPP_LLAMA_GRAPH_PATCH_INDEX EQUAL -1)
+ message(FATAL_ERROR
+ "StarVLA requires the pinned llama.cpp overlay. Run "
+ "`./tools/apply_patches.sh` from the repository root, "
+ "then configure again.")
+ endif()
+ unset(ROBOT_CPP_QWEN3VL_MTMD_SOURCE)
+ unset(ROBOT_CPP_LLAMA_PUBLIC_HEADER)
+endif()
set(LLAMA_BUILD_COMMON ON CACHE BOOL "" FORCE)
set(LLAMA_BUILD_TOOLS OFF CACHE BOOL "" FORCE)
set(LLAMA_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
@@ -20,7 +44,16 @@ add_subdirectory(third_party/llama.cpp EXCLUDE_FROM_ALL)
if(NOT TARGET ggml OR NOT TARGET llama)
message(FATAL_ERROR "llama.cpp must provide ggml and llama targets")
endif()
-
+if(ROBOT_CPP_BUILD_STARVLA)
+ # mtmd normally inherits this variable when llama.cpp builds all tools.
+ if(NOT LLAMA_INSTALL_VERSION)
+ set(LLAMA_INSTALL_VERSION ${PROJECT_VERSION})
+ endif()
+ add_subdirectory(third_party/llama.cpp/tools/mtmd EXCLUDE_FROM_ALL)
+ if(NOT TARGET mtmd)
+ message(FATAL_ERROR "llama.cpp must provide the mtmd target for Qwen-VL")
+ endif()
+endif()
set(ROBOT_CPP_LLAMA_INCLUDE_DIRS
${CMAKE_CURRENT_SOURCE_DIR}/third_party/llama.cpp
${CMAKE_CURRENT_SOURCE_DIR}/third_party/llama.cpp/include
@@ -30,14 +63,29 @@ set(ROBOT_CPP_LLAMA_INCLUDE_DIRS
)
set(SMOLVLA_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/models/smolvla)
+set(STARVLA_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/models/starvla)
set(ROBOT_SERVER_DIR ${CMAKE_CURRENT_SOURCE_DIR}/robot_server)
set(ROBOT_CLIENT_DIR ${CMAKE_CURRENT_SOURCE_DIR}/robot_client)
-add_library(smolvla_runtime STATIC
+add_library(robotcpp_model_common STATIC
src/models/ggml_backend.cpp
src/models/ggml_backend.h
src/models/gguf_loader.cpp
src/models/gguf_loader.h
+ src/models/model_type.cpp
+)
+target_include_directories(robotcpp_model_common
+ PUBLIC
+ ${CMAKE_CURRENT_SOURCE_DIR}/src
+ ${ROBOT_CPP_LLAMA_INCLUDE_DIRS}
+)
+target_link_libraries(robotcpp_model_common PUBLIC ggml)
+target_compile_features(robotcpp_model_common PUBLIC cxx_std_17)
+if(NOT MSVC)
+ target_compile_options(robotcpp_model_common PRIVATE -Wno-cast-qual)
+endif()
+
+add_library(smolvla_runtime STATIC
${SMOLVLA_DIR}/smolvla_engine.cpp
${SMOLVLA_DIR}/smolvla_engine.h
${SMOLVLA_DIR}/state_proj.cpp
@@ -53,17 +101,13 @@ target_include_directories(smolvla_runtime
${SMOLVLA_DIR}
${ROBOT_CPP_LLAMA_INCLUDE_DIRS}
)
-target_link_libraries(smolvla_runtime PUBLIC ggml llama)
+target_link_libraries(smolvla_runtime PUBLIC robotcpp_model_common llama)
target_compile_features(smolvla_runtime PUBLIC cxx_std_17)
if(NOT MSVC)
target_compile_options(smolvla_runtime PRIVATE -Wno-cast-qual)
endif()
add_library(pi0_engine STATIC
- src/models/ggml_backend.cpp
- src/models/ggml_backend.h
- src/models/gguf_loader.cpp
- src/models/gguf_loader.h
src/models/pi0/types.h
src/models/pi0/action.cpp
src/models/pi0/action.h
@@ -87,13 +131,65 @@ target_include_directories(pi0_engine
${CMAKE_CURRENT_SOURCE_DIR}/src
${ROBOT_CPP_LLAMA_INCLUDE_DIRS}
)
-target_link_libraries(pi0_engine PUBLIC ggml llama)
+target_link_libraries(pi0_engine PUBLIC robotcpp_model_common llama)
target_compile_features(pi0_engine PUBLIC cxx_std_17)
if(NOT MSVC)
target_compile_options(pi0_engine PRIVATE -Wno-cast-qual)
endif()
+if(ROBOT_CPP_BUILD_STARVLA)
+add_library(starvla_runtime STATIC
+ ${STARVLA_DIR}/fast_codec.cpp
+ ${STARVLA_DIR}/fast_codec.h
+ ${STARVLA_DIR}/fast_policy.cpp
+ ${STARVLA_DIR}/fast_policy.h
+ ${STARVLA_DIR}/groot_policy.cpp
+ ${STARVLA_DIR}/groot_policy.h
+ ${STARVLA_DIR}/groot_prompt.cpp
+ ${STARVLA_DIR}/groot_prompt.h
+ ${STARVLA_DIR}/normalization.cpp
+ ${STARVLA_DIR}/normalization.h
+ ${STARVLA_DIR}/oft_image_preprocess.cpp
+ ${STARVLA_DIR}/oft_image_preprocess.h
+ ${STARVLA_DIR}/oft_prompt.cpp
+ ${STARVLA_DIR}/oft_prompt.h
+ ${STARVLA_DIR}/oft_policy.cpp
+ ${STARVLA_DIR}/oft_policy.h
+ ${STARVLA_DIR}/pi_policy.cpp
+ ${STARVLA_DIR}/pi_policy.h
+ ${STARVLA_DIR}/pi_v3_policy.cpp
+ ${STARVLA_DIR}/pi_v3_policy.h
+ ${STARVLA_DIR}/policy_gguf.h
+ ${STARVLA_DIR}/qwen3vl_bridge.cpp
+ ${STARVLA_DIR}/qwen3vl_bridge.h
+ ${STARVLA_DIR}/starvla_engine.cpp
+ ${STARVLA_DIR}/starvla_engine.h
+ third_party/llama.cpp/examples/gguf-hash/deps/sha256/sha256.c
+)
+target_include_directories(starvla_runtime
+ PUBLIC
+ ${CMAKE_CURRENT_SOURCE_DIR}/src
+ ${ROBOT_CPP_LLAMA_INCLUDE_DIRS}
+ PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}/third_party/llama.cpp/vendor
+ ${CMAKE_CURRENT_SOURCE_DIR}/third_party/llama.cpp/examples/gguf-hash/deps
+ ${CMAKE_CURRENT_SOURCE_DIR}/third_party/llama.cpp/examples/gguf-hash/deps/sha256
+)
+target_link_libraries(starvla_runtime PUBLIC robotcpp_model_common llama mtmd)
+target_compile_features(starvla_runtime PUBLIC cxx_std_17)
+if(GGML_CUDA)
+ enable_language(CUDA)
+ target_sources(starvla_runtime PRIVATE ${STARVLA_DIR}/qwen_bf16_round_cuda.cu)
+ target_compile_definitions(starvla_runtime PRIVATE ROBOTCPP_STARVLA_CUDA=1)
+ set_property(TARGET starvla_runtime PROPERTY CUDA_STANDARD 17)
+endif()
+if(NOT MSVC)
+ target_compile_options(starvla_runtime PRIVATE -Wno-cast-qual)
+endif()
+endif()
+
add_library(robotcpp STATIC
+ src/models/argument_parse.h
src/models/model.h
src/models/model_factory.cpp
src/models/pi0/pi0_model.cpp
@@ -108,6 +204,19 @@ target_include_directories(robotcpp
)
target_link_libraries(robotcpp PUBLIC smolvla_runtime pi0_engine)
target_compile_features(robotcpp PUBLIC cxx_std_17)
+if(ROBOT_CPP_BUILD_STARVLA)
+ target_sources(robotcpp PRIVATE
+ ${STARVLA_DIR}/starvla_model.cpp
+ ${STARVLA_DIR}/starvla_model.h)
+ target_link_libraries(robotcpp PUBLIC starvla_runtime)
+ target_compile_definitions(robotcpp PUBLIC ROBOT_CPP_BUILD_STARVLA=1)
+endif()
+
+if(BUILD_TESTING AND ROBOT_CPP_BUILD_STARVLA)
+ add_executable(robotcpp-starvla-model-test tests/starvla/model_test.cpp)
+ target_link_libraries(robotcpp-starvla-model-test PRIVATE robotcpp)
+ add_test(NAME robotcpp-starvla-model-test COMMAND robotcpp-starvla-model-test)
+endif()
if(ROBOT_CPP_BUILD_ROBOT_SERVER OR ROBOT_CPP_BUILD_ROBOT_CLIENT)
add_library(robot_server_common STATIC
@@ -139,6 +248,7 @@ if(ROBOT_CPP_BUILD_ROBOT_CLIENT)
set_target_properties(model-cpp-client-example PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
target_link_libraries(model-cpp-client-example PRIVATE model_client_cpp)
target_compile_features(model-cpp-client-example PRIVATE cxx_std_17)
+
endif()
if(ROBOT_CPP_BUILD_ROBOT_SERVER)
@@ -165,7 +275,6 @@ if(ROBOT_CPP_BUILD_ROBOT_SERVER)
)
target_link_libraries(model-server PRIVATE robot_server_core robotcpp)
target_compile_features(model-server PRIVATE cxx_std_17)
-
add_executable(smolvla-raw-predict ${ROBOT_SERVER_DIR}/test/smolvla_raw_predict.cpp)
set_target_properties(smolvla-raw-predict PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
target_include_directories(smolvla-raw-predict PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src ${SMOLVLA_DIR})
diff --git a/README.md b/README.md
index 055e826..4e02885 100644
--- a/README.md
+++ b/README.md
@@ -54,8 +54,13 @@ We also provide two tools to support robot model development:
git clone https://github.com/Robot-cpp/robot.cpp
cd robot.cpp
git submodule update --init --recursive
+./tools/apply_patches.sh
```
+The launch scripts below configure and build `model-server` automatically. For
+a manual StarVLA build, enable `ROBOT_CPP_BUILD_STARVLA`; see the
+[Robot Server build instructions](robot_server/README.md#manual-build).
+
This section introduces three usage paths to help you quickly understand the repository:
* Starting `model-server` and connecting it to a minimal dummy `model-client`.
@@ -95,6 +100,14 @@ After downloading, run `model-server` like this:
For general local setups, we provide ready-to-use build-and-launch shells for three platforms. You can modify the environment variables inside the scripts, or override them directly with `export`. See [robot_server/README.md](robot_server/README.md) for details.
+For example, from the repository root on Linux with CUDA:
+
+```bash
+export ROBOT_CPP_ROOT="$PWD"
+export GGUF_DIR=/path/to/smolvla-so101-fp32
+bash robot_server/shell/launch_robot_server_linux_cuda.sh
+```
+
| Backend | macOS | Linux | Windows |
| ------- | ------------------------------------------------------- | -------------------------------------------------------- | ----------------------------------------------------------- |
| CUDA | - | `robot_server/shell/launch_robot_server_linux_cuda.sh` | `robot_server/shell/launch_robot_server_windows_cuda.bat` |
@@ -127,7 +140,7 @@ We provide a build-to-run example in `robot_client/shell/cpp_client_example.sh`.
| `ROBOT_CPP_ROOT` | unset; required | Repository root. |
| `BUILD_DIR` | `${ROBOT_CPP_ROOT}/build_robot_client` | C++ client CMake build directory. |
| `PORT` | `5555` | Server port used by the client. |
-| `BUILD_CLIENT` | `0` | Whether to force rebuild the client. Set to`1` to rebuild even if the binary already exists. |
+| `BUILD_CLIENT` | `0` | Whether to force rebuild the client. Set to `1` to rebuild even if the binary already exists. |
| `CMAKE_BIN` | `cmake` | CMake command path, useful for selecting a custom CMake binary. |
Then run:
@@ -138,7 +151,9 @@ bash robot_client/shell/cpp_client_example.sh
### 🧪 Using model-server in simulation, using LIBERO as the example
-See the [LIBERO simulation evaluation guide](eval/libero/README.md).
+See the [LIBERO simulation evaluation guide](eval/libero/README.md). To run
+local StarVLA GGUF models on the WidowX Bridge tasks, see the
+[SimplerEnv Bridge guide](eval/simpler_env/README.md).
### 🦾 Using model-server on real hardware, using SO-101 as the example
@@ -150,20 +165,33 @@ See the [SO-101 deployment guide](eval/lerobot_so101/README.md).
## ⚡ Performance
-We benchmark Robot.cpp on several platforms. Each measurement uses 5 warmup runs and 100 loop runs. The reported latency is the average time from receiving the image, through preprocessing and forward inference, to producing a usable action chunk, measured in milliseconds. All state projectors remain in f32 precision.
+We benchmark Robot.cpp on several platforms. Each measurement uses 5 warmup runs and 100 loop runs. The reported latency is the average time from receiving the image, through preprocessing and forward inference, to producing a usable action chunk, measured in milliseconds. State projectors, where present, remain in f32 precision.
For the LIBERO setting, the input contains two 256x256 images and an 8-dimensional state. For the SO-101 real-robot setting, the input contains one 224x224 image and a 6-dimensional state.
For SmolVLA preprocessing, we follow the official default setting: images are first resized to 512x512.
-| Model | Mac M4 Pro (CPU) | Mac M4 Pro (Metal) | RTX 4090 | RTX 3060 | A100 | Jetson AGX Orin |
-| ---------------------- | ---------------: | -----------------: | -------: | ----------: | ---: | --------------: |
-| smolvla@libero (bf16*) | 527 | 216 | 28 | 116 | 43 | 282 |
-| smolvla@libero (f32) | 577 | 236 | 32 | 142 | 42 | 299 |
-| smolvla@so-101 (bf16*) | 339 | 145 | 23 | 77 | 36 | 184 |
-| smolvla@so-101 (f32) | 396 | 158 | 24 | 92 | 34 | 200 |
-| pi0@libero (f32) | 1839 | 710 | 83 | OOM/offload | 71 | 956 |
-| pi0@libero (bf16*) | 1954 | 635 | 57 | 267 | 66 | 498 |
+For StarVLA, the input contains one 224x224 image and no robot state. Qwen and
+the multimodal projector use bf16; OFT, GR00T, PI, and PI_v3 policies use f32.
+FAST stores its action codec in the policy GGUF.
+The StarVLA A100 results use an A100-PCIE-40GB with 8 CPU threads,
+`n_ctx=2048`, `n_batch=2048`, and noise seed 0.
+
+| Model | Mac M4 Pro (CPU) | Mac M4 Pro (Metal) | RTX 4090 | RTX 3060 | A100 | Jetson AGX Orin |
+| ----------------------------- | ---------------: | -----------------: | -------: | ----------: | ---: | --------------: |
+| smolvla@libero (bf16*) | 527 | 216 | 28 | 116 | 43 | 282 |
+| smolvla@libero (f32) | 577 | 236 | 32 | 142 | 42 | 299 |
+| smolvla@so-101 (bf16*) | 339 | 145 | 23 | 77 | 36 | 184 |
+| smolvla@so-101 (f32) | 396 | 158 | 24 | 92 | 34 | 200 |
+| pi0@libero (f32) | 1839 | 710 | 83 | OOM/offload | 71 | 956 |
+| pi0@libero (bf16*) | 1954 | 635 | 57 | 267 | 66 | 498 |
+| starvla/oft@bridge | - | - | - | - | 50 | - |
+| starvla/groot@bridge | - | - | - | - | 54 | - |
+| starvla/pi_v3@bridge | - | - | - | - | 112 | - |
+| starvla/qwen25_oft@bridge | - | - | - | - | 42 | - |
+| starvla/qwen25_groot@bridge | - | - | - | - | 51 | - |
+| starvla/qwen25_pi@bridge | - | - | - | - | 101 | - |
+| starvla/qwen25_fast@bridge | - | - | - | - | 386 | - |
> `bf16*`: on Mac, f16 results are used in place of bf16 because current Mac bf16 support is not ideal.
> `OOM/offload`: pi0@libero (f32) runs out of memory on RTX 3060 and triggers offload, so we do not report a latency number for now.
@@ -230,6 +258,55 @@ This section lists converted GGUF models that can be used directly with `model-s
f32 |
pi0-libero-f32 |
+
+ | StarVLA Qwen3-VL OFT |
+ Bridge |
+ StarVLA/Qwen3VL-OFT-Bridge-RT-1 |
+ bf16 + f32 policy |
+ starvla-qwen3-oft-bridge-bf16 |
+
+
+ | StarVLA Qwen3-VL GR00T |
+ Bridge |
+ StarVLA/Qwen3VL-GR00T-Bridge-RT-1 |
+ bf16 + f32 policy |
+ starvla-qwen3-groot-bridge-bf16 |
+
+
+ | StarVLA Qwen3-VL PI_v3 |
+ Bridge |
+ StarVLA/Qwen3VL-PI_v3-Bridge-RT_1 |
+ bf16 + f32 policy |
+ starvla-qwen3-pi-v3-bridge-bf16 |
+
+
+ | StarVLA Qwen2.5-VL OFT |
+ Bridge |
+ StarVLA/Qwen-OFT-Bridge-RT-1 |
+ bf16 + f32 policy |
+ starvla-qwen25-oft-bridge-bf16 |
+
+
+ | StarVLA Qwen2.5-VL GR00T |
+ Bridge |
+ StarVLA/Qwen-GR00T-Bridge-RT-1 |
+ bf16 + f32 policy |
+ starvla-qwen25-groot-bridge-bf16 |
+
+
+ | StarVLA Qwen2.5-VL PI |
+ Bridge |
+ StarVLA/Qwen-PI-Bridge-RT-1 |
+ bf16 + f32 policy |
+ starvla-qwen25-pi-bridge-bf16 |
+
+
+ | StarVLA Qwen2.5-VL FAST |
+ Bridge |
+ StarVLA/Qwen-FAST-Bridge-RT-1 |
+ bf16 + codec |
+ starvla-qwen25-fast-bridge-bf16 |
+
@@ -269,6 +346,7 @@ robot.cpp/
├── eval/
│ ├── base_platform.py # Shared base class for real-robot platforms
│ ├── libero/ # LIBERO simulation evaluation
+│ ├── simpler_env/ # SimplerEnv WidowX / Bridge evaluation
│ └── lerobot_so101/ # SO-101 real-robot scripts and examples
└── third_party/
├── llama.cpp/ # ggml / llama.cpp backend
@@ -325,6 +403,7 @@ Robot.cpp's design and implementation benefit from several excellent open-source
* [llama.cpp](https://github.com/ggerganov/llama.cpp): provides lightweight local inference, the GGML/GGUF ecosystem, and cross-platform backend foundations. This project continues building robot model inference capabilities on top of its engineering philosophy and low-level runtime.
* [LeRobot](https://github.com/huggingface/lerobot): provides reference implementations for robot data, policy training, and real-robot integration. The SO-101 real-robot example and parts of the evaluation flow in this project are inspired by the LeRobot ecosystem.
* [LIBERO](https://github.com/Lifelong-Robot-Learning/LIBERO): provides robot simulation tasks and evaluation benchmarks. The LIBERO simulation evaluation flow in this project is based on its task environments and benchmark design.
+* [SimplerEnv](https://github.com/simpler-env/SimplerEnv): provides real-to-sim robot evaluation environments. The StarVLA Bridge success-rate evaluation uses its WidowX task suite and official visual-matching assets.
* [OpenPI](https://github.com/Physical-Intelligence/openpi): provides the pi0 policy model and related open-source implementation. The pi0 runtime, conversion, and evaluation work in this project references OpenPI's model design.
Thanks to these projects and communities for their contributions to robot learning and on-device inference.
diff --git a/README_ZH.md b/README_ZH.md
index 78c5e4a..0d61dfa 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -54,8 +54,13 @@ Robot.cpp是一个轻量化的on-device机器人模型推理框架,在llama.cp
git clone https://github.com/Robot-cpp/robot.cpp
cd robot.cpp
git submodule update --init --recursive
+./tools/apply_patches.sh
```
+下文的启动脚本会自动配置并编译 `model-server`。手动构建 StarVLA 时需要开启
+`ROBOT_CPP_BUILD_STARVLA`,详见
+[Robot Server 构建说明](robot_server/README_ZH.md#手动构建)。
+
我们介绍三类使用案例来帮助你快速了解本仓库:
* model-server的启动,其与最小dummy model-client通信的案例。
@@ -95,6 +100,14 @@ git submodule update --init --recursive
对于更加一般的情况,我们也提供了三个平台的开箱即用编译+启动的shell,可以通过修改shell里的环境变量,或者直接export的形式来快速在本机实现启动。详情参见 [robot_server/README_ZH.md](robot_server/README_ZH.md)
+例如,在 Linux CUDA 环境中从仓库根目录运行:
+
+```bash
+export ROBOT_CPP_ROOT="$PWD"
+export GGUF_DIR=/path/to/smolvla-so101-fp32
+bash robot_server/shell/launch_robot_server_linux_cuda.sh
+```
+
| Backend | macOS | Linux | Windows |
| ------- | ------------------------------------------------------- | -------------------------------------------------------- | ----------------------------------------------------------- |
| CUDA | - | `robot_server/shell/launch_robot_server_linux_cuda.sh` | `robot_server/shell/launch_robot_server_windows_cuda.bat` |
@@ -127,7 +140,7 @@ python robot_client/examples/python/minimal_example.py
| `ROBOT_CPP_ROOT` | 无,必须设置 | 仓库根目录。 |
| `BUILD_DIR` | `${ROBOT_CPP_ROOT}/build_robot_client` | C++ client 的 CMake build 目录 |
| `PORT` | `5555` | client 连接的 server port |
-| `BUILD_CLIENT` | `0` | 是否强制重新build client。设为`1` 时即使 binary 已存在也会重新 build |
+| `BUILD_CLIENT` | `0` | 是否强制重新build client。设为 `1` 时即使 binary 已存在也会重新 build |
| `CMAKE_BIN` | `cmake` | 使用的 CMake 命令路径,可用于指定自定义 CMake |
然后运行下面的bash:
@@ -138,7 +151,9 @@ bash robot_client/shell/cpp_client_example.sh
### 🧪 model-server在仿真平台上的使用(以LIBERO为例)
-详见 [LIBERO 仿真评测说明](eval/libero/README_ZH.md)。
+详见 [LIBERO 仿真评测说明](eval/libero/README_ZH.md)。在 WidowX Bridge 任务上运行
+StarVLA,并比较 Python checkpoint 与 GGUF 的方法见
+[SimplerEnv Bridge 说明](eval/simpler_env/README_ZH.md)。
### 🦾 model-server在真机平台上的使用(以SO-101为例)
@@ -156,6 +171,11 @@ bash robot_client/shell/cpp_client_example.sh
其中对于smolvla的preprocess设定,参考官方的基本设定,即首先会将图片变成512*512。
+StarVLA 使用一张 224x224 图像且不输入 robot state。Qwen 和 multimodal projector
+使用 bf16,OFT、GR00T、PI 和 PI_v3 policy 使用 f32;FAST 的 policy GGUF 保存 action
+codec。A100 数据在 A100-PCIE-40GB、8 个 CPU 线程、`n_ctx=2048`、`n_batch=2048` 和
+noise seed 0 下测得。
+
| Model | Mac M4 Pro (CPU) | Mac M4 Pro (Metal) | RTX 4090 | RTX 3060 | A100 | Jetson AGX Orin |
| ---------------------- | ---------------: | -----------------: | -------: | ----------: | ---: | --------------: |
| smolvla@libero (bf16*) | 527 | 216 | 28 | 116 | 43 | 282 |
@@ -164,15 +184,24 @@ bash robot_client/shell/cpp_client_example.sh
| smolvla@so-101 (f32) | 396 | 158 | 24 | 92 | 34 | 200 |
| pi0@libero (f32) | 1839 | 710 | 83 | OOM/offload | 71 | 956 |
| pi0@libero (bf16*) | 1954 | 635 | 57 | 267 | 66 | 498 |
+| starvla/oft@bridge | - | - | - | - | 50 | - |
+| starvla/groot@bridge | - | - | - | - | 54 | - |
+| starvla/pi_v3@bridge | - | - | - | - | 112 | - |
+| starvla/qwen25_oft@bridge | - | - | - | - | 42 | - |
+| starvla/qwen25_groot@bridge | - | - | - | - | 51 | - |
+| starvla/qwen25_pi@bridge | - | - | - | - | 101 | - |
+| starvla/qwen25_fast@bridge | - | - | - | - | 386 | - |
> `bf16*`:在 Mac上使用 f16 结果替代 bf16,因为当前 Mac对 bf16 的支持不够好。
> `OOM/offload`:pi0@libero (f32) 在 RTX 3060 上会 OOM 并触发 offload,因此暂时不报告 latency 数值。
---
-## 🧩 model-zoo
+## 🧩 Model Zoo
-这里整理一些已经转换好的 GGUF 模型,可以直接配合 `model-server` 做smoke test,以方便quick start!但针对自己的实际场景,我们推荐使用[hf2gguf](tools/hf2gguf/README_ZH.md)来生成自己的GGUF model!并且对于不同的部分,您还可以自定义不同的精度,来实现不同部分的精度组合(事实上,不同部分的最优精度通常是不同的),我们的例子中,state proj始终保持f32精度,其他的gguf随着precision精度变化而变化,您可以自行组合,探索更好更高效的性能tradeoff!
+下表列出可直接配合 `model-server` 使用的 GGUF 模型。实际部署时,建议使用
+[`hf2gguf`](tools/hf2gguf/README_ZH.md) 转换自己的 checkpoint。各组件可以分别选择
+精度;表中示例的 state projector 固定为 f32,其余组件采用标注的精度。
@@ -269,6 +347,7 @@ robot.cpp/
├── eval/
│ ├── base_platform.py # 真机 platform 的统一基类
│ ├── libero/ # LIBERO 仿真评测
+│ ├── simpler_env/ # SimplerEnv WidowX / Bridge 仿真评测
│ └── lerobot_so101/ # SO-101 真机相关脚本与示例
└── third_party/
├── llama.cpp/ # ggml / llama.cpp 后端
@@ -325,6 +404,7 @@ robot.cpp 的设计与实现受益于多个优秀的开源项目:
* [llama.cpp](https://github.com/ggerganov/llama.cpp):提供了轻量化本地推理、GGML/GGUF 生态与跨平台后端基础,本项目在其工程哲学和底层能力上继续构建机器人模型推理框架。
* [LeRobot](https://github.com/huggingface/lerobot):提供了机器人数据、策略训练与真实机器人接入的参考实现,本项目的 SO-101 真机示例与部分评测流程参考了 LeRobot 生态。
* [LIBERO](https://github.com/Lifelong-Robot-Learning/LIBERO):提供了机器人仿真任务与评测基准,本项目的 LIBERO 仿真评测流程基于其任务环境与 benchmark 设计。
+* [SimplerEnv](https://github.com/simpler-env/SimplerEnv):提供了 real-to-sim 机器人评测环境,本项目的 StarVLA Bridge 成功率评测使用其 WidowX 任务集与官方 visual-matching 资产。
* [OpenPI](https://github.com/Physical-Intelligence/openpi):提供了pi0策略模型与相关开源实现,本项目的 pi0 相关 runtime、转换与评测工作参考了 OpenPI 的模型设计。
感谢这些项目和社区为机器人学习与端侧推理生态做出的贡献。
diff --git a/eval/README.md b/eval/README.md
index 9cf9e75..0d9e672 100644
--- a/eval/README.md
+++ b/eval/README.md
@@ -22,6 +22,7 @@ The repo root [README.md](../README.md) describes the three-layer layout:
eval/
├── base_platform.py # Shared base class for real-robot / sim platforms
├── libero/ # LIBERO sim benchmark (multi-camera, batch rollout)
+├── simpler_env/ # SimplerEnv WidowX / Bridge closed-loop benchmark
└── lerobot_so101/ # SO-101 real-robot sync closed-loop example
```
@@ -30,12 +31,16 @@ eval/
| Directory | Scenario | Notes |
|---|---|---|
| [`libero/`](libero/README.md) | Sim eval | LIBERO benchmark with C++ policy rollout and LeRobot baseline. [中文](libero/README_ZH.md) |
+| [`simpler_env/`](simpler_env/README.md) | Sim eval | Runs StarVLA Python and GGUF on the SimplerEnv WidowX Bridge tasks. [中文](simpler_env/README_ZH.md) |
| [`lerobot_so101/`](lerobot_so101/README.md) | Real robot | SO-101 follower + single-camera observe → predict → act loop. [中文](lerobot_so101/README_ZH.md) |
The two examples are organized slightly differently:
- **SO-101** follows the standard `BasePlatform` + `RobotPolicy` + `SyncControlLoop` path—use it as the template for new real-robot platforms.
- **LIBERO** implements a dedicated observation adapter under `eval/libero/policy/` (multi-camera, state packing, sim rollout) and does **not** inherit `BasePlatform`—use it as a reference for new **sim benchmarks**.
+- **SimplerEnv** follows the same dedicated runner pattern and implements the
+ WidowX action transform, temporal ensemble, normalization profile, and Bridge
+ task settings for both Python and C++ runs.
## Standard closed-loop data flow
@@ -237,6 +242,7 @@ LIBERO’s [`ModelServerPolicy`](libero/policy/model_server.py) is an example of
- [SO-101 real-robot guide](lerobot_so101/README.md) · [中文](lerobot_so101/README_ZH.md)
- [LIBERO sim eval](libero/README.md) · [中文](libero/README_ZH.md)
+- [SimplerEnv Bridge eval](simpler_env/README.md) · [中文](simpler_env/README_ZH.md)
- [robot_server launch and protocol](../robot_server/README.md)
- [robot_client and policy](../robot_client/README.md)
- [Adding a new model runtime](../src/README.md)
diff --git a/eval/README_ZH.md b/eval/README_ZH.md
index eb1e781..99784b1 100644
--- a/eval/README_ZH.md
+++ b/eval/README_ZH.md
@@ -24,6 +24,7 @@
eval/
├── base_platform.py # 真机 / 仿真 platform 的统一基类
├── libero/ # LIBERO 仿真 benchmark(多相机、批量 rollout)
+├── simpler_env/ # SimplerEnv WidowX / Bridge 闭环 benchmark
└── lerobot_so101/ # SO-101 真机同步闭环示例
```
@@ -33,6 +34,7 @@ eval/
| 目录 | 场景 | 说明 |
| ---------------------------------------------- | ---- | ------------------------------------------------------------------------------------------ |
| `[libero/](libero/README_ZH.md)` | 仿真评测 | 面向 LIBERO benchmark,含 C++ policy rollout 与 LeRobot baseline 对比。[English](libero/README.md) |
+| `[simpler_env/](simpler_env/README_ZH.md)` | 仿真评测 | 在 SimplerEnv WidowX Bridge 任务上运行并比较 StarVLA Python 与 GGUF。[English](simpler_env/README.md) |
| `[lerobot_so101/](lerobot_so101/README_ZH.md)` | 真机闭环 | SO-101 follower + 单相机的 observe → predict → act 同步控制。[English](lerobot_so101/README.md) |
@@ -40,6 +42,7 @@ eval/
- **SO-101** 走标准 `BasePlatform` + `RobotPolicy` + `SyncControlLoop` 路径,适合作为新增真机 platform 的模板。
- **LIBERO** 在 `eval/libero/policy/` 里实现了专用的 observation 适配(多相机、state 拼接、仿真 rollout),不继承 `BasePlatform`,适合作为新增 **仿真 benchmark** 的参考。
+- **SimplerEnv** 沿用专用 runner 结构,为 Python 和 C++ 实现相同的 WidowX action 变换、时序集成、normalization profile 与 Bridge 任务设置。
## 标准闭环数据流
@@ -244,6 +247,7 @@ LIBERO 的 `[ModelServerPolicy](libero/policy/model_server.py)` 即为自定义
- [SO-101 真机使用说明](lerobot_so101/README_ZH.md)
- [LIBERO 仿真评测说明](libero/README_ZH.md)
+- [SimplerEnv Bridge 仿真评测说明](simpler_env/README_ZH.md)
- [robot_server 启动与协议](../robot_server/README_ZH.md)
- [robot_client 与 policy](../robot_client/README.md)
- [新增模型 runtime](../src/README_ZH.md)
diff --git a/eval/simpler_env/README.md b/eval/simpler_env/README.md
new file mode 100644
index 0000000..b22dc91
--- /dev/null
+++ b/eval/simpler_env/README.md
@@ -0,0 +1,97 @@
+# SimplerEnv WidowX Bridge Eval
+
+This directory evaluates the robot.cpp StarVLA GGUF runtime on the SimplerEnv
+WidowX Bridge tasks.
+
+## Protocol
+
+- Four Bridge tasks with object episodes `0..23`
+- At most 120 steps per episode at 5 Hz
+- Visual-matching RGB overlay resized to 224x224 with OpenCV `INTER_AREA`
+- One action chunk per step with the official seven-prediction adaptive ensemble
+
+A full run contains 96 rollouts. The result reports overall and per-task success
+rates; subset runs use `partial` coverage.
+
+## Setup
+
+Convert a checkpoint as described in the
+[StarVLA guide](../../tools/hf2gguf/starvla/README.md) and build the CUDA runtime.
+
+The environment uses these revisions:
+
+```text
+SimplerEnv: 06accaca93535902d408da4855f21cece12bceb7
+ManiSkill2_real2sim: ef7a4d4fdf4b69f2c2154db5b15b9ac8dfe10682
+```
+
+```bash
+conda env create -f eval/simpler_env/environment.yaml
+conda activate robotcpp-simpler-env
+
+git clone --recurse-submodules https://github.com/simpler-env/SimplerEnv \
+ ckpts/simpler_env/source/SimplerEnv
+git -C ckpts/simpler_env/source/SimplerEnv checkout \
+ 06accaca93535902d408da4855f21cece12bceb7
+git -C ckpts/simpler_env/source/SimplerEnv submodule update --init --recursive
+
+pip install -e ckpts/simpler_env/source/SimplerEnv/ManiSkill2_real2sim
+pip install -e ckpts/simpler_env/source/SimplerEnv
+```
+
+Headless simulation requires a working Vulkan ICD. Run SimplerEnv's environment
+test first to confirm that SAPIEN can find a rendering device.
+
+## Run
+
+`VARIANT` accepts `oft`, `groot`, `pi_v3`, `qwen25_oft`, `qwen25_groot`,
+`qwen25_pi`, and `qwen25_fast`.
+
+Run the full profile:
+
+```bash
+CUDA_VISIBLE_DEVICES=0 \
+VARIANT=oft \
+OUTPUT=ckpts/starvla/results/oft/bridge.json \
+bash eval/simpler_env/scripts/run_model_server.sh
+```
+
+Run one smoke episode:
+
+```bash
+CUDA_VISIBLE_DEVICES=0 \
+VARIANT=groot TASK_IDS=0 EPISODE_IDS=0 \
+bash eval/simpler_env/scripts/run_model_server.sh
+```
+
+The script reads three GGUF files from `ckpts/starvla/gguf/` and uses
+`build_cuda/bin/model-server` by default. Common overrides are `GGUF_DIR`,
+`SERVER_BIN`, `PYTHON`, `SIMPLER_ENV_ROOT`, `TASK_IDS`,
+`EPISODE_IDS`, `REPEATS`, and `OUTPUT`.
+
+Each task/repeat starts a fresh model-server. Results include checkpoint
+identity, rollout records, success rates, and timing summaries.
+
+## Latency
+
+Benchmark the official PyTorch checkpoint directly:
+
+```bash
+CUDA_VISIBLE_DEVICES=0 python -m eval.simpler_env.runners.latency_starvla \
+ --variant oft --compile-model
+```
+
+The runner selects the checkpoint, Qwen assets, and Bridge normalization from
+the StarVLA catalog. It reports policy, action unnormalization, and total
+latency after 5 warmup calls and 20 measured calls. Policy latency includes
+StarVLA's image/text preprocessing and model forward. Omit `--compile-model`
+for eager PyTorch. Compilation is lazy; the first FAST warmup can take several
+minutes and is not included in the reported measurements.
+
+For the robot.cpp model-server path, use the common server benchmark:
+
+```bash
+CUDA_VISIBLE_DEVICES=0 N_BATCH=2048 SKIP_BUILD=1 \
+GGUF_DIR="$PWD/ckpts/starvla/gguf/oft" \
+bash robot_server/test/test_server_latency.sh starvla linux-cuda starvla-bridge
+```
diff --git a/eval/simpler_env/README_ZH.md b/eval/simpler_env/README_ZH.md
new file mode 100644
index 0000000..4a049b2
--- /dev/null
+++ b/eval/simpler_env/README_ZH.md
@@ -0,0 +1,97 @@
+# SimplerEnv WidowX Bridge 评测
+
+本目录使用 robot.cpp 的 StarVLA GGUF runtime 运行 SimplerEnv WidowX Bridge 任务。
+
+## 评测设置
+
+- 四个 Bridge 任务,每个任务包含 object episode `0..23`
+- 每个 episode 最多 120 步,控制频率 5 Hz
+- visual-matching RGB overlay 使用 OpenCV `INTER_AREA` 缩放到 224x224
+- 每步预测一个 action chunk,并对最近七次预测做自适应集成
+
+完整评测包含 96 个 rollout。结果文件同时记录总体和各任务成功率;子集运行会标记为
+`partial` coverage。
+
+## 安装
+
+先按 [StarVLA 转换说明](../../tools/hf2gguf/starvla/README.md) 生成 GGUF,并完成 CUDA
+构建。
+
+SimplerEnv 使用以下 revision:
+
+```text
+SimplerEnv: 06accaca93535902d408da4855f21cece12bceb7
+ManiSkill2_real2sim: ef7a4d4fdf4b69f2c2154db5b15b9ac8dfe10682
+```
+
+```bash
+conda env create -f eval/simpler_env/environment.yaml
+conda activate robotcpp-simpler-env
+
+git clone --recurse-submodules https://github.com/simpler-env/SimplerEnv \
+ ckpts/simpler_env/source/SimplerEnv
+git -C ckpts/simpler_env/source/SimplerEnv checkout \
+ 06accaca93535902d408da4855f21cece12bceb7
+git -C ckpts/simpler_env/source/SimplerEnv submodule update --init --recursive
+
+pip install -e ckpts/simpler_env/source/SimplerEnv/ManiSkill2_real2sim
+pip install -e ckpts/simpler_env/source/SimplerEnv
+```
+
+无头运行需要可用的 Vulkan ICD。请先运行 SimplerEnv 自带的环境测试,确认 SAPIEN 能找到
+渲染设备。
+
+## 运行
+
+`VARIANT` 支持:
+
+```text
+oft groot pi_v3 qwen25_oft qwen25_groot qwen25_pi qwen25_fast
+```
+
+完整运行:
+
+```bash
+CUDA_VISIBLE_DEVICES=0 \
+VARIANT=oft \
+OUTPUT=ckpts/starvla/results/oft/bridge.json \
+bash eval/simpler_env/scripts/run_model_server.sh
+```
+
+快速检查一个 episode:
+
+```bash
+CUDA_VISIBLE_DEVICES=0 \
+VARIANT=groot TASK_IDS=0 EPISODE_IDS=0 \
+bash eval/simpler_env/scripts/run_model_server.sh
+```
+
+脚本默认从 `ckpts/starvla/gguf/` 读取三个 GGUF,并使用
+`build_cuda/bin/model-server`。常用覆盖项包括 `GGUF_DIR`、`SERVER_BIN`、`PYTHON`、
+`SIMPLER_ENV_ROOT`、`TASK_IDS`、`EPISODE_IDS`、`REPEATS` 和 `OUTPUT`。
+
+每个 task/repeat 会启动新的 model-server。结果包含 checkpoint 标识、rollout 明细、成功率
+和各阶段耗时。
+
+## 延迟测试
+
+直接测试官方 PyTorch checkpoint:
+
+```bash
+CUDA_VISIBLE_DEVICES=0 python -m eval.simpler_env.runners.latency_starvla \
+ --variant oft --compile-model
+```
+
+runner 会根据 StarVLA catalog 选择 checkpoint、Qwen 资源和 Bridge 归一化配置。默认先预热
+5 次,再统计 20 次推理,并分别报告 policy、action 反归一化和总耗时。policy 耗时包含
+StarVLA 的图像/文本预处理和模型 forward。去掉 `--compile-model` 即可测试 eager
+PyTorch。`torch.compile` 为惰性编译;FAST 第一次预热可能需要几分钟,这部分不会计入
+最终统计。
+
+robot.cpp model-server 使用统一的服务端测试脚本:
+
+```bash
+CUDA_VISIBLE_DEVICES=0 N_BATCH=2048 SKIP_BUILD=1 \
+GGUF_DIR="$PWD/ckpts/starvla/gguf/oft" \
+bash robot_server/test/test_server_latency.sh starvla linux-cuda starvla-bridge
+```
diff --git a/eval/simpler_env/__init__.py b/eval/simpler_env/__init__.py
new file mode 100644
index 0000000..55ef27c
--- /dev/null
+++ b/eval/simpler_env/__init__.py
@@ -0,0 +1 @@
+"""SimplerEnv evaluation integration."""
diff --git a/eval/simpler_env/environment.yaml b/eval/simpler_env/environment.yaml
new file mode 100644
index 0000000..1b17b4d
--- /dev/null
+++ b/eval/simpler_env/environment.yaml
@@ -0,0 +1,19 @@
+name: robotcpp-simpler-env
+channels:
+ - conda-forge
+dependencies:
+ - python=3.10
+ - pip
+ - ffmpeg
+ - pip:
+ - numpy==1.24.4
+ - scipy==1.11.4
+ - opencv-python==4.11.0.86
+ - opencv-python-headless==4.11.0.86
+ - setuptools<81
+ - transforms3d
+ - matplotlib
+ - mediapy
+ - tyro
+ - msgpack
+ - websockets
diff --git a/eval/simpler_env/policy/__init__.py b/eval/simpler_env/policy/__init__.py
new file mode 100644
index 0000000..3dbcd28
--- /dev/null
+++ b/eval/simpler_env/policy/__init__.py
@@ -0,0 +1 @@
+"""Policies used by the SimplerEnv runners."""
diff --git a/eval/simpler_env/policy/model_server.py b/eval/simpler_env/policy/model_server.py
new file mode 100644
index 0000000..b5fb743
--- /dev/null
+++ b/eval/simpler_env/policy/model_server.py
@@ -0,0 +1,234 @@
+"""StarVLA model-server adapter for the SimplerEnv WidowX benchmark."""
+
+from __future__ import annotations
+
+import time
+from collections import deque
+from typing import Any
+
+import numpy as np
+
+from eval.libero.policy.model_server import ServerTiming
+from robot_client.python.model_client import ModelClient, ModelResponse
+
+
+DEFAULT_IMAGE_NAME = "image_0"
+DEFAULT_IMAGE_SIZE = (224, 224)
+DEFAULT_ACTION_ENSEMBLE_HORIZON = 7
+DEFAULT_ADAPTIVE_ENSEMBLE_ALPHA = 0.1
+
+
+class AdaptiveEnsembler:
+ """StarVLA's cosine-similarity weighted temporal action ensemble."""
+
+ def __init__(self, horizon: int, alpha: float = DEFAULT_ADAPTIVE_ENSEMBLE_ALPHA):
+ if horizon <= 0:
+ raise ValueError("action ensemble horizon must be positive")
+ self.horizon = int(horizon)
+ self.alpha = float(alpha)
+ self._history: deque[np.ndarray] = deque(maxlen=self.horizon)
+
+ def reset(self) -> None:
+ self._history.clear()
+
+ def ensemble_action(self, action_chunk: np.ndarray) -> np.ndarray:
+ chunk = np.asarray(action_chunk)
+ if not np.issubdtype(chunk.dtype, np.floating):
+ chunk = chunk.astype(np.float32)
+ if chunk.ndim not in (1, 2):
+ raise ValueError(f"expected a 1D action or 2D action chunk, got shape={chunk.shape}")
+ if chunk.ndim == 2 and chunk.shape[0] < min(len(self._history) + 1, self.horizon):
+ raise ValueError("action chunk is shorter than the active ensemble history")
+
+ self._history.append(chunk)
+ count = len(self._history)
+ if chunk.ndim == 1:
+ current_predictions = np.stack(tuple(self._history))
+ else:
+ current_predictions = np.stack(
+ [prediction[index] for index, prediction in zip(range(count - 1, -1, -1), self._history)]
+ )
+
+ reference = current_predictions[-1]
+ dot = np.sum(current_predictions * reference, axis=1)
+ norms = np.linalg.norm(current_predictions, axis=1) * np.linalg.norm(reference)
+ cosine = dot / (norms + 1e-7)
+ weights = np.exp(self.alpha * cosine)
+ weights /= weights.sum()
+ return np.sum(weights[:, None] * current_predictions, axis=0)
+
+
+def resize_image_area(image: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
+ """Match the official StarVLA SimplerEnv client's OpenCV INTER_AREA resize."""
+
+ array = np.asarray(image)
+ if array.ndim != 3 or array.shape[2] != 3:
+ raise ValueError(f"expected an HWC RGB image, got shape={array.shape}")
+ if array.dtype != np.uint8:
+ raise ValueError(f"expected a uint8 RGB image, got dtype={array.dtype}")
+ width, height = (int(image_size[0]), int(image_size[1]))
+ if width <= 0 or height <= 0:
+ raise ValueError("image dimensions must be positive")
+ if array.shape[:2] == (height, width):
+ return np.ascontiguousarray(array)
+ try:
+ import cv2
+ except ImportError as exc:
+ raise RuntimeError("opencv-python-headless is required to resize SimplerEnv observations") from exc
+ return cv2.resize(array, (width, height), interpolation=cv2.INTER_AREA)
+
+
+def euler_xyz_to_axis_angle(rotation_delta: np.ndarray) -> np.ndarray:
+ """Use the same static-XYZ Euler convention as StarVLA's official adapter."""
+
+ roll, pitch, yaw = np.asarray(rotation_delta, dtype=np.float64).reshape(3) * 0.5
+ sr, cr = np.sin(roll), np.cos(roll)
+ sp, cp = np.sin(pitch), np.cos(pitch)
+ sy, cy = np.sin(yaw), np.cos(yaw)
+ quaternion = np.asarray(
+ [
+ cr * cp * cy + sr * sp * sy,
+ sr * cp * cy - cr * sp * sy,
+ cr * sp * cy + sr * cp * sy,
+ cr * cp * sy - sr * sp * cy,
+ ],
+ dtype=np.float64,
+ )
+ quaternion /= np.linalg.norm(quaternion)
+ vector_norm = float(np.linalg.norm(quaternion[1:]))
+ if vector_norm <= 1e-12:
+ return np.zeros(3, dtype=np.float64)
+ angle = 2.0 * np.arccos(np.clip(quaternion[0], -1.0, 1.0))
+ return quaternion[1:] * (angle / vector_norm)
+
+
+class SimplerEnvModelServerPolicy:
+ """Closed-loop WidowX policy matching StarVLA's official SimplerEnv adapter."""
+
+ def __init__(
+ self,
+ *,
+ host: str = "127.0.0.1",
+ port: int = 5555,
+ timeout: float | None = 120.0,
+ image_name: str = DEFAULT_IMAGE_NAME,
+ image_size: tuple[int, int] = DEFAULT_IMAGE_SIZE,
+ action_scale: float = 1.0,
+ action_ensemble: bool = True,
+ action_ensemble_horizon: int = DEFAULT_ACTION_ENSEMBLE_HORIZON,
+ adaptive_ensemble_alpha: float = DEFAULT_ADAPTIVE_ENSEMBLE_ALPHA,
+ client: ModelClient | None = None,
+ ):
+ self.client = client or ModelClient(host=host, port=port, timeout=timeout)
+ self.image_name = str(image_name)
+ self.image_size = (int(image_size[0]), int(image_size[1]))
+ self.action_scale = float(action_scale)
+ self.action_ensembler = (
+ AdaptiveEnsembler(action_ensemble_horizon, adaptive_ensemble_alpha)
+ if action_ensemble
+ else None
+ )
+ self.task_description: str | None = None
+ self.predict_calls = 0
+ self.timing_records: list[ServerTiming] = []
+ self._action_shape: tuple[int, int] | None = None
+
+ def health(self) -> str:
+ return self.client.health()
+
+ def action_shape(self) -> tuple[int, int]:
+ if self._action_shape is None:
+ raise RuntimeError("model-server has not returned an action chunk")
+ return self._action_shape
+
+ def _validate_response_actions(self, response: ModelResponse) -> np.ndarray:
+ shape = (int(response.chunk_size), int(response.action_dim))
+ if shape[0] <= 0 or shape[1] <= 0:
+ raise RuntimeError(f"model-server returned an invalid action shape: {shape}")
+ # Protocol actions are FP32. Preserve that dtype through temporal
+ # ensembling to match StarVLA's official SimplerEnv client.
+ actions = np.asarray(response.actions, dtype=np.float32)
+ if actions.shape != shape:
+ raise RuntimeError(
+ "model-server returned an invalid action matrix: "
+ f"wire_shape={shape}, decoded_shape={actions.shape}"
+ )
+ if not np.isfinite(actions).all():
+ raise RuntimeError("model-server returned non-finite action values")
+ if shape[1] != 7:
+ raise RuntimeError(f"SimplerEnv WidowX requires 7D actions, got {shape}")
+ if self.action_ensembler is not None and shape[0] < self.action_ensembler.horizon:
+ raise RuntimeError(
+ f"action chunk {shape[0]} is shorter than ensemble horizon "
+ f"{self.action_ensembler.horizon}"
+ )
+ if self._action_shape is not None and shape != self._action_shape:
+ raise RuntimeError(
+ f"model-server action shape changed from {self._action_shape} to {shape}"
+ )
+ self._action_shape = shape
+ return actions
+
+ def reset(
+ self,
+ task_description: str | None = None,
+ *,
+ reset_server: bool = True,
+ ) -> None:
+ self.task_description = task_description
+ if self.action_ensembler is not None:
+ self.action_ensembler.reset()
+ if reset_server:
+ self.client.reset()
+
+ def build_observation(self, image: np.ndarray, task_description: str) -> dict[str, Any]:
+ resized = resize_image_area(image, self.image_size)
+ observation = {
+ "images": [{"name": self.image_name, "image": resized}],
+ "state": [],
+ "prompt": task_description,
+ }
+ return observation
+
+ def predict_action_chunk(self, image: np.ndarray, task_description: str) -> ModelResponse:
+ request = self.build_observation(image, task_description)
+ started = time.perf_counter()
+ response = self.client.predict(request)
+ self._validate_response_actions(response)
+ self.timing_records.append(
+ ServerTiming(
+ roundtrip_ms=(time.perf_counter() - started) * 1000.0,
+ timings=response.timings,
+ )
+ )
+ self.predict_calls += 1
+ return response
+
+ def step(
+ self, image: np.ndarray, task_description: str | None = None
+ ) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
+ if task_description is not None and task_description != self.task_description:
+ self.reset(task_description, reset_server=False)
+ if self.task_description is None:
+ raise ValueError("task_description must be set before policy.step")
+
+ response = self.predict_action_chunk(image, self.task_description)
+ actions = np.asarray(response.actions, dtype=np.float32)
+ selected = (
+ self.action_ensembler.ensemble_action(actions)
+ if self.action_ensembler is not None
+ else actions[0]
+ )
+
+ raw_action = {
+ "world_vector": selected[:3].copy(),
+ "rotation_delta": selected[3:6].copy(),
+ "open_gripper": selected[6:7].copy(),
+ }
+ action = {
+ "world_vector": raw_action["world_vector"] * self.action_scale,
+ "rot_axangle": euler_xyz_to_axis_angle(raw_action["rotation_delta"]) * self.action_scale,
+ "gripper": 2.0 * (raw_action["open_gripper"] > 0.5).astype(np.float64) - 1.0,
+ "terminate_episode": np.asarray([0.0], dtype=np.float64),
+ }
+ return raw_action, action
diff --git a/eval/simpler_env/runners/__init__.py b/eval/simpler_env/runners/__init__.py
new file mode 100644
index 0000000..8145c73
--- /dev/null
+++ b/eval/simpler_env/runners/__init__.py
@@ -0,0 +1 @@
+"""SimplerEnv evaluation runners."""
diff --git a/eval/simpler_env/runners/latency_starvla.py b/eval/simpler_env/runners/latency_starvla.py
new file mode 100644
index 0000000..52972a7
--- /dev/null
+++ b/eval/simpler_env/runners/latency_starvla.py
@@ -0,0 +1,424 @@
+#!/usr/bin/env python3
+
+from __future__ import annotations
+
+import argparse
+import contextlib
+import gc
+import io
+import json
+import statistics
+import subprocess
+import sys
+import tarfile
+import tempfile
+import time
+from collections.abc import Iterator, Mapping
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import torch
+from PIL import Image
+
+from eval.libero.utils.common import DEFAULT_RESULTS_DIR, timestamp, write_json
+
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+STARVLA_TOOLS = REPO_ROOT / "tools" / "hf2gguf" / "starvla"
+if str(STARVLA_TOOLS) not in sys.path:
+ sys.path.insert(0, str(STARVLA_TOOLS))
+
+from starvla_checkpoint import ( # noqa: E402
+ DEFAULT_CATALOG,
+ get_qwen_asset,
+ get_variant,
+ load_catalog,
+ resolve_effective_config,
+)
+
+
+DEFAULT_PROMPT = "grab the block."
+LEGACY_PI_REVISION = "e872a8579055f9332add8a2549b9fd5599e11510"
+FULL_BF16_VARIANTS = {"oft", "qwen25_oft", "qwen25_pi"}
+FRAMEWORK_CLASSES = {
+ "oft": ("starVLA.model.framework.VLM4A.QwenOFT", "Qwenvl_OFT"),
+ "groot": ("starVLA.model.framework.VLM4A.QwenGR00T", "Qwen_GR00T"),
+ "pi_v3": ("starVLA.model.framework.VLM4A.QwenPI_v3", "Qwen_PI_v3"),
+ "fast": ("starVLA.model.framework.VLM4A.QwenFast", "Qwenvl_Fast"),
+}
+
+
+def build_parser() -> argparse.ArgumentParser:
+ variants = tuple(load_catalog(DEFAULT_CATALOG)["variants"])
+ parser = argparse.ArgumentParser(description="Benchmark an official StarVLA checkpoint with PyTorch.")
+ parser.add_argument("--variant", choices=variants, required=True)
+ parser.add_argument("--checkpoint-root", type=Path, default=REPO_ROOT / "ckpts" / "starvla")
+ parser.add_argument("--starvla-source", type=Path)
+ parser.add_argument("--device", default="cuda:0")
+ parser.add_argument("--compile-model", action=argparse.BooleanOptionalAction, default=False)
+ parser.add_argument("--compile-mode", default="default")
+ parser.add_argument("--warmup", type=int, default=5)
+ parser.add_argument("--loops", type=int, default=20)
+ parser.add_argument("--seed", type=int, default=0)
+ parser.add_argument("--prompt", default=DEFAULT_PROMPT)
+ parser.add_argument("--image-height", type=int, default=224)
+ parser.add_argument("--image-width", type=int, default=224)
+ parser.add_argument("--output", type=Path)
+ return parser
+
+
+def percentile(values: list[float], pct: float) -> float:
+ if len(values) == 1:
+ return values[0]
+ ordered = sorted(values)
+ pos = (len(ordered) - 1) * pct / 100.0
+ lo = int(pos)
+ hi = min(lo + 1, len(ordered) - 1)
+ return ordered[lo] * (hi - pos) + ordered[hi] * (pos - lo)
+
+
+def summarize(values: list[float]) -> dict[str, float | int]:
+ return {
+ "count": len(values),
+ "avg": statistics.fmean(values),
+ "min": min(values),
+ "p50": percentile(values, 50),
+ "p90": percentile(values, 90),
+ "p99": percentile(values, 99),
+ "max": max(values),
+ }
+
+
+def sync(device: str) -> None:
+ if device.startswith("cuda"):
+ torch.cuda.synchronize(torch.device(device))
+
+
+def checkpoint_paths(checkpoint_root: Path, variant_name: str) -> dict[str, Any]:
+ catalog = load_catalog(DEFAULT_CATALOG)
+ variant = get_variant(catalog, variant_name)
+ _qwen_name, qwen = get_qwen_asset(catalog, variant)
+ policy_dir = checkpoint_root / "sources" / variant["directory"] / variant["revision"]
+ qwen_dir = checkpoint_root / "sources" / qwen["directory"] / qwen["revision"]
+ checkpoint = policy_dir / variant["checkpoint"]["path"]
+ for path in (policy_dir, qwen_dir, checkpoint):
+ if not path.exists():
+ raise FileNotFoundError(path)
+ return {
+ "catalog": catalog,
+ "variant": variant,
+ "policy_dir": policy_dir.resolve(),
+ "qwen_dir": qwen_dir.resolve(),
+ "checkpoint": checkpoint.resolve(),
+ }
+
+
+def verify_source(source: Path, catalog: Mapping[str, Any]) -> str:
+ revision = subprocess.run(
+ ["git", "-C", str(source), "rev-parse", "HEAD"],
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout.strip()
+ expected = catalog["source_revisions"]["starvla"]
+ if revision != expected:
+ raise RuntimeError(f"StarVLA source revision must be {expected}, got {revision}")
+ changes = subprocess.run(
+ ["git", "-C", str(source), "status", "--porcelain"],
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout.strip()
+ if changes:
+ raise RuntimeError(f"StarVLA source checkout is not clean:\n{changes}")
+ return revision
+
+
+@contextlib.contextmanager
+def qwen_alias(qwen_dir: Path, backbone: str) -> Iterator[Path]:
+ name = "Qwen3-VL-4B-Instruct" if backbone == "qwen3_vl" else "Qwen2.5-VL-3B-Instruct"
+ with tempfile.TemporaryDirectory(prefix="starvla-latency-qwen-") as temporary:
+ alias = Path(temporary) / name
+ alias.symlink_to(qwen_dir, target_is_directory=True)
+ yield alias
+
+
+@contextlib.contextmanager
+def config_only_qwen(qwen_dir: Path, backbone: str) -> Iterator[None]:
+ import transformers
+
+ model_class = (
+ transformers.Qwen3VLForConditionalGeneration
+ if backbone == "qwen3_vl"
+ else transformers.Qwen2_5_VLForConditionalGeneration
+ )
+ original = model_class.__dict__.get("from_pretrained")
+
+ def from_config_only(_model_id: str, **_kwargs: Any) -> Any:
+ config = transformers.AutoConfig.from_pretrained(qwen_dir, local_files_only=True)
+ config._attn_implementation = "sdpa"
+ previous = torch.get_default_dtype()
+ try:
+ torch.set_default_dtype(torch.bfloat16)
+ with transformers.modeling_utils.no_init_weights():
+ return model_class(config)
+ finally:
+ torch.set_default_dtype(previous)
+
+ model_class.from_pretrained = staticmethod(from_config_only)
+ try:
+ yield
+ finally:
+ if original is None:
+ delattr(model_class, "from_pretrained")
+ else:
+ model_class.from_pretrained = original
+
+
+def extract_legacy_source(source: Path) -> tempfile.TemporaryDirectory[str]:
+ archive = subprocess.run(
+ ["git", "-C", str(source), "archive", "--format=tar", LEGACY_PI_REVISION],
+ check=True,
+ stdout=subprocess.PIPE,
+ ).stdout
+ holder: tempfile.TemporaryDirectory[str] = tempfile.TemporaryDirectory(prefix="starvla-pi-")
+ with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as stream:
+ stream.extractall(
+ holder.name,
+ filter=lambda member, target: None
+ if member.issym() or member.islnk()
+ else tarfile.data_filter(member, target),
+ )
+ return holder
+
+
+def fast_config(policy_dir: Path) -> dict[str, Any]:
+ import yaml
+
+ config = yaml.safe_load((policy_dir / "config.yaml").read_text(encoding="utf-8"))
+ config["framework"]["name"] = "QwenFast"
+ config["framework"]["action_model"]["action_model_type"] = "FAST"
+ config["framework"]["action_model"]["action_horizon"] = 16
+ return config
+
+
+def install_policy_dtype_bridge(framework: Any, framework_name: str) -> None:
+ if framework_name not in {"groot", "pi_v3"}:
+ return
+ action_model = framework.action_model
+ original_encoder = action_model.action_encoder.forward
+ original_dit = action_model.model.forward
+
+ def encoder(actions: Any, timesteps: Any) -> Any:
+ return original_encoder(actions.float(), timesteps)
+
+ def dit(*args: Any, **kwargs: Any) -> Any:
+ conditioning = kwargs.get("encoder_hidden_states", args[1] if len(args) > 1 else None)
+ if isinstance(conditioning, (list, tuple)):
+ conditioning = [value.float() for value in conditioning]
+ else:
+ conditioning = conditioning.float()
+ if "encoder_hidden_states" in kwargs:
+ kwargs["encoder_hidden_states"] = conditioning
+ else:
+ args = (args[0], conditioning, *args[2:])
+ return original_dit(*args, **kwargs)
+
+ action_model.action_encoder.forward = encoder
+ action_model.model.forward = dit
+
+
+def load_framework(paths: Mapping[str, Any], source: Path, device: str) -> tuple[Any, Any]:
+ import importlib
+
+ import yaml
+
+ variant = paths["variant"]
+ variant_name = variant["_catalog_key"]
+ framework_name = variant["framework"]
+ runtime_source = source
+ holder = None
+ if variant_name == "qwen25_pi":
+ holder = extract_legacy_source(source)
+ runtime_source = Path(holder.name)
+
+ sys.path.insert(0, str(runtime_source))
+ try:
+ if variant_name == "qwen25_pi":
+ module_name, class_name = "starVLA.model.framework.QwenPI", "Qwen_PI"
+ config = yaml.safe_load((paths["policy_dir"] / "config.yaml").read_text(encoding="utf-8"))
+ else:
+ module_name, class_name = FRAMEWORK_CLASSES[framework_name]
+ config = (
+ fast_config(paths["policy_dir"])
+ if framework_name == "fast"
+ else resolve_effective_config(paths["policy_dir"], variant_name, variant)
+ )
+
+ from starVLA.model.framework import share_tools
+
+ with qwen_alias(paths["qwen_dir"], variant["backbone"]) as alias:
+ config["framework"]["qwenvl"]["base_vlm"] = str(alias)
+ config["framework"]["qwenvl"]["attn_implementation"] = "sdpa"
+ cfg = share_tools.dict_to_namespace(config)
+ cfg.trainer.pretrained_checkpoint = None
+ module = importlib.import_module(module_name)
+ if framework_name == "fast":
+ from starVLA.model.modules.action_model.fast_ActionHeader import Fast_Action_Tokenizer
+
+ codec = paths["catalog"]["shared_assets"]["fast_codec"]
+ codec_dir = (
+ paths["policy_dir"].parents[1] / codec["directory"] / codec["revision"]
+ )
+ module.get_action_model = lambda config=None: Fast_Action_Tokenizer(str(codec_dir))
+ with config_only_qwen(paths["qwen_dir"], variant["backbone"]):
+ framework = getattr(module, class_name)(cfg)
+
+ state = torch.load(paths["checkpoint"], map_location="cpu", mmap=True, weights_only=True)
+ framework.load_state_dict(state, strict=True)
+ del state
+ gc.collect()
+ framework.norm_stats = json.loads(
+ (paths["policy_dir"] / "dataset_statistics.json").read_text(encoding="utf-8")
+ )
+ if variant_name in FULL_BF16_VARIANTS:
+ framework = framework.to(dtype=torch.bfloat16)
+ framework = framework.to(device).eval()
+ install_policy_dtype_bridge(framework, framework_name)
+ return framework, holder
+ except Exception:
+ if holder is not None:
+ holder.cleanup()
+ raise
+ finally:
+ if sys.path and sys.path[0] == str(runtime_source):
+ del sys.path[0]
+
+
+def enable_compile(framework: Any, backbone: str, framework_name: str, mode: str) -> None:
+ qwen = framework.qwen_vl_interface
+ if framework_name == "fast":
+ qwen.model.forward = torch.compile(qwen.model.forward, mode=mode, fullgraph=False)
+ elif backbone == "qwen3_vl":
+ model = qwen.model.model
+ model.visual.forward = torch.compile(model.visual.forward, mode=mode, fullgraph=False)
+ for layer in model.language_model.layers:
+ layer.forward = torch.compile(layer.forward, mode=mode, fullgraph=False)
+ else:
+ qwen.forward = torch.compile(qwen.forward, mode=mode, fullgraph=False)
+
+ if framework_name == "oft":
+ framework.action_model.predict_action = torch.compile(
+ framework.action_model.predict_action, mode=mode, fullgraph=False
+ )
+ elif framework_name != "fast":
+ framework.action_model.model.forward = torch.compile(
+ framework.action_model.model.forward, mode=mode, fullgraph=False
+ )
+
+
+def unnormalize(normalized: Any, statistics: Mapping[str, Any]) -> np.ndarray:
+ profile_name = next(iter(statistics))
+ stats = statistics[profile_name]["action"]
+ values = np.asarray(normalized, dtype=np.float32)
+ if values.shape != (1, 16, 7) or not np.isfinite(values).all():
+ raise ValueError(f"StarVLA returned invalid normalized actions: {values.shape}")
+ q01 = np.asarray(stats["q01"], dtype=np.float32)
+ q99 = np.asarray(stats["q99"], dtype=np.float32)
+ mask = np.asarray(stats["mask"], dtype=np.bool_)
+ result = np.empty_like(values)
+ result[..., mask] = (values[..., mask] + 1.0) * 0.5 * (q99[mask] - q01[mask]) + q01[mask]
+ result[..., ~mask] = (values[..., ~mask] > 0.5).astype(np.float32)
+ return result
+
+
+def predict(framework: Any, variant: str, image: Image.Image, prompt: str) -> Mapping[str, Any]:
+ if variant == "qwen25_pi":
+ return framework.predict_action(batch_images=[[image]], instructions=[prompt], state=None)
+ return framework.predict_action(examples=[{"image": [image], "lang": prompt}])
+
+
+def main() -> int:
+ args = build_parser().parse_args()
+ if args.warmup < 0 or args.loops <= 0:
+ raise ValueError("--warmup must be non-negative and --loops must be positive")
+ if not args.device.startswith("cuda") or not torch.cuda.is_available():
+ raise RuntimeError("StarVLA latency currently requires CUDA")
+
+ torch.manual_seed(args.seed)
+ np.random.seed(args.seed)
+ checkpoint_root = args.checkpoint_root.resolve()
+ source = (args.starvla_source or checkpoint_root / "source" / "starvla").resolve()
+ paths = checkpoint_paths(checkpoint_root, args.variant)
+ source_revision = verify_source(source, paths["catalog"])
+ output = args.output or DEFAULT_RESULTS_DIR / f"starvla-policy-latency-{args.variant}-{timestamp()}.json"
+
+ load_start = time.perf_counter()
+ framework, holder = load_framework(paths, source, args.device)
+ if args.compile_model:
+ enable_compile(framework, paths["variant"]["backbone"], paths["variant"]["framework"], args.compile_mode)
+ load_ms = (time.perf_counter() - load_start) * 1000.0
+
+ rng = np.random.default_rng(args.seed)
+ image = Image.fromarray(
+ rng.integers(0, 256, size=(args.image_height, args.image_width, 3), dtype=np.uint8), mode="RGB"
+ )
+ rows: list[dict[str, float]] = []
+ actions = None
+ print(
+ f"StarVLA latency: variant={args.variant} warmup={args.warmup} loops={args.loops} "
+ f"compile_model={args.compile_model} device={args.device}"
+ )
+ for index in range(args.warmup + args.loops):
+ sync(args.device)
+ started = time.perf_counter()
+ output_value = predict(framework, args.variant, image, args.prompt)
+ sync(args.device)
+ policy_ms = (time.perf_counter() - started) * 1000.0
+
+ unnorm_started = time.perf_counter()
+ actions = unnormalize(output_value["normalized_actions"], framework.norm_stats)
+ unnormalize_ms = (time.perf_counter() - unnorm_started) * 1000.0
+ total_ms = policy_ms + unnormalize_ms
+ if index >= args.warmup:
+ rows.append({"policy_ms": policy_ms, "unnormalize_ms": unnormalize_ms, "total_ms": total_ms})
+ print(
+ f"iter={index} policy_ms={policy_ms:.3f} unnormalize_ms={unnormalize_ms:.3f} "
+ f"total_ms={total_ms:.3f}",
+ flush=True,
+ )
+
+ assert actions is not None
+ variant = paths["variant"]
+ payload = {
+ "runner": "starvla-policy-latency",
+ "variant": args.variant,
+ "framework": variant["framework"],
+ "backbone": variant["backbone"],
+ "checkpoint": {"repo_id": variant["repo_id"], "revision": variant["revision"]},
+ "starvla_revision": source_revision,
+ "device": args.device,
+ "compile_model": args.compile_model,
+ "compile_mode": args.compile_mode if args.compile_model else None,
+ "warmup": args.warmup,
+ "loops": args.loops,
+ "load_ms": load_ms,
+ "action_shape": list(actions.shape),
+ "raw_input": {"image_shape_hwc": [args.image_height, args.image_width, 3], "prompt": args.prompt},
+ "timing_ms": {
+ key: summarize([row[key] for row in rows])
+ for key in ("policy_ms", "unnormalize_ms", "total_ms")
+ },
+ "rows": rows,
+ }
+ write_json(output, payload)
+ print(f"wrote {output}")
+ print(json.dumps(payload["timing_ms"], indent=2))
+ if holder is not None:
+ holder.cleanup()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/eval/simpler_env/runners/run_model_server.py b/eval/simpler_env/runners/run_model_server.py
new file mode 100755
index 0000000..74dcd8c
--- /dev/null
+++ b/eval/simpler_env/runners/run_model_server.py
@@ -0,0 +1,404 @@
+#!/usr/bin/env python3
+
+from __future__ import annotations
+
+import argparse
+import time
+from collections import defaultdict
+from copy import copy
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+from eval.libero.policy.model_server import (
+ average_timing,
+ maybe_launch_server,
+ parse_server_env,
+ server_command,
+ stop_server,
+ timing_summary,
+)
+from eval.libero.utils.common import DEFAULT_RESULTS_DIR, aggregate_episodes, timestamp, write_json
+from eval.simpler_env.policy.model_server import (
+ DEFAULT_ACTION_ENSEMBLE_HORIZON,
+ DEFAULT_ADAPTIVE_ENSEMBLE_ALPHA,
+ DEFAULT_IMAGE_NAME,
+ DEFAULT_IMAGE_SIZE,
+ SimplerEnvModelServerPolicy,
+)
+from eval.simpler_env.utils.environment import (
+ BRIDGE_EPISODE_COUNT,
+ BRIDGE_SUITE,
+ BRIDGE_TASKS,
+ BridgeTask,
+ apply_runtime_env,
+ close_env,
+ language_instruction,
+ make_env,
+ observation_image,
+ parse_episode_ids,
+ parse_task_ids,
+ reset_env,
+ selected_tasks,
+ simpler_env_root,
+)
+
+
+def _first_bool(value: Any) -> bool:
+ array = np.asarray(value).reshape(-1)
+ if array.size != 1:
+ raise RuntimeError(f"expected one termination value, got shape={np.asarray(value).shape}")
+ return bool(array[0])
+
+
+def _first_float(value: Any) -> float:
+ array = np.asarray(value).reshape(-1)
+ if array.size != 1 or not np.isfinite(array[0]):
+ raise RuntimeError(f"expected one finite reward value, got {value!r}")
+ return float(array[0])
+
+
+def _write_video(path: Path, images: list[np.ndarray], fps: int) -> None:
+ try:
+ from simpler_env.utils.visualization import write_video
+ except ImportError as exc:
+ raise RuntimeError("failed to import SimplerEnv video writer") from exc
+ path.parent.mkdir(parents=True, exist_ok=True)
+ write_video(str(path), images, fps=fps)
+
+
+def _command_with_noise_seed(command: list[str], seed: int) -> list[str]:
+ result = list(command)
+ for index, value in enumerate(result):
+ if value == "--noise-seed":
+ result[index + 1] = str(seed)
+ return result
+ if value.startswith("--noise-seed="):
+ result[index] = f"--noise-seed={seed}"
+ return result
+ return [*result, "--noise-seed", str(seed)]
+
+
+def _launch_fresh_server(args: argparse.Namespace, policy: SimplerEnvModelServerPolicy):
+ try:
+ health = policy.health()
+ except OSError:
+ pass
+ else:
+ raise RuntimeError(
+ f"refusing to reuse model-server at {args.host}:{args.port}: {health}"
+ )
+ process = maybe_launch_server(args, policy)
+ if process is None:
+ raise RuntimeError("model-server launch did not create a process")
+ return process
+
+
+def model_record(
+ args: argparse.Namespace, policy: SimplerEnvModelServerPolicy
+) -> dict[str, Any]:
+ chunk_size, action_dim = policy.action_shape()
+ return {
+ "model_type": args.expected_model_type,
+ "variant": args.variant,
+ "framework": args.expected_framework,
+ "checkpoint_revision": args.expected_checkpoint_revision,
+ "checkpoint_sha256": args.expected_checkpoint_sha256,
+ "qwen_revision": args.expected_qwen_revision,
+ "starvla_revision": args.expected_starvla_revision,
+ "chunk_size": chunk_size,
+ "action_dim": action_dim,
+ }
+
+
+def aggregate_task_repeats(episodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ groups: dict[tuple[int, int], list[dict[str, Any]]] = defaultdict(list)
+ for episode in episodes:
+ groups[(int(episode["task_id"]), int(episode["repeat"]))].append(episode)
+ return [
+ {
+ "suite": BRIDGE_SUITE,
+ "task_id": task_id,
+ "repeat": repeat,
+ **aggregate_episodes(rows)["overall"],
+ }
+ for (task_id, repeat), rows in sorted(groups.items())
+ ]
+
+
+def run_episode(
+ env: Any,
+ policy: SimplerEnvModelServerPolicy,
+ task_spec: BridgeTask,
+ episode_id: int,
+ *,
+ repeat: int,
+ max_episode_steps: int,
+ camera_name: str | None,
+ video_path: Path | None,
+ video_fps: int,
+) -> dict[str, Any]:
+ observation, _ = reset_env(env, task_spec, episode_id)
+ task = language_instruction(env)
+ if task != task_spec.instruction:
+ raise RuntimeError(
+ f"unexpected instruction for {task_spec.env_name}: {task!r}"
+ )
+ policy.reset(task, reset_server=True)
+ image = observation_image(env, observation, camera_name)
+ frames = [image] if video_path is not None else []
+ start_predict_calls = policy.predict_calls
+ start_timing_index = len(policy.timing_records)
+ started = time.perf_counter()
+ rewards: list[float] = []
+ success = False
+ terminated = False
+ truncated = False
+ steps = 0
+
+ while steps < max_episode_steps and not truncated:
+ _, action = policy.step(image, task)
+ env_action = np.concatenate(
+ [action["world_vector"], action["rot_axangle"], action["gripper"]]
+ )
+ observation, reward, terminated_value, truncated_value, _ = env.step(env_action)
+ terminated = _first_bool(terminated_value)
+ truncated = _first_bool(truncated_value)
+ success = terminated
+ rewards.append(_first_float(reward))
+ steps += 1
+ if terminated or truncated:
+ break
+ task = language_instruction(env)
+ image = observation_image(env, observation, camera_name)
+ if frames:
+ frames.append(image)
+
+ if video_path is not None:
+ _write_video(video_path, frames, video_fps)
+ records = policy.timing_records[start_timing_index:]
+ return {
+ "episode": int(episode_id),
+ "repeat": int(repeat),
+ "task": task_spec.instruction,
+ "task_name": task_spec.name,
+ "env_name": task_spec.env_name,
+ "success": bool(success),
+ "terminated": bool(terminated),
+ "truncated": bool(truncated),
+ "sum_reward": float(sum(rewards)),
+ "max_reward": float(max(rewards) if rewards else 0.0),
+ "steps": steps,
+ "elapsed_s": time.perf_counter() - started,
+ "predict_calls": policy.predict_calls - start_predict_calls,
+ "server_timing_avg_ms": average_timing(records),
+ "video": str(video_path) if video_path is not None else None,
+ }
+
+
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Evaluate model-server on SimplerEnv Bridge")
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=5555)
+ parser.add_argument("--launch-server", action="store_true")
+ parser.add_argument("--server-command", nargs=argparse.REMAINDER, help="must be last")
+ parser.add_argument("--server-env", action="append")
+ parser.add_argument("--server-wait-s", type=float, default=180.0)
+ parser.add_argument("--server-noise-seed-base", type=int, default=0)
+ parser.add_argument("--variant")
+ parser.add_argument("--expected-model-type")
+ parser.add_argument("--expected-checkpoint-revision")
+ parser.add_argument("--expected-checkpoint-sha256")
+ parser.add_argument("--expected-qwen-revision")
+ parser.add_argument("--expected-starvla-revision")
+ parser.add_argument("--expected-framework")
+ parser.add_argument("--task-ids", default="all")
+ parser.add_argument("--episode-ids", default="0:24")
+ parser.add_argument("--repeats", type=int, default=1)
+ parser.add_argument("--max-episode-steps", type=int, default=120)
+ parser.add_argument("--control-freq", type=int, default=5)
+ parser.add_argument("--sim-freq", type=int, default=500)
+ parser.add_argument("--image-name", default=DEFAULT_IMAGE_NAME)
+ parser.add_argument("--image-size", type=int, nargs=2, default=list(DEFAULT_IMAGE_SIZE))
+ parser.add_argument("--action-scale", type=float, default=1.0)
+ parser.add_argument("--no-action-ensemble", action="store_true")
+ parser.add_argument(
+ "--action-ensemble-horizon", type=int, default=DEFAULT_ACTION_ENSEMBLE_HORIZON
+ )
+ parser.add_argument(
+ "--adaptive-ensemble-alpha", type=float, default=DEFAULT_ADAPTIVE_ENSEMBLE_ALPHA
+ )
+ parser.add_argument("--camera-name")
+ parser.add_argument("--no-rgb-overlay", action="store_true")
+ parser.add_argument("--enable-raytracing", action="store_true")
+ parser.add_argument("--simpler-env-root", type=Path)
+ parser.add_argument("--record-video", action="store_true")
+ parser.add_argument("--video-dir", type=Path)
+ parser.add_argument("--video-fps", type=int, default=5)
+ parser.add_argument("--output", type=Path)
+ return parser.parse_args(argv)
+
+
+def _validate_args(args: argparse.Namespace) -> tuple[list[int], list[int]]:
+ positive = (
+ args.repeats,
+ args.max_episode_steps,
+ args.control_freq,
+ args.sim_freq,
+ args.video_fps,
+ args.action_ensemble_horizon,
+ )
+ if any(value <= 0 for value in positive):
+ raise ValueError("repeat, episode, timing, and ensemble values must be positive")
+ if args.server_noise_seed_base < 0:
+ raise ValueError("--server-noise-seed-base must be non-negative")
+ return parse_task_ids(args.task_ids), parse_episode_ids(args.episode_ids)
+
+
+def run(args: argparse.Namespace) -> dict[str, Any]:
+ task_ids, episode_ids = _validate_args(args)
+ output = args.output or DEFAULT_RESULTS_DIR / f"server-simpler-env-bridge-{timestamp()}.json"
+ video_dir = args.video_dir or output.with_suffix("").with_name(output.stem + "-videos")
+ apply_runtime_env()
+ root = simpler_env_root(args.simpler_env_root)
+ policy = SimplerEnvModelServerPolicy(
+ host=args.host,
+ port=args.port,
+ image_name=args.image_name,
+ image_size=tuple(args.image_size),
+ action_scale=args.action_scale,
+ action_ensemble=not args.no_action_ensemble,
+ action_ensemble_horizon=args.action_ensemble_horizon,
+ adaptive_ensemble_alpha=args.adaptive_ensemble_alpha,
+ )
+ episodes: list[dict[str, Any]] = []
+ launches: list[dict[str, Any]] = []
+ recorded_model: dict[str, Any] | None = None
+ process = None
+
+ try:
+ if not args.launch_server:
+ maybe_launch_server(args, policy)
+ base_command = server_command(args) if args.launch_server else []
+ for task_spec in selected_tasks(task_ids):
+ for repeat in range(1, args.repeats + 1):
+ derived_seed = args.server_noise_seed_base + task_spec.task_id * args.repeats + repeat - 1
+ noise_seed = None
+ if args.launch_server:
+ noise_seed = derived_seed
+ launch_args = copy(args)
+ launch_args.server_command = _command_with_noise_seed(base_command, noise_seed)
+ process = _launch_fresh_server(launch_args, policy)
+ launches.append(
+ {
+ "task_id": task_spec.task_id,
+ "repeat": repeat,
+ "noise_seed": noise_seed,
+ }
+ )
+ for episode_id in episode_ids:
+ video_path = (
+ video_dir
+ / f"task-{task_spec.task_id}-{task_spec.name}"
+ / f"repeat-{repeat:02d}-episode-{episode_id:02d}.mp4"
+ if args.record_video
+ else None
+ )
+ env = make_env(
+ task_spec,
+ root=root,
+ control_freq=args.control_freq,
+ sim_freq=args.sim_freq,
+ max_episode_steps=args.max_episode_steps,
+ use_rgb_overlay=not args.no_rgb_overlay,
+ enable_raytracing=args.enable_raytracing,
+ )
+ try:
+ result = run_episode(
+ env,
+ policy,
+ task_spec,
+ episode_id,
+ repeat=repeat,
+ max_episode_steps=args.max_episode_steps,
+ camera_name=args.camera_name,
+ video_path=video_path,
+ video_fps=args.video_fps,
+ )
+ finally:
+ close_env(env)
+ result.update(
+ suite=BRIDGE_SUITE,
+ task_id=task_spec.task_id,
+ noise_seed=noise_seed,
+ )
+ episodes.append(result)
+ print(
+ f"bridge[{task_spec.task_id}] repeat={repeat} episode={episode_id} "
+ f"success={result['success']} steps={result['steps']}"
+ )
+ current_model = model_record(args, policy)
+ if recorded_model is None:
+ recorded_model = current_model
+ elif current_model != recorded_model:
+ raise RuntimeError("model action contract changed between repeats")
+ if process is not None:
+ stop_server(process, policy)
+ process = None
+ finally:
+ stop_server(process, policy)
+
+ assert recorded_model is not None
+ full_coverage = (
+ task_ids == [task.task_id for task in BRIDGE_TASKS]
+ and episode_ids == list(range(BRIDGE_EPISODE_COUNT))
+ )
+ payload = {
+ "runner": "model-server",
+ "benchmark": {
+ "name": "SimplerEnv WidowX Bridge",
+ "suite": BRIDGE_SUITE,
+ "coverage": "full" if full_coverage else "partial",
+ },
+ "config": {
+ "task_ids": task_ids,
+ "episode_ids": episode_ids,
+ "repeats": args.repeats,
+ "max_episode_steps": args.max_episode_steps,
+ "control_freq": args.control_freq,
+ "sim_freq": args.sim_freq,
+ "host": args.host,
+ "port": args.port,
+ "server_command": base_command or None,
+ "server_env": parse_server_env(args.server_env),
+ "server_launches": launches,
+ "image_name": args.image_name,
+ "image_size": args.image_size,
+ "action_scale": args.action_scale,
+ "action_ensemble": not args.no_action_ensemble,
+ "action_ensemble_horizon": args.action_ensemble_horizon,
+ "adaptive_ensemble_alpha": args.adaptive_ensemble_alpha,
+ "rgb_overlay": not args.no_rgb_overlay,
+ "camera_name": args.camera_name,
+ "raytracing": args.enable_raytracing,
+ },
+ "model": recorded_model,
+ "episodes": episodes,
+ "per_task_repeat": aggregate_task_repeats(episodes),
+ "timing_ms": timing_summary(policy.timing_records),
+ **aggregate_episodes(episodes),
+ }
+ write_json(output, payload)
+ print(f"wrote {output}")
+ print(f"overall: {payload['overall']}")
+ return payload
+
+
+def main() -> int:
+ run(parse_args())
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/eval/simpler_env/scripts/run_model_server.sh b/eval/simpler_env/scripts/run_model_server.sh
new file mode 100755
index 0000000..1d3768a
--- /dev/null
+++ b/eval/simpler_env/scripts/run_model_server.sh
@@ -0,0 +1,81 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
+REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../../.." >/dev/null 2>&1 && pwd)"
+cd "${REPO_ROOT}"
+
+source "${REPO_ROOT}/tools/hf2gguf/starvla/starvla_variant_config.sh"
+VARIANT="${VARIANT:-groot}"
+load_starvla_variant "${VARIANT}"
+GGUF_DIR="${GGUF_DIR:-ckpts/starvla/gguf/${VARIANT}}"
+LLM_GGUF="${LLM_GGUF:-${GGUF_DIR}/qwen-${ARTIFACT_STEM}-bf16.gguf}"
+MMPROJ_GGUF="${MMPROJ_GGUF:-${GGUF_DIR}/mmproj-${ARTIFACT_STEM}-bf16.gguf}"
+if [[ "${VARIANT}" == "qwen25_fast" ]]; then
+ POLICY_GGUF="${POLICY_GGUF:-${GGUF_DIR}/policy-qwen25-fast.gguf}"
+else
+ POLICY_GGUF="${POLICY_GGUF:-${GGUF_DIR}/starvla-${ARTIFACT_STEM}-policy-fp32.gguf}"
+fi
+
+BACKEND="${BACKEND:-linux-cuda}"
+case "${BACKEND}" in
+ linux-cuda) BUILD_DIR="${BUILD_DIR:-${REPO_ROOT}/build_cuda}" ;;
+ linux-cpu) BUILD_DIR="${BUILD_DIR:-${REPO_ROOT}/build}" ;;
+ *) echo "unsupported BACKEND=${BACKEND}; expected linux-cuda or linux-cpu" >&2; exit 2 ;;
+esac
+SERVER_BIN="${SERVER_BIN:-${BUILD_DIR}/bin/model-server}"
+PYTHON_BIN="${PYTHON:-ckpts/simpler_env/.venv/bin/python}"
+HOST="${HOST:-127.0.0.1}"
+PORT="${PORT:-5555}"
+NOISE_SEED_BASE="${NOISE_SEED_BASE:-1000}"
+
+for path in "${LLM_GGUF}" "${MMPROJ_GGUF}" "${POLICY_GGUF}"; do
+ if [[ ! -f "${path}" ]]; then
+ echo "missing GGUF: ${path}" >&2
+ exit 2
+ fi
+done
+if [[ ! -x "${SERVER_BIN}" ]]; then
+ echo "model-server was not found or is not executable: ${SERVER_BIN}" >&2
+ exit 2
+fi
+if [[ ! -x "${PYTHON_BIN}" ]]; then
+ echo "SimplerEnv Python was not found: ${PYTHON_BIN}" >&2
+ echo "follow eval/simpler_env/README_ZH.md to create it" >&2
+ exit 2
+fi
+
+eval_cmd=(
+ "${PYTHON_BIN}" -m eval.simpler_env.runners.run_model_server
+ --launch-server
+ --host "${HOST}"
+ --port "${PORT}"
+ --variant "${VARIANT}"
+ --expected-model-type "${MODEL_TYPE}"
+ --expected-checkpoint-revision "${CHECKPOINT_REVISION}"
+ --expected-checkpoint-sha256 "${CHECKPOINT_SHA256}"
+ --expected-qwen-revision "${QWEN_REVISION}"
+ --expected-starvla-revision "${STARVLA_REVISION}"
+ --expected-framework "${FRAMEWORK}"
+ --server-noise-seed-base "${NOISE_SEED_BASE}"
+)
+[[ -n "${TASK_IDS:-}" ]] && eval_cmd+=(--task-ids "${TASK_IDS}")
+[[ -n "${EPISODE_IDS:-}" ]] && eval_cmd+=(--episode-ids "${EPISODE_IDS}")
+[[ -n "${REPEATS:-}" ]] && eval_cmd+=(--repeats "${REPEATS}")
+[[ -n "${OUTPUT:-}" ]] && eval_cmd+=(--output "${OUTPUT}")
+[[ -n "${SIMPLER_ENV_ROOT:-}" ]] && eval_cmd+=(--simpler-env-root "${SIMPLER_ENV_ROOT}")
+[[ "${RECORD_VIDEO:-0}" == "1" ]] && eval_cmd+=(--record-video)
+eval_cmd+=("$@")
+eval_cmd+=(
+ --server-command
+ "${SERVER_BIN}"
+ --model-type "${MODEL_TYPE}"
+ --policy "${POLICY_GGUF}"
+ --llm "${LLM_GGUF}"
+ --mmproj "${MMPROJ_GGUF}"
+ --host "${HOST}"
+ --port "${PORT}"
+ --noise-seed "${NOISE_SEED_BASE}"
+)
+
+exec "${eval_cmd[@]}"
diff --git a/eval/simpler_env/utils/__init__.py b/eval/simpler_env/utils/__init__.py
new file mode 100644
index 0000000..ea81195
--- /dev/null
+++ b/eval/simpler_env/utils/__init__.py
@@ -0,0 +1 @@
+"""SimplerEnv evaluation helpers."""
diff --git a/eval/simpler_env/utils/environment.py b/eval/simpler_env/utils/environment.py
new file mode 100644
index 0000000..4978636
--- /dev/null
+++ b/eval/simpler_env/utils/environment.py
@@ -0,0 +1,230 @@
+"""Official StarVLA WidowX Bridge task and environment configuration."""
+
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+
+BRIDGE_SUITE = "simpler_env_widowx_bridge"
+BRIDGE_EPISODE_COUNT = 24
+BRIDGE_OFFICIAL_REPEATS = 4
+BRIDGE_CONTROL_MODE = "arm_pd_ee_target_delta_pose_align2_gripper_pd_joint_pos"
+
+
+@dataclass(frozen=True)
+class BridgeTask:
+ task_id: int
+ name: str
+ env_name: str
+ instruction: str
+ scene_name: str
+ robot: str
+ overlay_filename: str
+ robot_init_x: float
+ robot_init_y: float
+
+
+BRIDGE_TASKS = (
+ BridgeTask(
+ 0,
+ "stack_green_cube_on_yellow_cube",
+ "StackGreenCubeOnYellowCubeBakedTexInScene-v0",
+ "stack the green block on the yellow block",
+ "bridge_table_1_v1",
+ "widowx",
+ "bridge_real_eval_1.png",
+ 0.147,
+ 0.028,
+ ),
+ BridgeTask(
+ 1,
+ "put_carrot_on_plate",
+ "PutCarrotOnPlateInScene-v0",
+ "put carrot on plate",
+ "bridge_table_1_v1",
+ "widowx",
+ "bridge_real_eval_1.png",
+ 0.147,
+ 0.028,
+ ),
+ BridgeTask(
+ 2,
+ "put_spoon_on_table_cloth",
+ "PutSpoonOnTableClothInScene-v0",
+ "put the spoon on the towel",
+ "bridge_table_1_v1",
+ "widowx",
+ "bridge_real_eval_1.png",
+ 0.147,
+ 0.028,
+ ),
+ BridgeTask(
+ 3,
+ "put_eggplant_in_basket",
+ "PutEggplantInBasketScene-v0",
+ "put eggplant into yellow basket",
+ "bridge_table_1_v2",
+ "widowx_sink_camera_setup",
+ "bridge_sink.png",
+ 0.127,
+ 0.060,
+ ),
+)
+
+
+def parse_task_ids(value: str | None) -> list[int]:
+ if value is None or value.strip().lower() in {"", "all"}:
+ return [task.task_id for task in BRIDGE_TASKS]
+ text = value.strip()
+ decoded = json.loads(text) if text.startswith("[") else text.split(",")
+ if not isinstance(decoded, list):
+ raise ValueError("--task-ids must be 'all', a comma list, or a JSON list")
+ task_ids = [int(item) for item in decoded]
+ known = {task.task_id for task in BRIDGE_TASKS}
+ if len(set(task_ids)) != len(task_ids) or any(task_id not in known for task_id in task_ids):
+ raise ValueError(f"--task-ids must contain unique values from {sorted(known)}")
+ return task_ids
+
+
+def selected_tasks(task_ids: list[int]) -> list[BridgeTask]:
+ by_id = {task.task_id: task for task in BRIDGE_TASKS}
+ return [by_id[task_id] for task_id in task_ids]
+
+
+def parse_episode_ids(value: str | None) -> list[int]:
+ text = (value or "0:24").strip()
+ if ":" in text and not text.startswith("["):
+ fields = text.split(":")
+ if len(fields) not in (2, 3):
+ raise ValueError("--episode-ids range must be START:STOP or START:STOP:STEP")
+ start, stop = int(fields[0]), int(fields[1])
+ step = int(fields[2]) if len(fields) == 3 else 1
+ if step <= 0:
+ raise ValueError("--episode-ids range step must be positive")
+ episode_ids = list(range(start, stop, step))
+ else:
+ decoded = json.loads(text) if text.startswith("[") else text.split(",")
+ if not isinstance(decoded, list):
+ raise ValueError("--episode-ids must be a comma list, JSON list, or range")
+ episode_ids = [int(item) for item in decoded if str(item).strip()]
+ if not episode_ids:
+ raise ValueError("--episode-ids must select at least one episode")
+ if len(set(episode_ids)) != len(episode_ids):
+ raise ValueError("--episode-ids must not contain duplicates")
+ if any(episode_id < 0 or episode_id >= BRIDGE_EPISODE_COUNT for episode_id in episode_ids):
+ raise ValueError(f"Bridge episode ids must be in [0, {BRIDGE_EPISODE_COUNT})")
+ return episode_ids
+
+
+def apply_runtime_env() -> None:
+ os.environ["DISPLAY"] = ""
+ os.environ.setdefault("XLA_PYTHON_CLIENT_PREALLOCATE", "false")
+
+
+def simpler_env_root(explicit_root: Path | None = None) -> Path:
+ try:
+ import mani_skill2_real2sim
+ import simpler_env
+ except ImportError as exc:
+ raise RuntimeError(
+ "simpler_env is not installed; follow eval/simpler_env/README_ZH.md"
+ ) from exc
+
+ if explicit_root is not None:
+ root = explicit_root.expanduser().resolve()
+ else:
+ root = Path(simpler_env.__file__).resolve().parent.parent
+ if not (root / "ManiSkill2_real2sim").exists():
+ raise RuntimeError(f"invalid SimplerEnv root (ManiSkill2_real2sim missing): {root}")
+ installed_simpler_root = Path(simpler_env.__file__).resolve().parent.parent
+ installed_maniskill_root = Path(mani_skill2_real2sim.__file__).resolve().parent.parent
+ expected_maniskill_root = (root / "ManiSkill2_real2sim").resolve()
+ if installed_simpler_root != root:
+ raise RuntimeError(
+ f"installed simpler_env comes from {installed_simpler_root}, expected {root}"
+ )
+ if installed_maniskill_root != expected_maniskill_root:
+ raise RuntimeError(
+ "installed mani_skill2_real2sim comes from "
+ f"{installed_maniskill_root}, expected {expected_maniskill_root}"
+ )
+ return root
+
+
+def overlay_path(root: Path, task: BridgeTask) -> Path:
+ path = root / "ManiSkill2_real2sim" / "data" / "real_inpainting" / task.overlay_filename
+ if not path.is_file():
+ raise RuntimeError(f"official Bridge RGB overlay is missing: {path}")
+ return path
+
+
+def make_env(
+ task: BridgeTask,
+ *,
+ root: Path,
+ control_freq: int = 5,
+ sim_freq: int = 500,
+ max_episode_steps: int = 120,
+ use_rgb_overlay: bool = True,
+ enable_raytracing: bool = False,
+) -> Any:
+ try:
+ from simpler_env.utils.env.env_builder import build_maniskill2_env
+ except ImportError as exc:
+ raise RuntimeError("failed to import the installed SimplerEnv environment builder") from exc
+
+ additional: dict[str, Any] = {"shader_dir": "rt"} if enable_raytracing else {}
+ return build_maniskill2_env(
+ task.env_name,
+ **additional,
+ obs_mode="rgbd",
+ robot=task.robot,
+ sim_freq=int(sim_freq),
+ control_mode=BRIDGE_CONTROL_MODE,
+ control_freq=int(control_freq),
+ max_episode_steps=int(max_episode_steps),
+ scene_name=task.scene_name,
+ camera_cfgs={"add_segmentation": True},
+ rgb_overlay_path=str(overlay_path(root, task)) if use_rgb_overlay else None,
+ )
+
+
+def reset_env(env: Any, task: BridgeTask, episode_id: int) -> tuple[Any, Any]:
+ options = {
+ "robot_init_options": {
+ "init_xy": np.asarray([task.robot_init_x, task.robot_init_y], dtype=np.float64),
+ "init_rot_quat": np.asarray([0.0, 0.0, 0.0, 1.0], dtype=np.float64),
+ },
+ "obj_init_options": {"episode_id": int(episode_id)},
+ }
+ return env.reset(options=options)
+
+
+def observation_image(env: Any, observation: dict[str, Any], camera_name: str | None = None) -> np.ndarray:
+ try:
+ from simpler_env.utils.env.observation_utils import get_image_from_maniskill2_obs_dict
+ except ImportError as exc:
+ raise RuntimeError("failed to import SimplerEnv observation helpers") from exc
+ image = np.asarray(get_image_from_maniskill2_obs_dict(env, observation, camera_name=camera_name))
+ if image.dtype != np.uint8 or image.ndim != 3 or image.shape[2] != 3:
+ raise RuntimeError(f"SimplerEnv returned an invalid RGB observation: shape={image.shape}, dtype={image.dtype}")
+ return image
+
+
+def language_instruction(env: Any) -> str:
+ instruction = str(env.get_language_instruction())
+ if not instruction:
+ raise RuntimeError("SimplerEnv returned an empty language instruction")
+ return instruction
+
+
+def close_env(env: Any) -> None:
+ close = getattr(env, "close", None)
+ if callable(close):
+ close()
diff --git a/patches/llama.cpp/0001-qwen3vl-vision-parity.patch b/patches/llama.cpp/0001-qwen3vl-vision-parity.patch
new file mode 100644
index 0000000..a868059
--- /dev/null
+++ b/patches/llama.cpp/0001-qwen3vl-vision-parity.patch
@@ -0,0 +1,36 @@
+diff --git a/tools/mtmd/models/qwen3vl.cpp b/tools/mtmd/models/qwen3vl.cpp
+index fa1100d..5119df6 100644
+--- a/tools/mtmd/models/qwen3vl.cpp
++++ b/tools/mtmd/models/qwen3vl.cpp
+@@ -43,8 +43,11 @@ ggml_cgraph * clip_graph_qwen3vl::build() {
+ cb(inp, "patch_bias", -1);
+ }
+
+- // calculate absolute position embedding and apply
+- ggml_tensor * learned_pos_embd = resize_position_embeddings();
++ // Qwen3-VL constructs interpolation coordinates with torch.linspace(0,
++ // num_grid_per_side - 1, size), which is bilinear align_corners=True
++ // without antialiasing.
++ ggml_tensor * learned_pos_embd = resize_position_embeddings(
++ GGML_SCALE_MODE_BILINEAR | GGML_SCALE_FLAG_ALIGN_CORNERS);
+ learned_pos_embd = ggml_cont_4d(
+ ctx0, learned_pos_embd,
+ n_embd * 2, n_patches_x / 2, n_patches_y, batch_size);
+@@ -154,7 +157,7 @@ ggml_cgraph * clip_graph_qwen3vl::build() {
+ layer.deepstack_fc1_w, layer.deepstack_fc1_b,
+ nullptr, nullptr,
+ layer.deepstack_fc2_w, layer.deepstack_fc2_b,
+- ffn_op_type::FFN_GELU, il);
++ ffn_op_type::FFN_GELU_ERF, il);
+
+ if(!deepstack_features) {
+ deepstack_features = feat;
+@@ -180,7 +183,7 @@ ggml_cgraph * clip_graph_qwen3vl::build() {
+ model.mm_0_w, model.mm_0_b,
+ nullptr, nullptr,
+ model.mm_1_w, model.mm_1_b,
+- ffn_op_type::FFN_GELU, -1);
++ ffn_op_type::FFN_GELU_ERF, -1);
+
+ if (deepstack_features) {
+ embeddings = ggml_concat(ctx0, embeddings, deepstack_features, 0);
diff --git a/patches/llama.cpp/0002-per-context-native-graph-control.patch b/patches/llama.cpp/0002-per-context-native-graph-control.patch
new file mode 100644
index 0000000..3b3a992
--- /dev/null
+++ b/patches/llama.cpp/0002-per-context-native-graph-control.patch
@@ -0,0 +1,267 @@
+diff --git a/ggml/include/ggml-backend.h b/ggml/include/ggml-backend.h
+index d0c7e5a..d86d0b3 100644
+--- a/ggml/include/ggml-backend.h
++++ b/ggml/include/ggml-backend.h
+@@ -215,6 +215,8 @@ extern "C" {
+ typedef ggml_backend_buffer_type_t * (*ggml_backend_dev_get_extra_bufts_t)(ggml_backend_dev_t device);
+ // Set the abort callback for the backend
+ typedef void (*ggml_backend_set_abort_callback_t)(ggml_backend_t backend, ggml_abort_callback abort_callback, void * abort_callback_data);
++ // Enable or disable native graph capture/cache for one backend instance.
++ typedef void (*ggml_backend_set_native_graphs_enabled_t)(ggml_backend_t backend, bool enabled);
+ // Get a list of feature flags supported by the backend (returns a NULL-terminated array)
+ struct ggml_backend_feature {
+ const char * name;
+diff --git a/ggml/src/ggml-cuda/common.cuh b/ggml/src/ggml-cuda/common.cuh
+index 1081750..fb7c279 100644
+--- a/ggml/src/ggml-cuda/common.cuh
++++ b/ggml/src/ggml-cuda/common.cuh
+@@ -1373,6 +1373,8 @@ struct ggml_backend_cuda_context {
+ int curr_stream_no = 0;
+
+ #ifdef USE_CUDA_GRAPH
++ bool cuda_graphs_enabled = true;
++
+ // Map from first_node_ptr to cuda_graph - allows multiple graphs per context
+ // when the computation is split across CPU/GPU (e.g., with --n-cpu-moe)
+ std::unordered_map> cuda_graphs;
+@@ -1405,6 +1407,9 @@ struct ggml_backend_cuda_context {
+ // Check if any CUDA graph is enabled for this context (used by kernels that need to know
+ // if graphs are in use without having access to the specific graph key)
+ bool any_cuda_graph_enabled() const {
++ if (!cuda_graphs_enabled) {
++ return false;
++ }
+ for (const auto & [key, graph] : cuda_graphs) {
+ if (graph && graph->is_enabled()) {
+ return true;
+@@ -1415,6 +1420,9 @@ struct ggml_backend_cuda_context {
+
+ // Check if any CUDA graph has an instance for this context
+ bool any_cuda_graph_has_instance() const {
++ if (!cuda_graphs_enabled) {
++ return false;
++ }
+ for (const auto & [key, graph] : cuda_graphs) {
+ if (graph && graph->instance != nullptr) {
+ return true;
+diff --git a/ggml/src/ggml-cuda/ggml-cuda.cu b/ggml/src/ggml-cuda/ggml-cuda.cu
+index 8d21b22..42f7838 100644
+--- a/ggml/src/ggml-cuda/ggml-cuda.cu
++++ b/ggml/src/ggml-cuda/ggml-cuda.cu
+@@ -3085,6 +3085,23 @@ static void ggml_backend_cuda_synchronize(ggml_backend_t backend) {
+ GGML_UNUSED(backend);
+ }
+
++static void ggml_backend_cuda_set_native_graphs_enabled(ggml_backend_t backend, bool enabled) {
++ ggml_backend_cuda_context * cuda_ctx = (ggml_backend_cuda_context *) backend->context;
++
++#ifdef USE_CUDA_GRAPH
++ if (cuda_ctx->cuda_graphs_enabled == enabled) {
++ return;
++ }
++
++ ggml_backend_cuda_synchronize(backend);
++ cuda_ctx->cuda_graphs.clear();
++ cuda_ctx->cuda_graphs_enabled = enabled;
++#else
++ GGML_UNUSED(cuda_ctx);
++ GGML_UNUSED(enabled);
++#endif
++}
++
+ #ifdef USE_CUDA_GRAPH
+ static bool ggml_cuda_graph_check_compability(ggml_cgraph * cgraph) {
+
+@@ -4202,8 +4219,8 @@ static void ggml_cuda_graph_evaluate_and_capture(ggml_backend_cuda_context * cud
+ }
+
+ #ifdef USE_CUDA_GRAPH
+- ggml_cuda_graph * graph = cuda_ctx->cuda_graph(graph_key);
+ if (use_cuda_graph && cuda_graph_update_required) { // End CUDA graph capture
++ ggml_cuda_graph * graph = cuda_ctx->cuda_graph(graph_key);
+ if (graph->graph != nullptr) {
+ CUDA_CHECK(cudaGraphDestroy(graph->graph));
+ graph->graph = nullptr;
+@@ -4240,6 +4257,10 @@ static void ggml_cuda_graph_evaluate_and_capture(ggml_backend_cuda_context * cud
+
+ #ifdef USE_CUDA_GRAPH
+ static bool ggml_cuda_graph_set_enabled(ggml_backend_cuda_context * cuda_ctx, const void * graph_key) {
++ if (!cuda_ctx->cuda_graphs_enabled) {
++ return false;
++ }
++
+ ggml_cuda_graph * graph = cuda_ctx->cuda_graph(graph_key);
+
+ if (graph->graph == nullptr) {
+@@ -4267,10 +4288,8 @@ static enum ggml_status ggml_backend_cuda_graph_compute(ggml_backend_t backend,
+ #ifdef USE_CUDA_GRAPH
+ graph_key = ggml_cuda_graph_get_key(cgraph);
+
+- ggml_cuda_graph_set_enabled(cuda_ctx, graph_key);
+-
+- ggml_cuda_graph * graph = cuda_ctx->cuda_graph(graph_key);
+- if (graph->is_enabled()) {
++ if (ggml_cuda_graph_set_enabled(cuda_ctx, graph_key)) {
++ ggml_cuda_graph * graph = cuda_ctx->cuda_graph(graph_key);
+ const bool graph_compatible = ggml_cuda_graph_check_compability(cgraph);
+ if (graph_compatible) {
+ const bool properties_changed = ggml_cuda_graph_update_required(cuda_ctx, cgraph);
+@@ -5400,6 +5419,9 @@ static void * ggml_backend_cuda_reg_get_proc_address(ggml_backend_reg_t reg, con
+ if (strcmp(name, "ggml_backend_get_features") == 0) {
+ return (void *)ggml_backend_cuda_get_features;
+ }
++ if (strcmp(name, "ggml_backend_set_native_graphs_enabled") == 0) {
++ return (void *)ggml_backend_cuda_set_native_graphs_enabled;
++ }
+ return nullptr;
+ }
+
+diff --git a/include/llama.h b/include/llama.h
+index 2ea2267..aa6c656 100644
+--- a/include/llama.h
++++ b/include/llama.h
+@@ -969,6 +969,10 @@ extern "C" {
+ // Set abort callback
+ LLAMA_API void llama_set_abort_callback(struct llama_context * ctx, ggml_abort_callback abort_callback, void * abort_callback_data);
+
++ // Enable or disable native graph capture/cache for each context backend
++ // that exposes this optional capability. Direct graph computation remains enabled.
++ LLAMA_API void llama_set_backend_native_graphs_enabled(struct llama_context * ctx, bool enabled);
++
+ // Wait until all computations are finished
+ // This is automatically done when using one of the functions below to obtain the computation results
+ // and is not necessary to call it explicitly in most cases
+diff --git a/src/llama-context.cpp b/src/llama-context.cpp
+index 71a5939..a705255 100644
+--- a/src/llama-context.cpp
++++ b/src/llama-context.cpp
+@@ -1031,6 +1031,21 @@ void llama_context::set_abort_callback(bool (*abort_callback)(void * data), void
+ }
+ }
+
++void llama_context::set_backend_native_graphs_enabled(bool enabled) {
++ for (auto & backend : backends) {
++ auto * reg = ggml_backend_dev_backend_reg(ggml_backend_get_device(backend.get()));
++ if (reg == nullptr) {
++ continue;
++ }
++ auto * set_enabled = reinterpret_cast(
++ ggml_backend_reg_get_proc_address(
++ reg, "ggml_backend_set_native_graphs_enabled"));
++ if (set_enabled != nullptr) {
++ set_enabled(backend.get(), enabled);
++ }
++ }
++}
++
+ void llama_context::set_embeddings(bool value) {
+ LLAMA_LOG_DEBUG("%s: value = %d\n", __func__, value);
+
+@@ -3365,6 +3380,10 @@ void llama_set_abort_callback(llama_context * ctx, bool (*abort_callback)(void *
+ ctx->set_abort_callback(abort_callback, abort_callback_data);
+ }
+
++void llama_set_backend_native_graphs_enabled(llama_context * ctx, bool enabled) {
++ ctx->set_backend_native_graphs_enabled(enabled);
++}
++
+ void llama_set_embeddings(llama_context * ctx, bool embeddings) {
+ ctx->set_embeddings(embeddings);
+ }
+diff --git a/src/llama-context.h b/src/llama-context.h
+index 92d1b0c..d1354a4 100644
+--- a/src/llama-context.h
++++ b/src/llama-context.h
+@@ -105,6 +105,7 @@ struct llama_context {
+ void set_n_threads(int32_t n_threads, int32_t n_threads_batch);
+
+ void set_abort_callback(bool (*abort_callback)(void * data), void * abort_callback_data);
++ void set_backend_native_graphs_enabled(bool enabled);
+
+ void set_embeddings (bool value);
+ void set_causal_attn(bool value);
+diff --git a/tools/mtmd/clip.cpp b/tools/mtmd/clip.cpp
+index 513b94f..4e63c93 100644
+--- a/tools/mtmd/clip.cpp
++++ b/tools/mtmd/clip.cpp
+@@ -2973,6 +2973,26 @@ void clip_free(clip_ctx * ctx) {
+ delete ctx;
+ }
+
++void clip_set_backend_native_graphs_enabled(clip_ctx * ctx, bool enabled) {
++ if (ctx == nullptr) {
++ return;
++ }
++
++ for (ggml_backend_t backend : ctx->backend_ptrs) {
++ ggml_backend_dev_t dev = ggml_backend_get_device(backend);
++ ggml_backend_reg_t reg = dev ? ggml_backend_dev_backend_reg(dev) : nullptr;
++ if (reg == nullptr) {
++ continue;
++ }
++ auto * set_enabled = reinterpret_cast(
++ ggml_backend_reg_get_proc_address(
++ reg, "ggml_backend_set_native_graphs_enabled"));
++ if (set_enabled != nullptr) {
++ set_enabled(backend, enabled);
++ }
++ }
++}
++
+ // deprecated
+ size_t clip_embd_nbytes(const struct clip_ctx * ctx) {
+ const int32_t nx = ctx->model.hparams.image_size;
+diff --git a/tools/mtmd/clip.h b/tools/mtmd/clip.h
+index a859b38..d63d43d 100644
+--- a/tools/mtmd/clip.h
++++ b/tools/mtmd/clip.h
+@@ -51,6 +51,10 @@ struct clip_init_result clip_init(const char * fname, struct clip_context_params
+
+ void clip_free(struct clip_ctx * ctx);
+
++// Enable or disable native graph capture/cache for each CLIP backend that
++// exposes this optional capability. Direct graph computation remains enabled.
++void clip_set_backend_native_graphs_enabled(struct clip_ctx * ctx, bool enabled);
++
+ size_t clip_embd_nbytes(const struct clip_ctx * ctx);
+ size_t clip_embd_nbytes_by_img(const struct clip_ctx * ctx, int img_w, int img_h);
+
+diff --git a/tools/mtmd/mtmd.cpp b/tools/mtmd/mtmd.cpp
+index 87da687..5fa1bc3 100644
+--- a/tools/mtmd/mtmd.cpp
++++ b/tools/mtmd/mtmd.cpp
+@@ -628,6 +628,19 @@ void mtmd_free(mtmd_context * ctx) {
+ delete ctx;
+ }
+
++void mtmd_set_backend_native_graphs_enabled(mtmd_context * ctx, bool enabled) {
++ if (ctx == nullptr) {
++ return;
++ }
++
++ if (ctx->ctx_v != nullptr) {
++ clip_set_backend_native_graphs_enabled(ctx->ctx_v, enabled);
++ }
++ if (ctx->ctx_a != nullptr) {
++ clip_set_backend_native_graphs_enabled(ctx->ctx_a, enabled);
++ }
++}
++
+ struct mtmd_tokenizer {
+ mtmd_context * ctx;
+ std::vector bitmaps;
+diff --git a/tools/mtmd/mtmd.h b/tools/mtmd/mtmd.h
+index e364174..7cf3af7 100644
+--- a/tools/mtmd/mtmd.h
++++ b/tools/mtmd/mtmd.h
+@@ -110,6 +110,10 @@ MTMD_API mtmd_context * mtmd_init_from_file(const char * mmproj_fname,
+
+ MTMD_API void mtmd_free(mtmd_context * ctx);
+
++// Enable or disable native graph capture/cache for each media backend that
++// exposes this optional capability. Direct graph computation remains enabled.
++MTMD_API void mtmd_set_backend_native_graphs_enabled(mtmd_context * ctx, bool enabled);
++
+ // whether we need to set non-causal mask before llama_decode
+ // if chunk is nullptr, we assume the default case where chunk is an image chunk
+ MTMD_API bool mtmd_decode_use_non_causal(const mtmd_context * ctx, const mtmd_input_chunk * chunk);
diff --git a/patches/llama.cpp/README.md b/patches/llama.cpp/README.md
new file mode 100644
index 0000000..1a17011
--- /dev/null
+++ b/patches/llama.cpp/README.md
@@ -0,0 +1,31 @@
+# llama.cpp patches
+
+The project pins `third_party/llama.cpp` at commit
+`3e941b813b1acbbf06c2203a94ceb33d84748c1e`. The repository applies two
+changes that are not available through that revision's public APIs:
+
+1. `0001-qwen3vl-vision-parity.patch` uses the position interpolation and exact
+ GELU operations from the Qwen3-VL implementation used by StarVLA.
+2. `0002-per-context-native-graph-control.patch` adds an optional backend API to
+ disable CUDA graph capture for the text and vision contexts owned by one
+ StarVLA instance. This avoids retained CUDA graphs growing memory use during
+ long runs without changing the setting for other llama.cpp users.
+
+Apply the repository patch set after initializing submodules and before building:
+
+```bash
+./tools/apply_patches.sh
+```
+
+The command verifies the exact llama.cpp revision and refuses a dirty or
+partially patched checkout. It is safe to run again after a complete apply.
+
+Inspect or remove the overlay with:
+
+```bash
+./tools/apply_patches.sh --check
+./tools/apply_patches.sh --revert
+```
+
+The parent repository commits only these patch assets. It does not advance or
+commit a forked llama.cpp gitlink.
diff --git a/robot_client/cpp/model_client.cpp b/robot_client/cpp/model_client.cpp
index 5496144..3ad82d8 100644
--- a/robot_client/cpp/model_client.cpp
+++ b/robot_client/cpp/model_client.cpp
@@ -55,8 +55,9 @@ bool make_predict_request(const ModelObservation & obs, proto::predict_request &
return false;
}
- req.task = obs.prompt;
- req.state = obs.state;
+ req.task = obs.prompt;
+ req.state = obs.state;
+ req.initial_noise = obs.initial_noise;
req.images.clear();
req.images.reserve(obs.images.size());
diff --git a/robot_client/cpp/model_client.h b/robot_client/cpp/model_client.h
index a6b7730..ea84f47 100644
--- a/robot_client/cpp/model_client.h
+++ b/robot_client/cpp/model_client.h
@@ -21,6 +21,7 @@ struct ModelImage {
struct ModelObservation {
std::vector images;
std::vector state;
+ std::vector initial_noise;
std::string prompt = "grab the block.";
};
diff --git a/robot_client/python/model_client.py b/robot_client/python/model_client.py
index 06e3791..5c79423 100644
--- a/robot_client/python/model_client.py
+++ b/robot_client/python/model_client.py
@@ -9,7 +9,7 @@
MAGIC = 0x414C5653
-VERSION = 3
+VERSION = 4
HEADER_SIZE = 32
OP_HEALTH = 1
@@ -21,8 +21,8 @@
IMAGE_RAW_RGB_U8 = 1
HEADER = struct.Struct(" tuple[int, int, int, bytes]:
def encode_predict_observation(observation: dict[str, Any]) -> bytes:
images = observation["images"]
state = state_to_list(observation["state"])
+ initial_noise = state_to_list(observation.get("initial_noise"))
prompt = str(observation["prompt"])
if not images:
raise ValueError("observation.images must contain at least one image")
@@ -88,13 +89,14 @@ def encode_predict_observation(observation: dict[str, Any]) -> bytes:
encoded_images.append((name, rgb, width, height, stride))
payload = bytearray()
- payload += PREDICT_REQ_V2_FIXED.pack(
+ payload += PREDICT_REQ_FIXED.pack(
len(encoded_images),
len(state),
+ len(initial_noise),
len(prompt_bytes),
)
for name, rgb, width, height, stride in encoded_images:
- payload += PREDICT_REQ_V2_IMAGE.pack(
+ payload += PREDICT_REQ_IMAGE.pack(
IMAGE_RAW_RGB_U8,
len(name),
width,
@@ -105,6 +107,8 @@ def encode_predict_observation(observation: dict[str, Any]) -> bytes:
)
for value in state:
payload += struct.pack("
@@ -26,6 +27,7 @@ struct server_args {
std::string action_decoder_path;
std::string state_proj_path;
std::string action_expert_path;
+ std::string policy_path;
std::string task = "grab the block.";
std::string host = "127.0.0.1";
int port = 5555;
@@ -37,18 +39,6 @@ struct server_args {
int verbosity = 0;
};
-static bool parse_model_type(const std::string & value, robotcpp::model_type & out) {
- if (value == "smolvla") {
- out = robotcpp::model_type::smolvla;
- return true;
- }
- if (value == "pi0") {
- out = robotcpp::model_type::pi0;
- return true;
- }
- return false;
-}
-
static bool parse_noise_mode(const std::string & value, int & out_mode) {
if (value == "gaussian") {
out_mode = SMOLVLA_NOISE_MODE_GAUSSIAN;
@@ -74,9 +64,11 @@ static void print_usage(const char * prog) {
" [options]\n"
" %s --model-type pi0 --vit --mmproj --llm --tokenizer --state-gguf "
" --action-decoder [options]\n"
+ " %s --model-type starvla --llm --mmproj --policy [options]\n"
"\n"
"Common options:\n"
- " --model-type Model type (default: smolvla)\n"
+ " --model-type smolvla|pi0|starvla\n"
+ " (default: smolvla)\n"
"\n"
"SmolVLA options:\n"
" --llm LLM GGUF path\n"
@@ -93,6 +85,11 @@ static void print_usage(const char * prog) {
" --state-gguf State projector GGUF path\n"
" --action-decoder Action decoder GGUF path\n"
"\n"
+ "StarVLA options:\n"
+ " --policy StarVLA policy GGUF path (required)\n"
+ " --llm Qwen text GGUF path (required)\n"
+ " --mmproj Qwen vision GGUF path (required)\n"
+ "\n"
"Runtime options:\n"
" --host Listen host (default: 127.0.0.1)\n"
" --port Listen port (default: 5555)\n"
@@ -103,7 +100,7 @@ static void print_usage(const char * prog) {
" --noise-seed RNG seed, <0 means auto (default: -1)\n"
" --verbosity Log verbosity (default: 0)\n"
" -h, --help Show this help\n",
- prog, prog);
+ prog, prog, prog);
}
// TODO: may need to be cleaned up and optimized
@@ -116,10 +113,12 @@ static bool parse_args(int argc, char ** argv, server_args & args) {
} else if (arg == "--llm" && i + 1 < argc) {
args.llm_path = argv[++i];
} else if (arg == "--model-type" && i + 1 < argc) {
- if (!parse_model_type(argv[++i], args.model_type)) {
+ if (!robotcpp::parse_model_type(argv[++i], args.model_type)) {
std::fprintf(stderr, "Error: unsupported model type '%s'\n", argv[i]);
return false;
}
+ } else if (arg == "--policy" && i + 1 < argc) {
+ args.policy_path = argv[++i];
} else if (arg == "--mmproj" && i + 1 < argc) {
args.mmproj_path = argv[++i];
} else if (arg == "--vit" && i + 1 < argc) {
@@ -139,22 +138,46 @@ static bool parse_args(int argc, char ** argv, server_args & args) {
} else if (arg == "--host" && i + 1 < argc) {
args.host = argv[++i];
} else if (arg == "--port" && i + 1 < argc) {
- args.port = std::atoi(argv[++i]);
+ const char * value = argv[++i];
+ if (!robotcpp::parse_integer_argument(value, args.port)) {
+ std::fprintf(stderr, "Error: invalid --port value '%s'\n", value);
+ return false;
+ }
} else if (arg == "--threads" && i + 1 < argc) {
- args.threads = std::atoi(argv[++i]);
+ const char * value = argv[++i];
+ if (!robotcpp::parse_integer_argument(value, args.threads)) {
+ std::fprintf(stderr, "Error: invalid --threads value '%s'\n", value);
+ return false;
+ }
} else if (arg == "--n-batch" && i + 1 < argc) {
- args.n_batch = std::atoi(argv[++i]);
+ const char * value = argv[++i];
+ if (!robotcpp::parse_integer_argument(value, args.n_batch)) {
+ std::fprintf(stderr, "Error: invalid --n-batch value '%s'\n", value);
+ return false;
+ }
} else if (arg == "--n-ctx" && i + 1 < argc) {
- args.n_ctx = std::atoi(argv[++i]);
+ const char * value = argv[++i];
+ if (!robotcpp::parse_integer_argument(value, args.n_ctx)) {
+ std::fprintf(stderr, "Error: invalid --n-ctx value '%s'\n", value);
+ return false;
+ }
} else if (arg == "--noise-mode" && i + 1 < argc) {
if (!parse_noise_mode(argv[++i], args.noise_mode)) {
std::fprintf(stderr, "Error: invalid noise mode '%s'\n", argv[i]);
return false;
}
} else if (arg == "--noise-seed" && i + 1 < argc) {
- args.noise_seed = (int64_t)std::atoll(argv[++i]);
+ const char * value = argv[++i];
+ if (!robotcpp::parse_integer_argument(value, args.noise_seed)) {
+ std::fprintf(stderr, "Error: invalid --noise-seed value '%s'\n", value);
+ return false;
+ }
} else if (arg == "--verbosity" && i + 1 < argc) {
- args.verbosity = std::atoi(argv[++i]);
+ const char * value = argv[++i];
+ if (!robotcpp::parse_integer_argument(value, args.verbosity)) {
+ std::fprintf(stderr, "Error: invalid --verbosity value '%s'\n", value);
+ return false;
+ }
} else {
std::fprintf(stderr, "Error: unknown argument '%s'\n", arg.c_str());
return false;
@@ -168,15 +191,36 @@ static bool parse_args(int argc, char ** argv, server_args & args) {
std::fprintf(stderr, "Error: model-server only listens on 127.0.0.1 in this phase\n");
return false;
}
+ if (args.threads < 0 || args.n_batch <= 0 || args.n_ctx <= 0 || args.verbosity < 0) {
+ std::fprintf(stderr,
+ "Error: --threads/--verbosity must be non-negative and --n-batch/--n-ctx must be positive\n");
+ return false;
+ }
+ if (robotcpp::is_starvla_model_type(args.model_type) && args.noise_mode != SMOLVLA_NOISE_MODE_GAUSSIAN) {
+ std::fprintf(stderr, "Error: StarVLA does not support --noise-mode debug-sin; use Gaussian noise\n");
+ return false;
+ }
if (args.model_type == robotcpp::model_type::smolvla) {
if (args.llm_path.empty() || args.mmproj_path.empty() || args.state_proj_path.empty() ||
args.action_expert_path.empty()) {
std::fprintf(stderr, "Error: smolvla requires --llm --mmproj --state-proj --action-expert\n");
return false;
}
- } else if (args.vit_path.empty() || args.mmproj_path.empty() || args.llm_path.empty() ||
- args.tokenizer_path.empty() || args.state_path.empty() || args.action_decoder_path.empty()) {
- std::fprintf(stderr, "Error: pi0 requires --vit --mmproj --llm --tokenizer --state-gguf --action-decoder\n");
+ } else if (args.model_type == robotcpp::model_type::pi0) {
+ if (args.vit_path.empty() || args.mmproj_path.empty() || args.llm_path.empty() || args.tokenizer_path.empty() ||
+ args.state_path.empty() || args.action_decoder_path.empty()) {
+ std::fprintf(stderr,
+ "Error: pi0 requires --vit --mmproj --llm --tokenizer --state-gguf --action-decoder\n");
+ return false;
+ }
+ } else if (robotcpp::is_starvla_model_type(args.model_type)) {
+ if (args.llm_path.empty() || args.mmproj_path.empty() || args.policy_path.empty()) {
+ std::fprintf(stderr, "Error: %s requires --llm --mmproj --policy\n",
+ robotcpp::model_type_name(args.model_type));
+ return false;
+ }
+ } else {
+ std::fprintf(stderr, "Error: unsupported model type '%s'\n", robotcpp::model_type_name(args.model_type));
return false;
}
return true;
@@ -195,6 +239,7 @@ static robotcpp::model_args make_model_args(const server_args & args) {
model_args.action_decoder_path = args.action_decoder_path;
model_args.state_proj_path = args.state_proj_path;
model_args.action_expert_path = args.action_expert_path;
+ model_args.policy_path = args.policy_path;
model_args.n_batch = args.n_batch;
model_args.n_ctx = args.n_ctx;
model_args.noise_mode = args.noise_mode;
diff --git a/robot_server/model_adapter.cpp b/robot_server/model_adapter.cpp
index 31556ad..4b1a7b8 100644
--- a/robot_server/model_adapter.cpp
+++ b/robot_server/model_adapter.cpp
@@ -30,8 +30,9 @@ bool model_adapter::predict(const proto::predict_request & req, proto::predict_r
image.stride_bytes = static_cast(src.stride_bytes);
obs.images.push_back(image);
}
- obs.state = req.state;
- obs.task = req.task;
+ obs.state = req.state;
+ obs.initial_noise = req.initial_noise;
+ obs.task = req.task;
robotcpp::model_result result;
if (!model_->predict(obs, result, error)) {
diff --git a/robot_server/protocol.cpp b/robot_server/protocol.cpp
index 8ea4c48..930a7db 100644
--- a/robot_server/protocol.cpp
+++ b/robot_server/protocol.cpp
@@ -1,5 +1,6 @@
#include "protocol.h"
+#include
#include
#include
@@ -122,6 +123,16 @@ static bool checked_u32_count(size_t n, const char * label, std::string & error)
return true;
}
+static bool validate_f32_array(const std::vector & values, const char * label, std::string & error) {
+ for (float value : values) {
+ if (!std::isfinite(value)) {
+ error = std::string(label) + " contains a non-finite value";
+ return false;
+ }
+ }
+ return true;
+}
+
static bool validate_image_payload(const image_payload & image, const char * label, std::string & error) {
if (image.image_format != image_raw_rgb_u8) {
error = std::string(label) + " unsupported image format";
@@ -216,7 +227,13 @@ bool encode_predict_request(const predict_request & req, std::vector &
return false;
}
if (!checked_u32_count(req.images.size(), "images", error) ||
- !checked_u32_count(req.state.size(), "state", error) || !checked_u32_count(req.task.size(), "task", error)) {
+ !checked_u32_count(req.state.size(), "state", error) ||
+ !checked_u32_count(req.initial_noise.size(), "initial noise", error) ||
+ !checked_u32_count(req.task.size(), "task", error)) {
+ return false;
+ }
+ if (!validate_f32_array(req.state, "state", error) ||
+ !validate_f32_array(req.initial_noise, "initial noise", error)) {
return false;
}
for (size_t i = 0; i < req.images.size(); ++i) {
@@ -228,6 +245,7 @@ bool encode_predict_request(const predict_request & req, std::vector &
put_u32(out, (uint32_t)req.images.size());
put_u32(out, (uint32_t)req.state.size());
+ put_u32(out, (uint32_t)req.initial_noise.size());
put_u32(out, (uint32_t)req.task.size());
for (const image_payload & image : req.images) {
put_u32(out, image.image_format);
@@ -242,6 +260,9 @@ bool encode_predict_request(const predict_request & req, std::vector &
for (float v : req.state) {
put_f32(out, v);
}
+ for (float v : req.initial_noise) {
+ put_f32(out, v);
+ }
out.insert(out.end(), req.task.begin(), req.task.end());
for (const image_payload & image : req.images) {
out.insert(out.end(), image.name.begin(), image.name.end());
@@ -255,9 +276,10 @@ bool decode_predict_request(const std::vector & payload, predict_reques
reader r(payload.data(), payload.size());
uint32_t image_count = 0;
uint32_t state_dim = 0;
+ uint32_t noise_dim = 0;
uint32_t task_len = 0;
- if (!r.u32(image_count) || !r.u32(state_dim) || !r.u32(task_len)) {
+ if (!r.u32(image_count) || !r.u32(state_dim) || !r.u32(noise_dim) || !r.u32(task_len)) {
error = "short predict request";
return false;
}
@@ -265,6 +287,11 @@ bool decode_predict_request(const std::vector & payload, predict_reques
error = "predict request requires at least one image";
return false;
}
+ constexpr size_t image_metadata_size = 6 * sizeof(uint32_t) + sizeof(uint64_t);
+ if (image_count > r.remaining() / image_metadata_size) {
+ error = "image count exceeds predict request metadata";
+ return false;
+ }
req.images.assign(image_count, image_payload{});
std::vector name_lens(image_count, 0);
@@ -286,6 +313,12 @@ bool decode_predict_request(const std::vector & payload, predict_reques
}
}
+ const uint64_t scalar_bytes = (static_cast(state_dim) + static_cast(noise_dim)) * sizeof(float);
+ if (scalar_bytes > r.remaining() || task_len > r.remaining() - scalar_bytes) {
+ error = "predict request fields exceed payload";
+ return false;
+ }
+
req.state.assign(state_dim, 0.0f);
for (uint32_t i = 0; i < state_dim; ++i) {
if (!r.f32(req.state[i])) {
@@ -293,6 +326,19 @@ bool decode_predict_request(const std::vector & payload, predict_reques
return false;
}
}
+ if (!validate_f32_array(req.state, "state", error)) {
+ return false;
+ }
+ req.initial_noise.assign(noise_dim, 0.0f);
+ for (uint32_t i = 0; i < noise_dim; ++i) {
+ if (!r.f32(req.initial_noise[i])) {
+ error = "short initial noise array";
+ return false;
+ }
+ }
+ if (!validate_f32_array(req.initial_noise, "initial noise", error)) {
+ return false;
+ }
if (!r.string(req.task, task_len)) {
error = "short task string";
return false;
diff --git a/robot_server/protocol.h b/robot_server/protocol.h
index 97ae387..a5f483e 100644
--- a/robot_server/protocol.h
+++ b/robot_server/protocol.h
@@ -9,7 +9,7 @@ namespace robot_server {
namespace protocol {
static constexpr uint32_t k_magic = 0x414c5653u; // "SVLA" in little-endian bytes.
-static constexpr uint16_t k_version = 3;
+static constexpr uint16_t k_version = 4;
static constexpr uint16_t k_header_size = 32;
static constexpr uint64_t k_default_max_payload = 256ull * 1024ull * 1024ull;
@@ -63,6 +63,7 @@ struct metric {
struct predict_request {
std::vector images;
std::vector state;
+ std::vector initial_noise;
std::string task;
};
diff --git a/robot_server/shell/launch_robot_server_linux_cuda.sh b/robot_server/shell/launch_robot_server_linux_cuda.sh
index 990ed44..c6002b9 100755
--- a/robot_server/shell/launch_robot_server_linux_cuda.sh
+++ b/robot_server/shell/launch_robot_server_linux_cuda.sh
@@ -21,6 +21,10 @@ SKIP_BUILD="${SKIP_BUILD:-0}"
CMAKE_BIN="${CMAKE_BIN:-cmake}"
GGML_NATIVE="${GGML_NATIVE:-OFF}"
GGML_OPENMP="${GGML_OPENMP:-OFF}"
+ROBOT_CPP_BUILD_STARVLA="${ROBOT_CPP_BUILD_STARVLA:-OFF}"
+if [ "${MODEL_TYPE}" = "starvla" ]; then
+ ROBOT_CPP_BUILD_STARVLA=ON
+fi
SERVER_BIN="${BUILD_DIR}/bin/model-server"
@@ -32,7 +36,8 @@ if [ "${SKIP_BUILD}" != "1" ]; then
-DGGML_OPENMP="${GGML_OPENMP}" \
-DGGML_CUDA=ON \
-DGGML_METAL=OFF \
- -DROBOT_CPP_BUILD_ROBOT_SERVER=ON
+ -DROBOT_CPP_BUILD_ROBOT_SERVER=ON \
+ -DROBOT_CPP_BUILD_STARVLA="${ROBOT_CPP_BUILD_STARVLA}"
echo "== build =="
"${CMAKE_BIN}" --build "${BUILD_DIR}" --target model-server -j8
@@ -70,6 +75,17 @@ case "${MODEL_TYPE}" in
--action-decoder "${ACTION_DECODER_GGUF}"
)
;;
+ starvla)
+ LLM_GGUF="${LLM_GGUF:?LLM_GGUF must be set for StarVLA}"
+ MMPROJ_GGUF="${MMPROJ_GGUF:?MMPROJ_GGUF must be set for StarVLA}"
+ POLICY_GGUF="${POLICY_GGUF:?POLICY_GGUF must be set for StarVLA}"
+ MODEL_ARGS=(
+ --model-type starvla
+ --llm "${LLM_GGUF}"
+ --mmproj "${MMPROJ_GGUF}"
+ --policy "${POLICY_GGUF}"
+ )
+ ;;
*)
echo "unsupported MODEL_TYPE=${MODEL_TYPE}" >&2
exit 1
diff --git a/robot_server/test/benchmark_latency.py b/robot_server/test/benchmark_latency.py
index e000113..fa2ef72 100644
--- a/robot_server/test/benchmark_latency.py
+++ b/robot_server/test/benchmark_latency.py
@@ -26,8 +26,15 @@ def make_random_state(dim: int, seed: int) -> np.ndarray:
return rng.uniform(-1.0, 1.0, size=(dim,)).astype(np.float32)
-def make_random_observation(width: int, height: int, state_dim: int, prompt: str, image_names: list[str]) -> dict:
- return {
+def make_random_observation(
+ width: int,
+ height: int,
+ state_dim: int,
+ initial_noise_dim: int,
+ prompt: str,
+ image_names: list[str],
+) -> dict:
+ observation = {
"images": [
{
"name": image_name,
@@ -38,6 +45,9 @@ def make_random_observation(width: int, height: int, state_dim: int, prompt: str
"state": make_random_state(state_dim, seed=1),
"prompt": prompt,
}
+ if initial_noise_dim:
+ observation["initial_noise"] = make_random_state(initial_noise_dim, seed=2)
+ return observation
def ordered_columns(rows: list[dict[str, float]]) -> list[str]:
@@ -141,6 +151,7 @@ def main() -> int:
parser.add_argument("--height", type=int, default=224)
parser.add_argument("--image-name", action="append")
parser.add_argument("--state-dim", type=int, default=6)
+ parser.add_argument("--initial-noise-dim", type=int, default=0)
parser.add_argument("--prompt", default=os.environ.get("SMOLVLA_PROMPT", "grab the block."))
parser.add_argument("--warmup", type=int, default=1)
parser.add_argument("--loops", type=int, default=10)
@@ -167,6 +178,7 @@ def main() -> int:
width=args.width,
height=args.height,
state_dim=args.state_dim,
+ initial_noise_dim=args.initial_noise_dim,
prompt=args.prompt,
image_names=image_names,
)
diff --git a/robot_server/test/test_server_latency.sh b/robot_server/test/test_server_latency.sh
index 3073dff..de19516 100755
--- a/robot_server/test/test_server_latency.sh
+++ b/robot_server/test/test_server_latency.sh
@@ -7,9 +7,9 @@ set -e
# bash robot_server/test/test_server_latency.sh
#
# Positional args:
-# $1: model-type, e.g. smolvla / pi0
+# $1: model-type, e.g. smolvla / pi0 / starvla
# $2: backend, e.g. mac-cpu / mac-metal / linux-cpu / linux-cuda
-# $3: test-suite, e.g. smolvla-libero / smolvla-so101 / pi0-libero
+# $3: test-suite, e.g. smolvla-libero / smolvla-so101 / pi0-libero / starvla-bridge
ROBOT_CPP_ROOT="${ROBOT_CPP_ROOT:?ROBOT_CPP_ROOT must be set}"
GGUF_DIR="${GGUF_DIR:?GGUF_DIR must be set}"
MODEL_TYPE="${1:-${MODEL_TYPE:-smolvla}}"
@@ -39,6 +39,17 @@ case "${MODEL_TYPE}" in
ACTION_DECODER_GGUF="${ACTION_DECODER_GGUF:-${GGUF_DIR}/${MODEL_BASENAME}.action_decoder.gguf}"
LLM_GGUF="${LLM_GGUF:-${GGUF_DIR}/${MODEL_BASENAME}.llm.gguf}"
;;
+ starvla)
+ mapfile -t LLM_CANDIDATES < <(find "${GGUF_DIR}" -maxdepth 1 -type f -name 'qwen-*.gguf' -print)
+ mapfile -t MMPROJ_CANDIDATES < <(find "${GGUF_DIR}" -maxdepth 1 -type f -name 'mmproj-*.gguf' -print)
+ mapfile -t POLICY_CANDIDATES < <(find "${GGUF_DIR}" -maxdepth 1 -type f -name '*policy*.gguf' -print)
+ [[ ${#LLM_CANDIDATES[@]} -eq 1 ]] || { echo "expected one Qwen GGUF in ${GGUF_DIR}" >&2; exit 1; }
+ [[ ${#MMPROJ_CANDIDATES[@]} -eq 1 ]] || { echo "expected one mmproj GGUF in ${GGUF_DIR}" >&2; exit 1; }
+ [[ ${#POLICY_CANDIDATES[@]} -eq 1 ]] || { echo "expected one policy GGUF in ${GGUF_DIR}" >&2; exit 1; }
+ LLM_GGUF="${LLM_GGUF:-${LLM_CANDIDATES[0]}}"
+ MMPROJ_GGUF="${MMPROJ_GGUF:-${MMPROJ_CANDIDATES[0]}}"
+ POLICY_GGUF="${POLICY_GGUF:-${POLICY_CANDIDATES[0]}}"
+ ;;
*)
echo "unsupported MODEL_TYPE=${MODEL_TYPE}" >&2
exit 1
@@ -99,6 +110,12 @@ case "${TEST_SUITE}" in
IMAGE_HEIGHT="${IMAGE_HEIGHT:-256}"
STATE_DIM="${STATE_DIM:-8}"
;;
+ starvla-bridge)
+ IMAGE_NAMES="${IMAGE_NAMES:-${IMAGE_NAME:-image_0}}"
+ IMAGE_WIDTH="${IMAGE_WIDTH:-224}"
+ IMAGE_HEIGHT="${IMAGE_HEIGHT:-224}"
+ STATE_DIM="${STATE_DIM:-0}"
+ ;;
*)
echo "unsupported TEST_SUITE=${TEST_SUITE}" >&2
exit 1
@@ -108,6 +125,7 @@ WARMUP="${WARMUP:-5}"
LOOPS="${LOOPS:-100}"
SERVER_WAIT_S="${SERVER_WAIT_S:-120}"
DTYPE="${DTYPE:-f32}"
+NOISE_SEED="${NOISE_SEED:--1}"
PYTHON="${PYTHON:-python3}"
# ====================================
@@ -155,12 +173,13 @@ run_latency_case() {
TOKENIZER_GGUF="${TOKENIZER_GGUF:-}" \
STATE_GGUF="${STATE_GGUF:-}" \
ACTION_DECODER_GGUF="${ACTION_DECODER_GGUF:-}" \
+ POLICY_GGUF="${POLICY_GGUF:-}" \
HOST="${HOST}" \
PORT="${PORT}" \
THREADS="${threads}" \
TASK="${PROMPT}" \
NOISE_MODE="gaussian" \
- NOISE_SEED="-1" \
+ NOISE_SEED="${NOISE_SEED}" \
bash "${LAUNCH_SHELL}" "${MODEL_TYPE}" >"${server_log}" 2>&1 &
SERVER_PID=$!
diff --git a/src/model-cli.cpp b/src/model-cli.cpp
index bc82d8e..8dc8441 100644
--- a/src/model-cli.cpp
+++ b/src/model-cli.cpp
@@ -1,7 +1,9 @@
// model-cli.cpp — common robotcpp::Model CLI frontend
#include "models/model.h"
+#include "models/argument_parse.h"
#include "models/smolvla/smolvla_engine.h"
+#include "llama.h"
#include "stb_image.h"
#include
@@ -26,16 +28,11 @@ struct loaded_image {
int stride_bytes = 0;
};
-bool parse_model_type(const std::string & value, robotcpp::model_type & out) {
- if (value == "smolvla") {
- out = robotcpp::model_type::smolvla;
- return true;
- }
- if (value == "pi0") {
- out = robotcpp::model_type::pi0;
- return true;
+void quiet_llama_log_callback(ggml_log_level level, const char * text, void * user_data) {
+ (void)user_data;
+ if (level == GGML_LOG_LEVEL_ERROR) {
+ std::fputs(text, stderr);
}
- return false;
}
bool parse_noise_mode(const std::string & value, int & out_mode) {
@@ -54,13 +51,14 @@ void print_usage(const char * prog) {
std::fprintf(stderr, "\nModel CLI - robotcpp::Model frontend\n\n");
std::fprintf(stderr, "Usage:\n");
std::fprintf(stderr, " %s --model-type smolvla [options]\n", prog);
- std::fprintf(stderr, " %s --model-type pi0 [options]\n\n", prog);
+ std::fprintf(stderr, " %s --model-type pi0 [options]\n", prog);
+ std::fprintf(stderr, " %s --model-type starvla --llm --mmproj --policy [options]\n\n", prog);
std::fprintf(stderr, "Common options:\n");
- std::fprintf(stderr, " --model-type Model type (default: smolvla)\n");
+ std::fprintf(stderr, " --model-type smolvla|pi0|starvla\n"
+ " (default: smolvla)\n");
std::fprintf(stderr, " --image Input image (repeatable; order matches --image-name)\n");
- std::fprintf(
- stderr,
- " --image-name Observation image name (repeatable; default: image for single-image input)\n");
+ std::fprintf(stderr,
+ " --image-name Observation image name (default: image_0 for StarVLA, image otherwise)\n");
std::fprintf(stderr, " --state Proprio/state values (comma-separated)\n");
std::fprintf(stderr, " --task Task instruction (default: \"grab the block.\")\n");
std::fprintf(stderr, " --threads Number of threads (default: auto)\n");
@@ -82,6 +80,13 @@ void print_usage(const char * prog) {
std::fprintf(stderr, " --tokenizer Tokenizer GGUF path\n");
std::fprintf(stderr, " --state-gguf State projector GGUF path\n");
std::fprintf(stderr, " --action-decoder Action decoder GGUF path\n");
+ std::fprintf(stderr, "\nStarVLA options:\n");
+ std::fprintf(stderr, " --policy StarVLA policy GGUF path (required)\n");
+ std::fprintf(stderr, " --llm Qwen text GGUF path (required)\n");
+ std::fprintf(stderr, " --mmproj Qwen vision GGUF path (required)\n");
+ std::fprintf(stderr, " --n-batch Qwen batch size (default: 512)\n");
+ std::fprintf(stderr, " --n-ctx Qwen context size (default: 2048)\n");
+ std::fprintf(stderr, " --noise-seed GR00T/PI/PI_v3 noise seed, <0 means auto (default: -1)\n");
}
bool parse_state(const char * csv, std::vector & out) {
@@ -128,7 +133,7 @@ int main(int argc, char ** argv) {
std::vector image_paths;
std::vector image_names;
std::string state_csv;
- std::string task;
+ std::string task = "grab the block.";
for (int i = 1; i < argc; i++) {
std::string arg = argv[i];
@@ -139,10 +144,12 @@ int main(int argc, char ** argv) {
} else if (arg == "-v" || arg == "--verbose") {
args.verbosity++;
} else if (arg == "--model-type" && i + 1 < argc) {
- if (!parse_model_type(argv[++i], args.type)) {
+ if (!robotcpp::parse_model_type(argv[++i], args.type)) {
std::fprintf(stderr, "Error: unsupported model type '%s'\n", argv[i]);
return 1;
}
+ } else if (arg == "--policy" && i + 1 < argc) {
+ args.policy_path = argv[++i];
} else if (arg == "--llm" && i + 1 < argc) {
args.llm_path = argv[++i];
} else if (arg == "--mmproj" && i + 1 < argc) {
@@ -168,18 +175,34 @@ int main(int argc, char ** argv) {
} else if (arg == "--task" && i + 1 < argc) {
task = argv[++i];
} else if (arg == "--threads" && i + 1 < argc) {
- args.threads = std::atoi(argv[++i]);
+ const char * value = argv[++i];
+ if (!robotcpp::parse_integer_argument(value, args.threads)) {
+ std::fprintf(stderr, "Error: invalid --threads value '%s'\n", value);
+ return 1;
+ }
} else if (arg == "--n-batch" && i + 1 < argc) {
- args.n_batch = std::atoi(argv[++i]);
+ const char * value = argv[++i];
+ if (!robotcpp::parse_integer_argument(value, args.n_batch)) {
+ std::fprintf(stderr, "Error: invalid --n-batch value '%s'\n", value);
+ return 1;
+ }
} else if (arg == "--n-ctx" && i + 1 < argc) {
- args.n_ctx = std::atoi(argv[++i]);
+ const char * value = argv[++i];
+ if (!robotcpp::parse_integer_argument(value, args.n_ctx)) {
+ std::fprintf(stderr, "Error: invalid --n-ctx value '%s'\n", value);
+ return 1;
+ }
} else if (arg == "--noise-mode" && i + 1 < argc) {
if (!parse_noise_mode(argv[++i], args.noise_mode)) {
std::fprintf(stderr, "Error: invalid noise mode '%s'\n", argv[i]);
return 1;
}
} else if (arg == "--noise-seed" && i + 1 < argc) {
- args.noise_seed = std::atoll(argv[++i]);
+ const char * value = argv[++i];
+ if (!robotcpp::parse_integer_argument(value, args.noise_seed)) {
+ std::fprintf(stderr, "Error: invalid --noise-seed value '%s'\n", value);
+ return 1;
+ }
} else {
std::fprintf(stderr, "Error: unknown argument '%s'\n", arg.c_str());
print_usage(argv[0]);
@@ -187,23 +210,46 @@ int main(int argc, char ** argv) {
}
}
+ if (args.threads < 0 || args.n_batch <= 0 || args.n_ctx <= 0) {
+ std::fprintf(stderr, "Error: --threads must be non-negative and --n-batch/--n-ctx must be positive\n");
+ return 1;
+ }
+ if (robotcpp::is_starvla_model_type(args.type)) {
+ if (args.llm_path.empty() || args.mmproj_path.empty() || args.policy_path.empty()) {
+ std::fprintf(stderr, "Error: %s requires --llm --mmproj --policy\n", robotcpp::model_type_name(args.type));
+ return 1;
+ }
+ if (args.noise_mode != SMOLVLA_NOISE_MODE_GAUSSIAN) {
+ std::fprintf(stderr, "Error: StarVLA does not support --noise-mode debug-sin; use Gaussian noise\n");
+ return 1;
+ }
+ }
+
if (image_paths.empty()) {
std::fprintf(stderr, "Error: --image is required\n");
print_usage(argv[0]);
return 1;
}
+ if (robotcpp::is_starvla_model_type(args.type) && image_paths.size() != 1) {
+ std::fprintf(stderr, "Error: %s requires exactly one --image\n", robotcpp::model_type_name(args.type));
+ return 1;
+ }
if (image_names.empty()) {
if (image_paths.size() != 1) {
std::fprintf(stderr, "Error: multiple --image inputs require one --image-name per image\n");
return 1;
}
- image_names.push_back("image");
+ image_names.push_back(robotcpp::is_starvla_model_type(args.type) ? "image_0" : "image");
}
if (image_names.size() != image_paths.size()) {
std::fprintf(stderr, "Error: --image count (%zu) must match --image-name count (%zu)\n", image_paths.size(),
image_names.size());
return 1;
}
+ if (robotcpp::is_starvla_model_type(args.type) && image_names[0] != "image_0") {
+ std::fprintf(stderr, "Error: %s image must be named 'image_0'\n", robotcpp::model_type_name(args.type));
+ return 1;
+ }
std::vector state_vec;
if (!parse_state(state_csv.c_str(), state_vec)) {
@@ -218,6 +264,8 @@ int main(int argc, char ** argv) {
}
}
+ llama_log_set(args.verbosity > 0 ? nullptr : quiet_llama_log_callback, nullptr);
+
const auto init_start = std::chrono::high_resolution_clock::now();
std::string error;
std::unique_ptr model;
diff --git a/src/models/argument_parse.h b/src/models/argument_parse.h
new file mode 100644
index 0000000..0ad5b60
--- /dev/null
+++ b/src/models/argument_parse.h
@@ -0,0 +1,27 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+namespace robotcpp {
+
+template bool parse_integer_argument(const char * value, Integer & output) {
+ static_assert(std::is_integral::value && !std::is_same::value,
+ "Integer must be a non-bool integral type");
+ if (value == nullptr || value[0] == '\0') {
+ return false;
+ }
+
+ Integer parsed = 0;
+ const char * end = value + std::strlen(value);
+ const std::from_chars_result result = std::from_chars(value, end, parsed, 10);
+ if (result.ec != std::errc{} || result.ptr != end) {
+ return false;
+ }
+ output = parsed;
+ return true;
+}
+
+} // namespace robotcpp
diff --git a/src/models/ggml_backend.cpp b/src/models/ggml_backend.cpp
index 062d895..65ef5ed 100644
--- a/src/models/ggml_backend.cpp
+++ b/src/models/ggml_backend.cpp
@@ -6,7 +6,7 @@
#include
#include
-static const char * backend_mode_name(backend_mode mode) {
+const char * backend_mode_name(backend_mode mode) {
switch (mode) {
case backend_mode::cuda:
return "cuda";
diff --git a/src/models/ggml_backend.h b/src/models/ggml_backend.h
index 08b735d..a044f6f 100644
--- a/src/models/ggml_backend.h
+++ b/src/models/ggml_backend.h
@@ -11,6 +11,8 @@ enum class backend_mode {
metal,
};
+const char * backend_mode_name(backend_mode mode);
+
struct backend_buft_policy {
ggml_backend_buffer_type_t model_buft = nullptr;
ggml_backend_buffer_type_t runtime_buft = nullptr;
diff --git a/src/models/model.h b/src/models/model.h
index 587957c..d89b9d3 100644
--- a/src/models/model.h
+++ b/src/models/model.h
@@ -10,8 +10,13 @@ namespace robotcpp {
enum class model_type {
smolvla,
pi0,
+ starvla,
};
+const char * model_type_name(model_type type);
+bool parse_model_type(const std::string & value, model_type & out);
+bool is_starvla_model_type(model_type type);
+
struct model_image {
std::string name;
const uint8_t * data = nullptr;
@@ -24,6 +29,7 @@ struct model_image {
struct observation {
std::vector images;
std::vector state;
+ std::vector initial_noise;
std::string task;
};
@@ -59,6 +65,9 @@ struct model_args {
std::string tokenizer_path;
std::string state_path;
std::string action_decoder_path;
+
+ // starvla
+ std::string policy_path;
};
class Model {
diff --git a/src/models/model_factory.cpp b/src/models/model_factory.cpp
index d6b1aa3..3f887ad 100644
--- a/src/models/model_factory.cpp
+++ b/src/models/model_factory.cpp
@@ -2,6 +2,9 @@
#include "models/pi0/pi0_model.h"
#include "models/smolvla/smolvla_model.h"
+#ifdef ROBOT_CPP_BUILD_STARVLA
+#include "models/starvla/starvla_model.h"
+#endif
namespace robotcpp {
@@ -13,8 +16,16 @@ bool make_model(const model_args & args, std::unique_ptr & out, std::stri
if (args.type == model_type::pi0) {
return make_pi0_model(args, out, error);
}
+ if (args.type == model_type::starvla) {
+#ifdef ROBOT_CPP_BUILD_STARVLA
+ return make_starvla_model(args, out, error);
+#else
+ error = "StarVLA support was not built; configure with -DROBOT_CPP_BUILD_STARVLA=ON";
+ return false;
+#endif
+ }
- error = "unsupported model type";
+ error = std::string("unsupported model type: ") + model_type_name(args.type);
return false;
}
diff --git a/src/models/model_type.cpp b/src/models/model_type.cpp
new file mode 100644
index 0000000..6a4d3d5
--- /dev/null
+++ b/src/models/model_type.cpp
@@ -0,0 +1,49 @@
+#include "models/model.h"
+
+#include
+
+namespace robotcpp {
+namespace {
+
+struct model_type_entry {
+ model_type type;
+ const char * name;
+};
+
+constexpr std::array MODEL_TYPES = {{
+ {model_type::smolvla, "smolvla"},
+ {model_type::pi0, "pi0"},
+ {model_type::starvla, "starvla"},
+}};
+
+const model_type_entry * find_entry(model_type type) {
+ for (const model_type_entry & entry : MODEL_TYPES) {
+ if (entry.type == type) {
+ return &entry;
+ }
+ }
+ return nullptr;
+}
+
+} // namespace
+
+const char * model_type_name(model_type type) {
+ const model_type_entry * entry = find_entry(type);
+ return entry ? entry->name : "unknown";
+}
+
+bool parse_model_type(const std::string & value, model_type & out) {
+ for (const model_type_entry & entry : MODEL_TYPES) {
+ if (value == entry.name) {
+ out = entry.type;
+ return true;
+ }
+ }
+ return false;
+}
+
+bool is_starvla_model_type(model_type type) {
+ return type == model_type::starvla;
+}
+
+} // namespace robotcpp
diff --git a/src/models/pi0/pi0_model.cpp b/src/models/pi0/pi0_model.cpp
index 5d62866..4d46a0d 100644
--- a/src/models/pi0/pi0_model.cpp
+++ b/src/models/pi0/pi0_model.cpp
@@ -80,6 +80,10 @@ bool Pi0Model::predict(const observation & obs, model_result & out, std::string
error = "Pi0 model is not initialized";
return false;
}
+ if (!obs.initial_noise.empty()) {
+ error = "Pi0 does not accept explicit initial noise";
+ return false;
+ }
if (obs.images.empty()) {
error = "Pi0 requires at least one image";
return false;
diff --git a/src/models/smolvla/smolvla_model.cpp b/src/models/smolvla/smolvla_model.cpp
index a718fd2..db6567c 100644
--- a/src/models/smolvla/smolvla_model.cpp
+++ b/src/models/smolvla/smolvla_model.cpp
@@ -72,6 +72,10 @@ bool SmolVLAModel::predict(const observation & obs, model_result & out, std::str
error = "SmolVLA model is not initialized";
return false;
}
+ if (!obs.initial_noise.empty()) {
+ error = "SmolVLA does not accept explicit initial noise";
+ return false;
+ }
if (obs.images.empty()) {
error = "SmolVLA requires at least one image";
return false;
diff --git a/src/models/starvla/fast_codec.cpp b/src/models/starvla/fast_codec.cpp
new file mode 100644
index 0000000..9ce67fd
--- /dev/null
+++ b/src/models/starvla/fast_codec.cpp
@@ -0,0 +1,537 @@
+#include "models/starvla/fast_codec.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace robotcpp::starvla {
+namespace {
+
+constexpr size_t kMaximumVocabSize = 65536U;
+constexpr size_t kMaximumTimeHorizon = 1024U;
+constexpr size_t kMaximumActionDim = 1024U;
+constexpr size_t kMaximumBatchSize = 1024U;
+constexpr size_t kMaximumTokenSequence = 4096U;
+constexpr size_t kMaximumGeneratedSequence = 2048U;
+constexpr size_t kMaximumDecodedBytes = 1024U * 1024U;
+constexpr size_t kMaximumOutputScalars = 16U * 1024U * 1024U;
+constexpr uint64_t kMaximumIdctMultiplyAdds = 64ULL * 1024ULL * 1024ULL;
+constexpr double kPi = 3.141592653589793238462643383279502884;
+
+bool decode_utf8_strict(const std::string & input, std::vector & output, std::string & error) {
+ output.clear();
+ for (size_t i = 0; i < input.size();) {
+ const uint8_t first = static_cast(input[i]);
+ uint32_t value = 0;
+ size_t length = 0;
+ if (first <= 0x7fU) {
+ value = first;
+ length = 1;
+ } else if (first >= 0xc2U && first <= 0xdfU) {
+ value = first & 0x1fU;
+ length = 2;
+ } else if (first >= 0xe0U && first <= 0xefU) {
+ value = first & 0x0fU;
+ length = 3;
+ } else if (first >= 0xf0U && first <= 0xf4U) {
+ value = first & 0x07U;
+ length = 4;
+ } else {
+ error = "StarVLA FAST tokenizer vocabulary contains invalid UTF-8";
+ return false;
+ }
+ if (i + length > input.size()) {
+ error = "StarVLA FAST tokenizer vocabulary contains truncated UTF-8";
+ return false;
+ }
+ for (size_t j = 1; j < length; ++j) {
+ const uint8_t continuation = static_cast(input[i + j]);
+ if ((continuation & 0xc0U) != 0x80U) {
+ error = "StarVLA FAST tokenizer vocabulary contains invalid UTF-8 continuation";
+ return false;
+ }
+ value = (value << 6U) | (continuation & 0x3fU);
+ }
+ const bool overlong =
+ (length == 2 && value < 0x80U) || (length == 3 && value < 0x800U) || (length == 4 && value < 0x10000U);
+ if (overlong || value > 0x10ffffU || (value >= 0xd800U && value <= 0xdfffU)) {
+ error = "StarVLA FAST tokenizer vocabulary contains a non-scalar UTF-8 value";
+ return false;
+ }
+ output.push_back(value);
+ i += length;
+ }
+ return true;
+}
+
+std::unordered_map byte_level_inverse_alphabet() {
+ std::unordered_map result;
+ std::unordered_set direct;
+ for (int value = 0x21; value <= 0x7e; ++value) {
+ direct.insert(value);
+ result.emplace(static_cast(value), static_cast(value));
+ }
+ for (int value = 0xa1; value <= 0xac; ++value) {
+ direct.insert(value);
+ result.emplace(static_cast(value), static_cast(value));
+ }
+ for (int value = 0xae; value <= 0xff; ++value) {
+ direct.insert(value);
+ result.emplace(static_cast(value), static_cast(value));
+ }
+ uint32_t extra = 0;
+ for (int value = 0; value <= 0xff; ++value) {
+ if (direct.count(value) == 0) {
+ result.emplace(256U + extra, static_cast(value));
+ ++extra;
+ }
+ }
+ return result;
+}
+
+bool compile_token_bytes(const std::vector & vocab_by_id, std::vector> & token_bytes,
+ std::string & error) {
+ const auto inverse_alphabet = byte_level_inverse_alphabet();
+ token_bytes.clear();
+ token_bytes.reserve(vocab_by_id.size());
+ for (size_t token_id = 0; token_id < vocab_by_id.size(); ++token_id) {
+ if (vocab_by_id[token_id].empty()) {
+ error = "StarVLA FAST tokenizer has an empty vocabulary piece at ID " + std::to_string(token_id);
+ return false;
+ }
+ std::vector piece_codepoints;
+ if (!decode_utf8_strict(vocab_by_id[token_id], piece_codepoints, error)) {
+ error += " at token ID " + std::to_string(token_id);
+ return false;
+ }
+ std::vector bytes;
+ bytes.reserve(piece_codepoints.size());
+ for (uint32_t codepoint : piece_codepoints) {
+ const auto found = inverse_alphabet.find(codepoint);
+ if (found == inverse_alphabet.end()) {
+ error = "StarVLA FAST tokenizer piece contains a code point outside the ByteLevel "
+ "alphabet at ID " +
+ std::to_string(token_id);
+ return false;
+ }
+ bytes.push_back(found->second);
+ }
+ token_bytes.push_back(std::move(bytes));
+ }
+ return true;
+}
+
+void decode_utf8_lossy(const std::vector & input, std::vector & output) {
+ output.clear();
+ for (size_t i = 0; i < input.size();) {
+ const uint8_t first = input[i];
+ if (first <= 0x7fU) {
+ output.push_back(first);
+ ++i;
+ continue;
+ }
+
+ size_t length = 0;
+ uint32_t value = 0;
+ if (first >= 0xc2U && first <= 0xdfU) {
+ length = 2;
+ value = first & 0x1fU;
+ } else if (first >= 0xe0U && first <= 0xefU) {
+ length = 3;
+ value = first & 0x0fU;
+ } else if (first >= 0xf0U && first <= 0xf4U) {
+ length = 4;
+ value = first & 0x07U;
+ } else {
+ output.push_back(0xfffdU);
+ ++i;
+ continue;
+ }
+
+ if (i + 1 >= input.size()) {
+ output.push_back(0xfffdU);
+ break;
+ }
+ const uint8_t second = input[i + 1];
+ const bool second_is_continuation = (second & 0xc0U) == 0x80U;
+ const bool second_in_scalar_range = !(first == 0xe0U && second < 0xa0U) &&
+ !(first == 0xedU && second > 0x9fU) &&
+ !(first == 0xf0U && second < 0x90U) && !(first == 0xf4U && second > 0x8fU);
+ if (!second_is_continuation || !second_in_scalar_range) {
+ output.push_back(0xfffdU);
+ ++i;
+ continue;
+ }
+ value = (value << 6U) | (second & 0x3fU);
+
+ bool invalid = false;
+ size_t consumed_prefix = 2;
+ for (size_t j = 2; j < length; ++j) {
+ if (i + j >= input.size()) {
+ output.push_back(0xfffdU);
+ i = input.size();
+ invalid = true;
+ break;
+ }
+ const uint8_t continuation = input[i + j];
+ if ((continuation & 0xc0U) != 0x80U) {
+ output.push_back(0xfffdU);
+ i += consumed_prefix;
+ invalid = true;
+ break;
+ }
+ value = (value << 6U) | (continuation & 0x3fU);
+ ++consumed_prefix;
+ }
+ if (invalid) {
+ continue;
+ }
+ output.push_back(value);
+ i += length;
+ }
+}
+
+bool checked_action_count(const FastCodecConfig & config, size_t batch_size, size_t & per_sample, size_t & total,
+ std::string & error) {
+ if (config.vocab_size > kMaximumVocabSize || config.time_horizon > kMaximumTimeHorizon ||
+ config.action_dim > kMaximumActionDim) {
+ error = "StarVLA FAST codec dimensions exceed the runtime safety limits";
+ return false;
+ }
+ if (batch_size == 0 || batch_size > kMaximumBatchSize) {
+ error = "StarVLA FAST batch size exceeds the runtime safety limit";
+ return false;
+ }
+ if (config.time_horizon > std::numeric_limits::max() / config.action_dim) {
+ error = "StarVLA FAST action shape overflows size_t";
+ return false;
+ }
+ per_sample = config.time_horizon * config.action_dim;
+ if (batch_size > std::numeric_limits::max() / per_sample) {
+ error = "StarVLA FAST batch shape overflows size_t";
+ return false;
+ }
+ total = batch_size * per_sample;
+ if (total > kMaximumOutputScalars) {
+ error = "StarVLA FAST output tensor exceeds the runtime scalar limit";
+ return false;
+ }
+ const uint64_t horizon = static_cast(config.time_horizon);
+ const uint64_t action_dim = static_cast(config.action_dim);
+ const uint64_t batch = static_cast(batch_size);
+ if (horizon > kMaximumIdctMultiplyAdds / horizon) {
+ error = "StarVLA FAST inverse DCT exceeds the runtime work limit";
+ return false;
+ }
+ uint64_t multiply_adds = horizon * horizon;
+ if (action_dim > kMaximumIdctMultiplyAdds / multiply_adds) {
+ error = "StarVLA FAST inverse DCT exceeds the runtime work limit";
+ return false;
+ }
+ multiply_adds *= action_dim;
+ if (batch > kMaximumIdctMultiplyAdds / multiply_adds) {
+ error = "StarVLA FAST inverse DCT exceeds the runtime work limit";
+ return false;
+ }
+ return true;
+}
+
+} // namespace
+
+FastCodec::FastCodec(FastCodecConfig config, std::vector> token_bytes,
+ std::vector fast_to_vlm_id)
+ : config_(config), token_bytes_(std::move(token_bytes)), fast_to_vlm_id_(std::move(fast_to_vlm_id)) {
+ vlm_to_fast_id_.reserve(fast_to_vlm_id_.size());
+ for (size_t fast_id = 0; fast_id < fast_to_vlm_id_.size(); ++fast_id) {
+ vlm_to_fast_id_.emplace_back(fast_to_vlm_id_[fast_id], static_cast(fast_id));
+ }
+ std::sort(vlm_to_fast_id_.begin(), vlm_to_fast_id_.end());
+}
+
+std::unique_ptr FastCodec::create(FastCodecConfig config, std::vector vocab_by_id,
+ std::vector fast_to_vlm_id, std::string & error) {
+ error.clear();
+ if (!std::isfinite(config.scale) || config.scale == 0.0 || config.vocab_size == 0 || config.time_horizon == 0 ||
+ config.action_dim == 0) {
+ error = "StarVLA FAST codec dimensions and scale must be non-zero and finite";
+ return nullptr;
+ }
+ if (config.vocab_size > static_cast(std::numeric_limits::max())) {
+ error = "StarVLA FAST vocabulary exceeds the int32 token-ID range";
+ return nullptr;
+ }
+ if (vocab_by_id.size() != config.vocab_size || fast_to_vlm_id.size() != config.vocab_size) {
+ error = "StarVLA FAST codec vocabulary or action-token map has the wrong size";
+ return nullptr;
+ }
+ size_t per_sample = 0;
+ size_t total = 0;
+ if (!checked_action_count(config, 1, per_sample, total, error)) {
+ return nullptr;
+ }
+ std::unordered_set unique_vlm_ids;
+ for (int32_t vlm_id : fast_to_vlm_id) {
+ if (vlm_id < 0 || !unique_vlm_ids.insert(vlm_id).second) {
+ error = "StarVLA FAST action-token VLM IDs must be unique and non-negative";
+ return nullptr;
+ }
+ }
+ std::vector> token_bytes;
+ if (!compile_token_bytes(vocab_by_id, token_bytes, error)) {
+ return nullptr;
+ }
+ return std::unique_ptr(new FastCodec(config, std::move(token_bytes), std::move(fast_to_vlm_id)));
+}
+
+std::unique_ptr FastCodec::create_compiled(FastCodecConfig config, std::vector token_offsets,
+ std::vector token_bytes,
+ std::vector fast_to_vlm_id, std::string & error) {
+ error.clear();
+ if (!std::isfinite(config.scale) || config.scale == 0.0 || config.vocab_size == 0 || config.time_horizon == 0 ||
+ config.action_dim == 0 || config.vocab_size > static_cast(std::numeric_limits::max())) {
+ error = "StarVLA FAST compiled codec dimensions and scale are invalid";
+ return nullptr;
+ }
+ if (config.vocab_size == std::numeric_limits::max() || token_offsets.size() != config.vocab_size + 1U ||
+ fast_to_vlm_id.size() != config.vocab_size || token_offsets.empty() || token_offsets.front() != 0 ||
+ token_offsets.back() < 0 || static_cast(token_offsets.back()) != token_bytes.size()) {
+ error = "StarVLA FAST compiled codec tensor shapes are incompatible";
+ return nullptr;
+ }
+ size_t per_sample = 0;
+ size_t total = 0;
+ if (!checked_action_count(config, 1, per_sample, total, error)) {
+ return nullptr;
+ }
+
+ std::unordered_set unique_vlm_ids;
+ for (int32_t vlm_id : fast_to_vlm_id) {
+ if (vlm_id < 0 || !unique_vlm_ids.insert(vlm_id).second) {
+ error = "StarVLA FAST compiled action-token IDs must be unique and non-negative";
+ return nullptr;
+ }
+ }
+
+ std::vector> pieces;
+ pieces.reserve(config.vocab_size);
+ for (size_t index = 0; index < config.vocab_size; ++index) {
+ const int32_t begin = token_offsets[index];
+ const int32_t end = token_offsets[index + 1U];
+ if (begin < 0 || end <= begin || static_cast(end) > token_bytes.size()) {
+ error = "StarVLA FAST compiled codec offsets are not strictly increasing";
+ return nullptr;
+ }
+ pieces.emplace_back(token_bytes.begin() + begin, token_bytes.begin() + end);
+ }
+ return std::unique_ptr(new FastCodec(config, std::move(pieces), std::move(fast_to_vlm_id)));
+}
+
+const FastCodecConfig & FastCodec::config() const {
+ return config_;
+}
+
+const std::vector & FastCodec::fast_to_vlm_ids() const {
+ return fast_to_vlm_id_;
+}
+
+bool FastCodec::map_fast_to_vlm(const std::vector & fast_ids, std::vector & vlm_ids,
+ std::string & error) const {
+ vlm_ids.clear();
+ error.clear();
+ if (fast_ids.size() > kMaximumTokenSequence) {
+ error = "StarVLA FAST token sequence exceeds the runtime length limit";
+ return false;
+ }
+ vlm_ids.reserve(fast_ids.size());
+ for (int32_t fast_id : fast_ids) {
+ if (fast_id < 0 || static_cast(fast_id) >= fast_to_vlm_id_.size()) {
+ error = "StarVLA FAST token ID is outside the codec vocabulary";
+ vlm_ids.clear();
+ return false;
+ }
+ vlm_ids.push_back(fast_to_vlm_id_[static_cast(fast_id)]);
+ }
+ return true;
+}
+
+bool FastCodec::map_vlm_to_fast(const std::vector & vlm_ids, std::vector & fast_ids,
+ std::string & error) const {
+ fast_ids.clear();
+ error.clear();
+ if (vlm_ids.size() > kMaximumTokenSequence) {
+ error = "StarVLA FAST action-token sequence exceeds the runtime length limit";
+ return false;
+ }
+ fast_ids.reserve(vlm_ids.size());
+ for (int32_t vlm_id : vlm_ids) {
+ const auto found = std::lower_bound(
+ vlm_to_fast_id_.begin(), vlm_to_fast_id_.end(), vlm_id,
+ [](const std::pair & entry, int32_t value) { return entry.first < value; });
+ if (found == vlm_to_fast_id_.end() || found->first != vlm_id) {
+ error = "Qwen token ID is not present in the StarVLA FAST action-token map";
+ fast_ids.clear();
+ return false;
+ }
+ fast_ids.push_back(found->second);
+ }
+ return true;
+}
+
+bool FastCodec::extract_fast_tokens(const std::vector & generated_ids, std::vector & fast_ids,
+ std::string & error) const {
+ fast_ids.clear();
+ error.clear();
+ if (generated_ids.size() > kMaximumGeneratedSequence) {
+ error = "Qwen generated sequence exceeds the StarVLA FAST runtime length limit";
+ return false;
+ }
+ for (int32_t vlm_id : generated_ids) {
+ const auto found = std::lower_bound(
+ vlm_to_fast_id_.begin(), vlm_to_fast_id_.end(), vlm_id,
+ [](const std::pair & entry, int32_t value) { return entry.first < value; });
+ if (found != vlm_to_fast_id_.end() && found->first == vlm_id) {
+ fast_ids.push_back(found->second);
+ }
+ }
+ return true;
+}
+
+bool FastCodec::byte_level_decode(const std::vector & fast_ids, std::vector & codepoints,
+ std::string & error) const {
+ codepoints.clear();
+ error.clear();
+ if (fast_ids.size() > kMaximumTokenSequence) {
+ error = "StarVLA FAST token sequence exceeds the runtime length limit";
+ return false;
+ }
+ size_t byte_count = 0;
+ for (int32_t fast_id : fast_ids) {
+ if (fast_id < 0 || static_cast(fast_id) >= token_bytes_.size()) {
+ error = "StarVLA FAST token ID is outside the ByteLevel BPE vocabulary";
+ return false;
+ }
+ const size_t piece_size = token_bytes_[static_cast(fast_id)].size();
+ if (byte_count > std::numeric_limits::max() - piece_size) {
+ error = "StarVLA FAST ByteLevel output size overflows size_t";
+ return false;
+ }
+ byte_count += piece_size;
+ if (byte_count > kMaximumDecodedBytes) {
+ error = "StarVLA FAST ByteLevel decode exceeds the runtime byte limit";
+ return false;
+ }
+ }
+ std::vector bytes;
+ bytes.reserve(byte_count);
+ for (int32_t fast_id : fast_ids) {
+ const auto & piece = token_bytes_[static_cast(fast_id)];
+ bytes.insert(bytes.end(), piece.begin(), piece.end());
+ }
+ decode_utf8_lossy(bytes, codepoints);
+ return true;
+}
+
+bool FastCodec::decode_fast_tokens(const std::vector> & batch_fast_ids, FastDecodeResult & result,
+ std::string & error) const {
+ result = {};
+ error.clear();
+ if (batch_fast_ids.empty()) {
+ error = "StarVLA FAST decode batch must contain at least one sequence";
+ return false;
+ }
+ if (batch_fast_ids.size() > kMaximumBatchSize) {
+ error = "StarVLA FAST decode batch exceeds the runtime size limit";
+ return false;
+ }
+ for (const auto & fast_ids : batch_fast_ids) {
+ if (fast_ids.size() > kMaximumTokenSequence) {
+ error = "StarVLA FAST token sequence exceeds the runtime length limit";
+ return false;
+ }
+ }
+ size_t per_sample = 0;
+ size_t total = 0;
+ if (!checked_action_count(config_, batch_fast_ids.size(), per_sample, total, error)) {
+ return false;
+ }
+
+ result.batch_size = batch_fast_ids.size();
+ result.time_horizon = config_.time_horizon;
+ result.action_dim = config_.action_dim;
+ result.actions.assign(total, 0.0);
+
+ const double dc_scale = 1.0 / std::sqrt(static_cast(config_.time_horizon));
+ const double ac_scale = std::sqrt(2.0 / static_cast(config_.time_horizon));
+ for (size_t batch = 0; batch < batch_fast_ids.size(); ++batch) {
+ std::vector codepoints;
+ std::string sequence_error;
+ if (!byte_level_decode(batch_fast_ids[batch], codepoints, sequence_error) || codepoints.size() != per_sample) {
+ error = "StarVLA FAST sequence " + std::to_string(batch) + ": " +
+ (sequence_error.empty() ? "decoded DCT coefficient shape mismatch" : sequence_error);
+ result = {};
+ return false;
+ }
+
+ for (size_t action = 0; action < config_.action_dim; ++action) {
+ const double dc = (static_cast(codepoints[action]) + config_.min_token) / config_.scale;
+ for (size_t time = 0; time < config_.time_horizon; ++time) {
+ double value = dc_scale * dc;
+ for (size_t frequency = 1; frequency < config_.time_horizon; ++frequency) {
+ const size_t coefficient_index = frequency * config_.action_dim + action;
+ const double coefficient =
+ (static_cast(codepoints[coefficient_index]) + config_.min_token) / config_.scale;
+ const double angle = kPi * static_cast(frequency) * static_cast(2U * time + 1U) /
+ (2.0 * static_cast(config_.time_horizon));
+ value += ac_scale * coefficient * std::cos(angle);
+ }
+ result.actions[batch * per_sample + time * config_.action_dim + action] = value;
+ }
+ }
+ }
+ return true;
+}
+
+bool FastCodec::decode_vlm_action_tokens(const std::vector> & batch_vlm_ids,
+ FastDecodeResult & result, std::string & error) const {
+ if (batch_vlm_ids.empty() || batch_vlm_ids.size() > kMaximumBatchSize) {
+ result = {};
+ error = "StarVLA FAST action-token batch is empty or exceeds the runtime size limit";
+ return false;
+ }
+ std::vector> batch_fast_ids;
+ batch_fast_ids.reserve(batch_vlm_ids.size());
+ for (const auto & vlm_ids : batch_vlm_ids) {
+ std::vector fast_ids;
+ if (!map_vlm_to_fast(vlm_ids, fast_ids, error)) {
+ result = {};
+ return false;
+ }
+ batch_fast_ids.push_back(std::move(fast_ids));
+ }
+ return decode_fast_tokens(batch_fast_ids, result, error);
+}
+
+bool FastCodec::decode_generated_tokens(const std::vector> & batch_generated_ids,
+ FastDecodeResult & result, std::string & error) const {
+ if (batch_generated_ids.empty() || batch_generated_ids.size() > kMaximumBatchSize) {
+ result = {};
+ error = "StarVLA FAST generated-token batch is empty or exceeds the runtime size limit";
+ return false;
+ }
+ std::vector> batch_fast_ids;
+ batch_fast_ids.reserve(batch_generated_ids.size());
+ for (const auto & generated_ids : batch_generated_ids) {
+ std::vector fast_ids;
+ if (!extract_fast_tokens(generated_ids, fast_ids, error)) {
+ result = {};
+ return false;
+ }
+ batch_fast_ids.push_back(std::move(fast_ids));
+ }
+ return decode_fast_tokens(batch_fast_ids, result, error);
+}
+
+} // namespace robotcpp::starvla
diff --git a/src/models/starvla/fast_codec.h b/src/models/starvla/fast_codec.h
new file mode 100644
index 0000000..4c3742b
--- /dev/null
+++ b/src/models/starvla/fast_codec.h
@@ -0,0 +1,79 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace robotcpp::starvla {
+
+struct FastCodecConfig {
+ double scale = 0.0;
+ int32_t min_token = 0;
+ size_t vocab_size = 0;
+ size_t time_horizon = 0;
+ size_t action_dim = 0;
+};
+
+struct FastDecodeResult {
+ size_t batch_size = 0;
+ size_t time_horizon = 0;
+ size_t action_dim = 0;
+ std::vector actions;
+};
+
+class FastCodec {
+ public:
+ static std::unique_ptr create(FastCodecConfig config, std::vector vocab_by_id,
+ std::vector fast_to_vlm_id, std::string & error);
+
+ // Constructs directly from the converter-compiled ByteLevel pieces stored
+ // in policy GGUF. offsets has vocab_size + 1 entries and indexes the flat
+ // byte buffer; no external tokenizer JSON is consulted.
+ static std::unique_ptr create_compiled(FastCodecConfig config, std::vector token_offsets,
+ std::vector token_bytes,
+ std::vector fast_to_vlm_id, std::string & error);
+
+ const FastCodecConfig & config() const;
+ const std::vector & fast_to_vlm_ids() const;
+
+ bool map_fast_to_vlm(const std::vector & fast_ids, std::vector & vlm_ids,
+ std::string & error) const;
+ bool map_vlm_to_fast(const std::vector & vlm_ids, std::vector & fast_ids,
+ std::string & error) const;
+
+ // Extracts every mapped action token from a generated Qwen sequence in order.
+ // EOS stopping remains the generator's responsibility; ordinary EOS/pad/text
+ // IDs in the returned sequence are ignored and do not terminate this scan.
+ bool extract_fast_tokens(const std::vector & generated_ids, std::vector & fast_ids,
+ std::string & error) const;
+
+ bool decode_fast_tokens(const std::vector> & batch_fast_ids, FastDecodeResult & result,
+ std::string & error) const;
+
+ // Strict low-level API: every input ID must be an action token. Use
+ // decode_generated_tokens for complete Qwen sequences containing text.
+ bool decode_vlm_action_tokens(const std::vector> & batch_vlm_ids, FastDecodeResult & result,
+ std::string & error) const;
+
+ // Production entry point for complete Qwen generated_ids. Ordinary text and
+ // control tokens are filtered through the explicit inverse action-token map.
+ bool decode_generated_tokens(const std::vector> & batch_generated_ids,
+ FastDecodeResult & result, std::string & error) const;
+
+ private:
+ FastCodec(FastCodecConfig config, std::vector> token_bytes,
+ std::vector fast_to_vlm_id);
+
+ bool byte_level_decode(const std::vector & fast_ids, std::vector & codepoints,
+ std::string & error) const;
+
+ FastCodecConfig config_;
+ std::vector> token_bytes_;
+ std::vector fast_to_vlm_id_;
+ std::vector> vlm_to_fast_id_;
+};
+
+} // namespace robotcpp::starvla
diff --git a/src/models/starvla/fast_policy.cpp b/src/models/starvla/fast_policy.cpp
new file mode 100644
index 0000000..e416fbb
--- /dev/null
+++ b/src/models/starvla/fast_policy.cpp
@@ -0,0 +1,272 @@
+#include "models/starvla/fast_policy.h"
+
+#include "ggml.h"
+#include "gguf.h"
+#include "models/starvla/policy_gguf.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace robotcpp::starvla {
+namespace {
+
+constexpr const char * kArchitecture = "starvla-policy";
+constexpr const char * kActionMapTensor = "starvla.policy.fast.action_token_map";
+constexpr const char * kOffsetsTensor = "starvla.policy.fast.codec.token_offsets";
+constexpr const char * kTokenBytesTensor = "starvla.policy.fast.codec.token_bytes";
+
+using detail::require_f32;
+using detail::require_i32;
+using detail::require_i32_array;
+using detail::require_string;
+using detail::require_string_array;
+
+struct FastRuntimeMetadata {
+ FastCodecConfig codec;
+ int token_bytes_count = 0;
+};
+
+FastRuntimeMetadata parse_metadata(gguf_context * gguf, FastPolicyConfig & config) {
+ if (require_string(gguf, "general.architecture") != kArchitecture ||
+ require_i32(gguf, "starvla.schema_version") != 1 || require_string(gguf, "starvla.framework") != "fast") {
+ throw std::runtime_error("GGUF is not a supported StarVLA FAST policy");
+ }
+ config.backbone_arch = require_string(gguf, "starvla.backbone.arch");
+ config.bundle_uuid = require_string(gguf, "starvla.bundle.uuid");
+ config.text_filename = require_string(gguf, "starvla.component.text.filename");
+ config.mmproj_filename = require_string(gguf, "starvla.component.mmproj.filename");
+ if (config.backbone_arch != "qwen2_5_vl" || config.bundle_uuid.empty() || config.text_filename.empty() ||
+ config.mmproj_filename.empty()) {
+ throw std::runtime_error("StarVLA FAST bundle metadata is incomplete");
+ }
+
+ config.qwen_hidden_dim = require_i32(gguf, "starvla.qwen.hidden_size");
+ config.qwen_input_embedding_dim = require_i32(gguf, "starvla.qwen.input_embedding_size");
+ config.qwen_vocab_size = require_i32(gguf, "starvla.qwen.vocab_size");
+ config.qwen_layer_count = require_i32(gguf, "starvla.qwen.layer_count");
+ config.cot_template = require_string(gguf, "starvla.prompt.cot_template");
+
+ config.action_dim = require_i32(gguf, "starvla.action.dimension");
+ config.horizon = require_i32(gguf, "starvla.action.horizon");
+ config.image_count = require_i32(gguf, "starvla.image.count");
+ config.image_names = require_string_array(gguf, "starvla.image.names");
+ config.image_processor_min_pixels = require_i32(gguf, "starvla.image.processor_min_pixels");
+ config.image_processor_max_pixels = require_i32(gguf, "starvla.image.processor_max_pixels");
+ config.image_patch_size = require_i32(gguf, "starvla.image.patch_size");
+ config.image_spatial_merge_size = require_i32(gguf, "starvla.image.spatial_merge_size");
+ config.image_min_token_count = require_i32(gguf, "starvla.image.min_token_count");
+ config.image_max_token_count = require_i32(gguf, "starvla.image.max_token_count");
+
+ const int max_length = require_i32(gguf, "starvla.fast.generation.max_length");
+ config.generation_eos_token_ids = require_i32_array(gguf, "starvla.fast.generation.eos_token_ids");
+ config.generation_top_k = require_i32(gguf, "starvla.fast.generation.top_k");
+ config.generation_repetition_penalty = require_f32(gguf, "starvla.fast.generation.repetition_penalty");
+
+ FastRuntimeMetadata runtime;
+ runtime.codec.scale = require_f32(gguf, "starvla.fast.codec.scale");
+ runtime.codec.min_token = require_i32(gguf, "starvla.fast.codec.min_token");
+ runtime.codec.vocab_size = static_cast(require_i32(gguf, "starvla.fast.codec.vocab_size"));
+ runtime.codec.time_horizon = static_cast(require_i32(gguf, "starvla.fast.codec.time_horizon"));
+ runtime.codec.action_dim = static_cast(require_i32(gguf, "starvla.fast.codec.action_dimension"));
+ const int action_token_count = require_i32(gguf, "starvla.fast.action_token.count");
+ const int offsets_count = require_i32(gguf, "starvla.fast.codec.token_offsets_count");
+ runtime.token_bytes_count = require_i32(gguf, "starvla.fast.codec.token_bytes_count");
+
+ const bool valid =
+ config.qwen_hidden_dim > 0 && config.qwen_input_embedding_dim > 0 && config.qwen_vocab_size > 0 &&
+ config.qwen_layer_count > 0 && !config.cot_template.empty() && config.action_dim > 0 && config.horizon > 0 &&
+ config.image_count > 0 && config.image_names.size() == static_cast(config.image_count) &&
+ config.image_processor_min_pixels > 0 &&
+ config.image_processor_max_pixels >= config.image_processor_min_pixels && config.image_patch_size > 0 &&
+ config.image_spatial_merge_size > 0 && config.image_min_token_count > 0 &&
+ config.image_max_token_count >= config.image_min_token_count && max_length > 0 &&
+ !config.generation_eos_token_ids.empty() && config.generation_top_k > 0 &&
+ std::isfinite(config.generation_repetition_penalty) && config.generation_repetition_penalty > 0.0f &&
+ runtime.codec.vocab_size > 0 && action_token_count == static_cast(runtime.codec.vocab_size) &&
+ offsets_count == action_token_count + 1 && runtime.token_bytes_count > 0 &&
+ runtime.codec.time_horizon == static_cast(config.horizon) &&
+ runtime.codec.action_dim == static_cast(config.action_dim);
+ if (!valid) {
+ throw std::runtime_error("StarVLA FAST metadata has incompatible dimensions");
+ }
+ config.generation_max_length = static_cast(max_length);
+
+ config.normalization = detail::require_normalization(gguf, config.action_dim);
+ return runtime;
+}
+
+struct RawTensor {
+ ggml_tensor * metadata = nullptr;
+ int index = -1;
+ std::vector bytes;
+};
+
+RawTensor read_tensor(const std::string & path, gguf_context * gguf, ggml_context * metadata_context, const char * name,
+ ggml_type expected_type, int64_t expected_elements) {
+ RawTensor result;
+ result.metadata = ggml_get_tensor(metadata_context, name);
+ result.index = gguf_find_tensor(gguf, name);
+ if (result.metadata == nullptr || result.index < 0 || result.metadata->type != expected_type ||
+ ggml_n_dims(result.metadata) != 1 || result.metadata->ne[0] != expected_elements ||
+ ggml_nelements(result.metadata) != expected_elements) {
+ throw std::runtime_error(std::string("FAST runtime tensor shape/type mismatch: ") + name);
+ }
+ result.bytes.resize(ggml_nbytes(result.metadata));
+ std::ifstream stream(path, std::ios::binary);
+ if (!stream) {
+ throw std::runtime_error("failed to open FAST policy GGUF tensor data");
+ }
+ const size_t offset = gguf_get_data_offset(gguf) + gguf_get_tensor_offset(gguf, result.index);
+ stream.seekg(static_cast(offset), std::ios::beg);
+ if (!stream || offset > static_cast(std::numeric_limits::max())) {
+ throw std::runtime_error(std::string("failed to seek FAST runtime tensor: ") + name);
+ }
+ stream.read(reinterpret_cast(result.bytes.data()), static_cast(result.bytes.size()));
+ if (!stream) {
+ throw std::runtime_error(std::string("failed to read FAST runtime tensor: ") + name);
+ }
+ return result;
+}
+
+} // namespace
+
+struct FastPolicy::Impl {
+ FastPolicyConfig config;
+ std::unique_ptr codec;
+};
+
+FastPolicy::FastPolicy(std::unique_ptr impl) : impl_(std::move(impl)) {}
+
+FastPolicy::~FastPolicy() = default;
+
+std::unique_ptr FastPolicy::load(const std::string & path, int verbosity, std::string & error) {
+ error.clear();
+ if (path.empty()) {
+ error = "StarVLA FAST policy path is required";
+ return nullptr;
+ }
+
+ ggml_context * metadata_context = nullptr;
+ gguf_init_params params{};
+ params.no_alloc = true;
+ params.ctx = &metadata_context;
+ gguf_context * gguf = gguf_init_from_file(path.c_str(), params);
+ if (gguf == nullptr || metadata_context == nullptr) {
+ if (metadata_context != nullptr) {
+ ggml_free(metadata_context);
+ }
+ if (gguf != nullptr) {
+ gguf_free(gguf);
+ }
+ error = "failed to read StarVLA FAST policy GGUF";
+ return nullptr;
+ }
+ auto cleanup = [&]() {
+ ggml_free(metadata_context);
+ metadata_context = nullptr;
+ gguf_free(gguf);
+ gguf = nullptr;
+ };
+
+ std::unique_ptr impl(new Impl());
+ try {
+ const FastRuntimeMetadata runtime = parse_metadata(gguf, impl->config);
+
+ RawTensor action_map = read_tensor(path, gguf, metadata_context, kActionMapTensor, GGML_TYPE_I32,
+ static_cast(runtime.codec.vocab_size));
+ RawTensor offsets = read_tensor(path, gguf, metadata_context, kOffsetsTensor, GGML_TYPE_I32,
+ static_cast(runtime.codec.vocab_size + 1));
+ RawTensor token_bytes =
+ read_tensor(path, gguf, metadata_context, kTokenBytesTensor, GGML_TYPE_I8, runtime.token_bytes_count);
+
+ const uint32_t endian_probe = 1;
+ if (*reinterpret_cast(&endian_probe) != 1) {
+ throw std::runtime_error("FAST runtime currently requires a little-endian host");
+ }
+ std::vector action_ids(runtime.codec.vocab_size);
+ std::vector token_offsets(runtime.codec.vocab_size + 1);
+ std::memcpy(action_ids.data(), action_map.bytes.data(), action_map.bytes.size());
+ std::memcpy(token_offsets.data(), offsets.bytes.data(), offsets.bytes.size());
+ impl->codec = FastCodec::create_compiled(runtime.codec, std::move(token_offsets), std::move(token_bytes.bytes),
+ std::move(action_ids), error);
+ if (impl->codec == nullptr) {
+ throw std::runtime_error("failed to construct embedded FAST codec: " + error);
+ }
+ if (verbosity >= 1) {
+ std::fprintf(stderr,
+ "%s: bundle=%s runtime_tensors=3 codec_vocab=%zu "
+ "generation_max_length=%zu profiles=%zu\n",
+ __func__, impl->config.bundle_uuid.c_str(), runtime.codec.vocab_size,
+ impl->config.generation_max_length, impl->config.normalization.profiles.size());
+ }
+ cleanup();
+ } catch (const std::exception & exception) {
+ cleanup();
+ error = exception.what();
+ return nullptr;
+ }
+ return std::unique_ptr(new FastPolicy(std::move(impl)));
+}
+
+bool FastPolicy::decode_generated(const std::vector & full_sequence, std::vector & normalized_actions,
+ std::string & error) const {
+ normalized_actions.clear();
+ error.clear();
+ if (impl_ == nullptr || impl_->codec == nullptr) {
+ error = "StarVLA FAST policy is not initialized";
+ return false;
+ }
+ FastDecodeResult decoded;
+ if (!impl_->codec->decode_generated_tokens({full_sequence}, decoded, error)) {
+ return false;
+ }
+ if (decoded.batch_size != 1 || decoded.time_horizon != static_cast(impl_->config.horizon) ||
+ decoded.action_dim != static_cast(impl_->config.action_dim) ||
+ decoded.actions.size() != static_cast(impl_->config.horizon * impl_->config.action_dim)) {
+ error = "embedded FAST codec returned an incompatible action tensor";
+ return false;
+ }
+ normalized_actions.reserve(decoded.actions.size());
+ for (double value : decoded.actions) {
+ const float converted = static_cast(value);
+ if (!std::isfinite(converted)) {
+ normalized_actions.clear();
+ error = "embedded FAST codec returned a non-finite action";
+ return false;
+ }
+ normalized_actions.push_back(converted);
+ }
+ return true;
+}
+
+bool FastPolicy::unnormalize(const std::vector & normalized_actions, const std::string & profile_key,
+ std::vector