From 1b8c63975e3abe0607878c54062b8b8d1a9f1ed9 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Tue, 4 Aug 2026 13:51:23 +0100 Subject: [PATCH 01/23] Benchmark replay --- .github/workflows/codspeed.yml | 67 +++++++ dimos/core/global_config.py | 7 + dimos/robot/unitree/go2/connection.py | 27 ++- dimos/robot/unitree/go2/test_connection.py | 24 +++ .../unitree/go2/test_replay_benchmark.py | 183 ++++++++++++++++++ docs/usage/cli.md | 5 +- pyproject.toml | 1 + uv.lock | 27 +++ 8 files changed, 332 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/codspeed.yml create mode 100644 dimos/robot/unitree/go2/test_replay_benchmark.py diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml new file mode 100644 index 0000000000..2b1c1404dd --- /dev/null +++ b/.github/workflows/codspeed.yml @@ -0,0 +1,67 @@ +name: codspeed + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +permissions: {} + +env: + UV_NO_SYNC: "1" + +jobs: + benchmarks: + timeout-minutes: 30 + # CodSpeed-managed bare-metal ARM64 runner; required for walltime mode. + runs-on: codspeed-macro + permissions: + contents: read # For checkout + id-token: write # OIDC upload to CodSpeed + steps: + - name: Checkout + uses: actions/checkout@v7 + # After checkout (lfs: false) the archive is a ~130-byte LFS pointer; + # hashFiles() of the pointer is a stable content-addressed key that + # changes exactly when the recording changes (arch-independent asset). + - name: Cache the replay dataset archive + uses: actions/cache@v6 + with: + path: data/.lfs/go2_hongkong_office.db.tar.gz + key: lfs-go2-hongkong-office-${{ hashFiles('data/.lfs/go2_hongkong_office.db.tar.gz') }} + # Self-healing whether or not the cache restored anything: pull only when + # the file is still a pointer. Anonymous read from the .lfsconfig endpoint. + - name: Fetch the replay dataset from LFS if not cached + run: | + if git lfs pointer --check --file data/.lfs/go2_hongkong_office.db.tar.gz; then + git lfs pull --include="data/.lfs/go2_hongkong_office.db.tar.gz" --exclude="" + else + echo "archive restored from cache" + fi + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y portaudio19-dev libturbojpeg + - name: Raise UDP receive buffers for LCM + run: sudo sysctl -w net.core.rmem_max=67108864 net.core.rmem_default=67108864 + - name: Install uv + uses: astral-sh/setup-uv@v8.3.2 + with: + enable-cache: true + - name: Setup Python + uses: actions/setup-python@v6.3.0 + with: + python-version: '3.12' + - name: Install dependencies + run: uv sync --group tests --frozen + - name: Pre-extract the replay database + run: uv run python -c "from dimos.utils.data import get_data; print(get_data('go2_hongkong_office.db'))" + - name: Run benchmarks + uses: CodSpeedHQ/action@v5 + with: + mode: walltime + run: uv run pytest --codspeed -m '' --no-cov dimos/robot/unitree/go2/test_replay_benchmark.py diff --git a/dimos/core/global_config.py b/dimos/core/global_config.py index f5cce34f11..741a115278 100644 --- a/dimos/core/global_config.py +++ b/dimos/core/global_config.py @@ -55,6 +55,13 @@ class GlobalConfig(BaseSettings): simulation: str = "" replay: bool = False replay_db: str = "go2_short" + # Playback rate multiplier for --replay (1.0 = realtime; large values drain + # as fast as the pipeline allows since late frames are never delayed). + replay_speed: float = 1.0 + # Seconds into the recording to start replay at (None = the beginning). + replay_seek: float | None = None + # Seconds of recording to play before the streams complete (None = all). + replay_duration: float | None = None new_memory: bool = False # Discover zenoh peers across the network. # Toggling off drops back to loopback-only discovery: diff --git a/dimos/robot/unitree/go2/connection.py b/dimos/robot/unitree/go2/connection.py index ee9224bbdb..cadd2cf137 100644 --- a/dimos/robot/unitree/go2/connection.py +++ b/dimos/robot/unitree/go2/connection.py @@ -140,8 +140,12 @@ def make_connection( connection_type = cfg.unitree_connection_type.lower() if ip in ("fake", "mock", "replay") or connection_type == "replay": - dataset = cfg.replay_db - return ReplayConnection(dataset=dataset) + return ReplayConnection( + dataset=cfg.replay_db, + speed=cfg.replay_speed, + seek=cfg.replay_seek, + duration=cfg.replay_duration, + ) elif ip == "mujoco" or connection_type in ("mujoco", "true"): from dimos.robot.unitree.mujoco_connection import MujocoConnection @@ -162,15 +166,20 @@ def make_connection( class ReplayConnection(UnitreeWebRTCConnection, CompositeResource): - def __init__( # type: ignore[no-untyped-def] + def __init__( self, dataset: str = "go2_china_office", - **kwargs, + *, + speed: float = 1.0, + seek: float | None = None, + duration: float | None = None, + loop: bool = False, ) -> None: self.dataset = dataset - self._loop = kwargs.get("loop", False) - self._seek = kwargs.get("seek") - self._duration = kwargs.get("duration") + self._speed = speed + self._seek = seek + self._duration = duration + self._loop = loop @cached_property def replay(self) -> Replay: @@ -180,7 +189,9 @@ def replay(self) -> Replay: SqliteStore(path=str(resolve_db_path(self.dataset)), must_exist=True) ) store.start() - return store.replay(loop=self._loop, seek=self._seek, duration=self._duration) + return store.replay( + speed=self._speed, seek=self._seek, duration=self._duration, loop=self._loop + ) def connect(self) -> None: pass diff --git a/dimos/robot/unitree/go2/test_connection.py b/dimos/robot/unitree/go2/test_connection.py index 533254fe2e..f60fa1f5ee 100644 --- a/dimos/robot/unitree/go2/test_connection.py +++ b/dimos/robot/unitree/go2/test_connection.py @@ -49,6 +49,30 @@ def test_make_connection_webrtc_forwards_aes_128_key(stub_webrtc: MagicMock) -> ) +def test_make_connection_replay_forwards_speed_and_window() -> None: + """--replay-speed/--replay-seek/--replay-duration reach ReplayConnection. + + The `replay` property is lazy, so no database is opened here. + """ + cfg = GlobalConfig( + replay=True, + replay_db="some_db", + replay_speed=25.0, + replay_seek=3.5, + replay_duration=60.0, + ) + conn = go2_conn.make_connection(None, cfg) + assert isinstance(conn, go2_conn.ReplayConnection) + assert conn.dataset == "some_db" + assert (conn._speed, conn._seek, conn._duration) == (25.0, 3.5, 60.0) + + +def test_make_connection_replay_defaults_realtime() -> None: + conn = go2_conn.make_connection("replay", GlobalConfig(replay=True)) + assert isinstance(conn, go2_conn.ReplayConnection) + assert (conn._speed, conn._seek, conn._duration) == (1.0, None, None) + + def test_connection_config_aes_key_defaults_from_global_config() -> None: """ConnectionConfig.aes_128_key defaults from GlobalConfig.unitree_aes_128_key.""" g = GlobalConfig(robot_ip="127.0.0.1", unitree_aes_128_key="dd" * 16) diff --git a/dimos/robot/unitree/go2/test_replay_benchmark.py b/dimos/robot/unitree/go2/test_replay_benchmark.py new file mode 100644 index 0000000000..729dae6a59 --- /dev/null +++ b/dimos/robot/unitree/go2/test_replay_benchmark.py @@ -0,0 +1,183 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CodSpeed walltime benchmark of the unitree-go2 blueprint under replay. + +The equivalent of `dimos --replay --replay-db go2_hongkong_office run +unitree-go2`, bounded: build the coordinator, drain the first ~60s of the +recording as fast as the pipeline allows (large --replay-speed collapses the +wall-clock pacing), stop. The measured region is build+drain; replay starts +inside ModuleCoordinator.build() (GO2Connection.start() subscribes the replay +streams), so the two can't be separated without changing the blueprint. + +Drain completion is detected by quiescence — no arrival on any watched source +topic for QUIET_S — because exact delivery counting is unreliable by design +(zenoh gives Image/PointCloud2 topics latest-wins QoS; LCM UDP drops fragments +under flood). Windowed per-stream counts read from the database beforehand act +as validity floors so a silently dead stream fails the run instead of producing +a fast-but-meaningless sample. + +self_hosted (LFS data, ~10 worker processes): the self-hosted CI job runs it +as a plain test — the `benchmark` fixture just calls the function once — which +keeps the path in coverage; .github/workflows/codspeed.yml runs the same test +under `pytest --codspeed` for the measured walltime sample. Linux-only for +now (skipif_macos_bug): local macOS runs die on the coordinator->worker zenoh +RPC timeout, and the LCM fallback needs lo0 route + maxdgram tuning. Linux +smoke against the small bundled recording: + + DIMOS_BENCH_REPLAY_DB=go2_short DIMOS_BENCH_DURATION=10 \ + uv run pytest dimos/robot/unitree/go2/test_replay_benchmark.py \ + -m self_hosted --no-cov -v +""" + +from __future__ import annotations + +import os +import threading +import time +from typing import TYPE_CHECKING + +import pytest + +from dimos.core.global_config import global_config +from dimos.core.transport_factory import make_transport +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.sensor_msgs.Image import Image +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 + +if TYPE_CHECKING: + from pytest_codspeed import BenchmarkFixture + +REPLAY_DB = os.environ.get("DIMOS_BENCH_REPLAY_DB", "go2_hongkong_office") +DURATION = float(os.environ.get("DIMOS_BENCH_DURATION", "60")) +SPEED = 1000.0 # every emission delay clamps to 0: a pure CPU-bound drain +QUIET_S = 3.0 # all watched streams silent this long => drained +DRAIN_TIMEOUT = 420.0 # build+drain deadline, inside the test timeout +# GO2Connection source streams. camera_info is deliberately absent: it is +# published by a 1 Hz forever-loop thread and never quiesces. +WATCHED = (("odom", PoseStamped), ("lidar", PointCloud2), ("color_image", Image)) +# Validity gates, not the timing edge: odom is small and near-lossless; the +# heavy latest-wins streams legitimately drop under the flood. +FLOOR_FRACTION = {"odom": 0.9, "lidar": 0.5, "color_image": 0.5} + + +def _expected_counts(db_path: str) -> dict[str, int]: + """Windowed per-stream counts straight from the DB. + + Mirrors ReplayConnection's stream-name fallback (mid360-era recordings use + go2_lidar/go2_odom, older ones lidar/odom). + """ + from dimos.memory2.store.sqlite import SqliteStore + + store = SqliteStore(path=db_path, must_exist=True) + store.start() + try: + replay = store.replay(duration=DURATION) + available = replay.list_streams() + + def first_present(*names: str) -> str: + for name in names: + if name in available: + return name + raise KeyError(f"none of {names!r} in {db_path!r}; available: {available}") + + return { + "odom": replay.stream(first_present("go2_odom", "odom")).count(), + "lidar": replay.stream(first_present("go2_lidar", "lidar")).count(), + "color_image": replay.stream("color_image").count(), + } + finally: + store.stop() + + +@pytest.mark.self_hosted +# macOS: coordinator->worker zenoh RPC times out (set_transport), and the +# in-test LCM subscriptions would need lo0 route + maxdgram host tuning. +@pytest.mark.skipif_macos_bug +@pytest.mark.timeout(900) +def test_go2_replay_drain_walltime(benchmark: BenchmarkFixture) -> None: + """Build the unitree-go2 blueprint and drain a replay window at max speed.""" + from dimos.core.coordination.module_coordinator import ModuleCoordinator + from dimos.memory2.replay import resolve_db_path + from dimos.robot.get_all_blueprints import get_blueprint_by_name + + # Configure before resolving the blueprint: unitree_go2_basic composes its + # vis bundle from global_config.viewer at import time. build() also writes + # the blueprint's own overrides into global_config, so restore everything. + saved = global_config.model_dump() + global_config.update( + replay=True, + replay_db=REPLAY_DB, + replay_speed=SPEED, + replay_duration=DURATION, + viewer="none", + ) + try: + db_path = str(resolve_db_path(REPLAY_DB)) # LFS pull/extract on miss + expected = _expected_counts(db_path) + assert all(count > 0 for count in expected.values()), f"empty window: {expected}" + floors = {name: int(count * FLOOR_FRACTION[name]) for name, count in expected.items()} + + blueprint = get_blueprint_by_name("unitree-go2") + + counts = dict.fromkeys(FLOOR_FRACTION, 0) + last_arrival = time.monotonic() + lock = threading.Lock() + + def record(name: str) -> None: + nonlocal last_arrival + with lock: + counts[name] += 1 + last_arrival = time.monotonic() + + # Same topics and backend the blueprint materializes for these + # name-unique streams. Subscribe before build: replay data flows as + # soon as GO2Connection starts, mid-build. + transports = [make_transport(name, typ) for name, typ in WATCHED] + for (name, _), transport in zip(WATCHED, transports, strict=True): + transport.subscribe(lambda _msg, _name=name: record(_name)) + + state: dict[str, ModuleCoordinator] = {} + + def build_and_drain() -> None: + nonlocal last_arrival + state["coordinator"] = ModuleCoordinator.build(blueprint) + with lock: + last_arrival = time.monotonic() + deadline = time.monotonic() + DRAIN_TIMEOUT + while time.monotonic() < deadline: + with lock: + quiet = time.monotonic() - last_arrival + done = all(counts[name] >= floors[name] for name in counts) + if done and quiet >= QUIET_S: + return + time.sleep(0.2) + pytest.fail(f"window did not drain: counts={counts}, expected~{expected}") + + def teardown() -> None: + for transport in transports: + transport.stop() + coordinator = state.pop("coordinator", None) + if coordinator is not None: + # coordinator.stop() can hang on worker teardown; a hard exit + # (tool_replay_bench style) would lose the benchmark upload. + stopper = threading.Thread(target=coordinator.stop, daemon=True) + stopper.start() + stopper.join(timeout=90) + if stopper.is_alive(): + pytest.fail("coordinator.stop() hung") + + benchmark.pedantic(build_and_drain, teardown=teardown, rounds=1, warmup_rounds=0) + finally: + global_config.update(**saved) diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 9f57380290..ffba95e1e9 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -18,7 +18,10 @@ dimos [GLOBAL OPTIONS] COMMAND [ARGS] | `--robot-ips` | TEXT | `None` | Multiple robot IPs | | `--simulation` / `--no-simulation` | bool | `False` | Enable MuJoCo simulation | | `--replay` / `--no-replay` | bool | `False` | Use recorded replay data | -| `--replay-db` | TEXT | `go2_bigoffice` | Replay memory2 SQLite database name | +| `--replay-db` | TEXT | `go2_short` | Replay memory2 SQLite database name | +| `--replay-speed` | FLOAT | `1.0` | Replay playback rate multiplier (large values drain as fast as the pipeline allows) | +| `--replay-seek` | FLOAT | `None` | Seconds into the recording to start replay at | +| `--replay-duration` | FLOAT | `None` | Seconds of recording to play before the streams complete | | `--new-memory` / `--no-new-memory` | bool | `False` | Clear persistent memory on start | | `--viewer` | `rerun\|none` | `rerun` | Visualization backend | | `--rerun-open` | `native\|web\|both\|none` | `native` | How to open the Rerun viewer | diff --git a/pyproject.toml b/pyproject.toml index 6441ea3202..48c5bbe7f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -408,6 +408,7 @@ tests = [ "pytest-cov>=5.0", "pytest-error-for-skips>=2.0.2", "pytest-rerunfailures>=15.0", + "pytest-codspeed==5.0.3", "coverage>=7.0", "requests-mock==1.12.1", diff --git a/uv.lock b/uv.lock index 799831a286..e8ad0d815f 100644 --- a/uv.lock +++ b/uv.lock @@ -1956,6 +1956,7 @@ tests = [ { name = "pygame" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-codspeed" }, { name = "pytest-cov" }, { name = "pytest-env" }, { name = "pytest-error-for-skips" }, @@ -2005,6 +2006,7 @@ tests-self-hosted = [ { name = "pygame" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-codspeed" }, { name = "pytest-cov" }, { name = "pytest-env" }, { name = "pytest-error-for-skips" }, @@ -2248,6 +2250,7 @@ tests = [ { name = "pygame", specifier = ">=2.6.1" }, { name = "pytest", specifier = "==8.3.5" }, { name = "pytest-asyncio", specifier = "==0.26.0" }, + { name = "pytest-codspeed", specifier = "==5.0.3" }, { name = "pytest-cov", specifier = ">=5.0" }, { name = "pytest-env", specifier = "==1.1.5" }, { name = "pytest-error-for-skips", specifier = ">=2.0.2" }, @@ -2299,6 +2302,7 @@ tests-self-hosted = [ { name = "pygame", specifier = ">=2.6.1" }, { name = "pytest", specifier = "==8.3.5" }, { name = "pytest-asyncio", specifier = "==0.26.0" }, + { name = "pytest-codspeed", specifier = "==5.0.3" }, { name = "pytest-cov", specifier = ">=5.0" }, { name = "pytest-env", specifier = "==1.1.5" }, { name = "pytest-error-for-skips", specifier = ">=2.0.2" }, @@ -7303,6 +7307,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" }, ] +[[package]] +name = "pytest-codspeed" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "pytest" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/b4/cf932fcd1960a2fd6d9b09eb403253a8709aeee975961afa6299239a830e/pytest_codspeed-5.0.3.tar.gz", hash = "sha256:91afef90e6a96b013495e4702ef5d6358614a449e71008cdc194ef668778b92f", size = 324571, upload-time = "2026-05-22T16:20:49.231Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/f5/a8f70147216e4b84046ca406d03ecc8e83e3ea56ba1bdca0bb79cca79fee/pytest_codspeed-5.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:005348ea52ace3ede2e2f595913912ad2564cca7b124211a88dc78a9cb1fca63", size = 366249, upload-time = "2026-05-22T16:20:39.985Z" }, + { url = "https://files.pythonhosted.org/packages/f6/bd/7a4dbcf457fcc3ed788c55d402f3af2671e0e342b6098090fd590aa8712e/pytest_codspeed-5.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbe6a4a00b449b6ba2771f644cbc38bdf55acf5c812e60e5659110e19dd9f510", size = 932229, upload-time = "2026-05-22T16:20:37.283Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/414ea4c66559f24ec06aeb6db62bfc7079582dac1452e648affe1eb5cfb4/pytest_codspeed-5.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac4344f34bbcdd17f6f8c30dbac3da2f80d223dd112e568fd7f7c2cd4cbc693", size = 934647, upload-time = "2026-05-22T16:20:31.997Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ef/32ce60d42a4aa43e728d988e13eb6568fbc7b10a514517b459bafd3f2b94/pytest_codspeed-5.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f56d0339cd98d26f6e561987be25bdd2761a5d53d8f73493b1ebe02d0d451093", size = 366253, upload-time = "2026-05-22T16:21:10.013Z" }, + { url = "https://files.pythonhosted.org/packages/2a/15/c66ef90a793c5d2c039e63a1726a5e55c678be2618b0f5f1660d0f79e25f/pytest_codspeed-5.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c682f6645d4eb472f3bd95dbda1805e3af4243610572cb7d6bf94a88e8a0b6c", size = 932465, upload-time = "2026-05-22T16:20:34.265Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7b/d231279301967f05b7909160489e85ee3a1b9da76094ea25343faba1abc2/pytest_codspeed-5.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f852bee785a7a124cb1720b1915670c6742af87747dc4d838f3ffdbd365ce9d9", size = 934925, upload-time = "2026-05-22T16:20:47.63Z" }, + { url = "https://files.pythonhosted.org/packages/c2/22/456c48160b761d5028c8afa119f085a9fc42855a783a13d73918078969f0/pytest_codspeed-5.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2eeb25fb1ac3f73c4de50e739e78fea396b89782bdb740bf2a7cd2df21f8d4ee", size = 366255, upload-time = "2026-05-22T16:20:56.214Z" }, + { url = "https://files.pythonhosted.org/packages/74/33/ac7441fa937c9d9f158083a8c46920a5a5c81ed3c5f96240fc8d650db5c2/pytest_codspeed-5.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73c5c9d98a3372a42611989ccfa437cce3842431ac6d6b9ab42c4f0e59c070f7", size = 932325, upload-time = "2026-05-22T16:21:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/77/bc/8b994adcb9e9016e7d9a808056a3dd9cca21441e432ef456eae2b697d7fe/pytest_codspeed-5.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2e0ab65df73e837666d12357280ca50ff6d6ac03ea5266703be518b68170edf", size = 934885, upload-time = "2026-05-22T16:21:01.444Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b2/1d2a993c532146dce9eca5b5942d51898021c3579ce18b2454f932a915f8/pytest_codspeed-5.0.3-py3-none-any.whl", hash = "sha256:fe2ea83c924c2250675b75686c3ee456b8cf0208d83d552e182a195fdf467378", size = 74033, upload-time = "2026-05-22T16:20:26.814Z" }, +] + [[package]] name = "pytest-cov" version = "7.1.0" From badf42c950ea06b4f4a972566b1c7447072d09b3 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Tue, 4 Aug 2026 17:06:54 +0100 Subject: [PATCH 02/23] Fix --- .github/workflows/codspeed.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 2b1c1404dd..7f26e0d250 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -52,10 +52,11 @@ jobs: uses: astral-sh/setup-uv@v8.3.2 with: enable-cache: true + # uv-managed (python-build-standalone) rather than setup-python: it + # statically bundles a modern sqlite (>=3.45), which reading the replay + # DB's JSONB tags requires; setup-python links the runner's libsqlite3. - name: Setup Python - uses: actions/setup-python@v6.3.0 - with: - python-version: '3.12' + run: uv python install 3.12 - name: Install dependencies run: uv sync --group tests --frozen - name: Pre-extract the replay database From 11a09b11b154a2b6f328dbf002ee12fa73886919 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Tue, 4 Aug 2026 20:11:05 +0100 Subject: [PATCH 03/23] Fix --- .github/workflows/codspeed.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 7f26e0d250..fe3407b8ea 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -42,10 +42,12 @@ jobs: else echo "archive restored from cache" fi + # portaudio/turbojpeg mirror the tests job; libgl1/libglib2.0-0 are for + # open3d (preinstalled on GitHub-hosted images, not necessarily here). - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y portaudio19-dev libturbojpeg + sudo apt-get install -y portaudio19-dev libturbojpeg libgl1 libglib2.0-0 - name: Raise UDP receive buffers for LCM run: sudo sysctl -w net.core.rmem_max=67108864 net.core.rmem_default=67108864 - name: Install uv From 82a52d145c9a3ea741a9550014bd57f48fccf584 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Tue, 4 Aug 2026 20:19:21 +0100 Subject: [PATCH 04/23] Fix --- .github/workflows/codspeed.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index fe3407b8ea..e346806c9d 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -42,12 +42,13 @@ jobs: else echo "archive restored from cache" fi - # portaudio/turbojpeg mirror the tests job; libgl1/libglib2.0-0 are for - # open3d (preinstalled on GitHub-hosted images, not necessarily here). + # portaudio/turbojpeg mirror the tests job; libgl1/libglib2.0-0 (open3d) + # and libgfortran5 (scipy) are preinstalled on GitHub-hosted images but + # not necessarily on this runner. - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y portaudio19-dev libturbojpeg libgl1 libglib2.0-0 + sudo apt-get install -y portaudio19-dev libturbojpeg libgl1 libglib2.0-0 libgfortran5 - name: Raise UDP receive buffers for LCM run: sudo sysctl -w net.core.rmem_max=67108864 net.core.rmem_default=67108864 - name: Install uv @@ -65,6 +66,11 @@ jobs: run: uv run python -c "from dimos.utils.data import get_data; print(get_data('go2_hongkong_office.db'))" - name: Run benchmarks uses: CodSpeedHQ/action@v5 + env: + # The benchmark process dies with SIGILL (exit 132) under the default + # samply-based walltime profiler on this ARM64 runner; the same stack + # runs clean on linux-aarch64 without it. Drop once samply behaves. + CODSPEED_WALLTIME_PROFILER: perf with: mode: walltime run: uv run pytest --codspeed -m '' --no-cov dimos/robot/unitree/go2/test_replay_benchmark.py From 92ca3fa2616f425a6bf803c7b280dc138535a667 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Wed, 5 Aug 2026 15:08:04 +0100 Subject: [PATCH 05/23] Fix --- .github/workflows/codspeed.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index e346806c9d..4e2c34dd9c 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -13,6 +13,9 @@ permissions: {} env: UV_NO_SYNC: "1" + # Dump the Python stack if the benchmark process dies on a signal (it + # currently exits 132/SIGILL on this runner with no traceback). + PYTHONFAULTHANDLER: "1" jobs: benchmarks: @@ -23,6 +26,12 @@ jobs: contents: read # For checkout id-token: write # OIDC upload to CodSpeed steps: + # Temporary diagnostics: the SIGILL below may be an ARMv8.2-compiled + # wheel (torch et al.) on older silicon — this names the CPU. + - name: Show runner hardware + run: | + uname -a + head -30 /proc/cpuinfo - name: Checkout uses: actions/checkout@v7 # After checkout (lfs: false) the archive is a ~130-byte LFS pointer; From 8c41c8799e091383c41e0f94692cf1692abd32bf Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Wed, 5 Aug 2026 15:51:54 +0100 Subject: [PATCH 06/23] Fix --- .github/workflows/codspeed.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 4e2c34dd9c..f8a3e9539d 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -27,11 +27,13 @@ jobs: id-token: write # OIDC upload to CodSpeed steps: # Temporary diagnostics: the SIGILL below may be an ARMv8.2-compiled - # wheel (torch et al.) on older silicon — this names the CPU. + # wheel (torch et al.) on older or feature-asymmetric silicon — this + # names the CPU and per-core features. - name: Show runner hardware run: | uname -a - head -30 /proc/cpuinfo + nproc + cat /proc/cpuinfo - name: Checkout uses: actions/checkout@v7 # After checkout (lfs: false) the archive is a ~130-byte LFS pointer; @@ -73,6 +75,13 @@ jobs: run: uv sync --group tests --frozen - name: Pre-extract the replay database run: uv run python -c "from dimos.utils.data import get_data; print(get_data('go2_hongkong_office.db'))" + # Temporary diagnostics: the same pytest command, same machine, but + # without the codspeed runner's isolation/profiler wrapping (short + # window to keep it cheap). SIGILL here => our stack on this silicon + # (faulthandler prints the stack); pass here => runner-context problem. + - name: Smoke the benchmark without the codspeed runner + run: env DIMOS_BENCH_DURATION=10 uv run pytest --codspeed -m '' --no-cov dimos/robot/unitree/go2/test_replay_benchmark.py + - name: Run benchmarks uses: CodSpeedHQ/action@v5 env: From 8189b7bc6646b7a0c2e4d44ca98a89cca8fd395b Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Wed, 5 Aug 2026 16:02:37 +0100 Subject: [PATCH 07/23] Fix --- .github/workflows/codspeed.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index f8a3e9539d..2dfea8f4d9 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -75,10 +75,18 @@ jobs: run: uv sync --group tests --frozen - name: Pre-extract the replay database run: uv run python -c "from dimos.utils.data import get_data; print(get_data('go2_hongkong_office.db'))" - # Temporary diagnostics: the same pytest command, same machine, but - # without the codspeed runner's isolation/profiler wrapping (short - # window to keep it cheap). SIGILL here => our stack on this silicon - # (faulthandler prints the stack); pass here => runner-context problem. + # Temporary diagnostics: the plain (runner-less) smoke SIGILLed too, so a + # native wheel dies on this silicon. Import each in a fresh interpreter; + # the one that exits 132 is the culprit (the loop survives it). + - name: Bisect native imports + run: | + for m in numpy scipy cv2 sqlite_vec lcm zenoh rerun open3d torch onnxruntime; do + echo "=== import $m" + uv run python -X faulthandler -c "import $m; print('$m ok')" || echo "=== $m FAILED with $?" + done + - name: Probe the sqlite read path + run: uv run python -X faulthandler -c "import os; os.environ['DIMOS_BENCH_DURATION']='10'; from dimos.robot.unitree.go2.test_replay_benchmark import _expected_counts; print(_expected_counts('data/go2_hongkong_office.db'))" + # If both probes pass, re-check the full path without the runner wrap. - name: Smoke the benchmark without the codspeed runner run: env DIMOS_BENCH_DURATION=10 uv run pytest --codspeed -m '' --no-cov dimos/robot/unitree/go2/test_replay_benchmark.py From 628aeb61b22520012d211032fe549dc8910cf0b4 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Wed, 5 Aug 2026 16:03:37 +0100 Subject: [PATCH 08/23] Fix --- .github/workflows/codspeed.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 2dfea8f4d9..78a026cd5a 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -33,6 +33,7 @@ jobs: run: | uname -a nproc + free -h cat /proc/cpuinfo - name: Checkout uses: actions/checkout@v7 @@ -90,6 +91,13 @@ jobs: - name: Smoke the benchmark without the codspeed runner run: env DIMOS_BENCH_DURATION=10 uv run pytest --codspeed -m '' --no-cov dimos/robot/unitree/go2/test_replay_benchmark.py + # The kernel names the killer authoritatively: OOM kills log + # "Out of memory: Killed process", SIGILL logs an undefined-instruction + # fault with the offending library mapping. + - name: Kernel log tail + if: always() + run: sudo dmesg | tail -60 || true + - name: Run benchmarks uses: CodSpeedHQ/action@v5 env: From ed607ed516006a5c07644ddfa6866dcf16c07784 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Thu, 6 Aug 2026 18:22:02 +0100 Subject: [PATCH 09/23] Fix --- .github/workflows/codspeed.yml | 131 +++++++++++++++++---------------- 1 file changed, 67 insertions(+), 64 deletions(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 78a026cd5a..cc6642e8a1 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -13,98 +13,101 @@ permissions: {} env: UV_NO_SYNC: "1" - # Dump the Python stack if the benchmark process dies on a signal (it - # currently exits 132/SIGILL on this runner with no traceback). + # Walltime benchmarks die without a traceback otherwise (a native-lib crash + # on the CodSpeed macro fleet cost several blind CI cycles to diagnose). PYTHONFAULTHANDLER: "1" jobs: - benchmarks: - timeout-minutes: 30 - # CodSpeed-managed bare-metal ARM64 runner; required for walltime mode. - runs-on: codspeed-macro + compute-ros-pin: + # Same as ci.yml: extracts the ros-dev image digest pinned in + # docker/ros-dev-pin/Dockerfile for the `container:` field below. + runs-on: ubuntu-latest permissions: contents: read # For checkout - id-token: write # OIDC upload to CodSpeed + outputs: + digest: ${{ steps.read.outputs.digest }} steps: - # Temporary diagnostics: the SIGILL below may be an ARMv8.2-compiled - # wheel (torch et al.) on older or feature-asymmetric silicon — this - # names the CPU and per-core features. - - name: Show runner hardware + - name: Checkout + uses: actions/checkout@v7 + - id: read run: | - uname -a - nproc - free -h - cat /proc/cpuinfo + DIGEST=$(grep -oE 'sha256:[a-f0-9]{64}' docker/ros-dev-pin/Dockerfile) + if [ -z "$DIGEST" ]; then + echo "::error::No sha256 digest found in docker/ros-dev-pin/Dockerfile" + exit 1 + fi + echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" + + benchmarks: + # Same-repo PRs and pushes only: fork PRs must not run on the self-hosted + # runner (mirrors the self-hosted-tests gate in ci.yml). Fork PRs simply + # get no benchmark comparison. + # Walltime on our own runner rather than `codspeed-macro`: the CodSpeed + # macro fleet is AWS a1.metal (Graviton1, ARMv8.0) where ARMv8.2-compiled + # wheels (torch et al.) SIGILL at import. One job per runner agent means + # the benchmark has the machine to itself while it runs. + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + needs: compute-ros-pin + timeout-minutes: 30 + runs-on: + - self-hosted + - Linux + - base + container: + image: ghcr.io/dimensionalos/ros-dev@${{ needs.compute-ros-pin.outputs.digest }} + # Fleet-standard cap (base runners have 8 GB). If the 10-worker + # blueprint doesn't fit, the run dies with a contained cgroup OOM + # (exit 137) and this job moves to the `large` host-mode runner. + options: --memory=6g --memory-swap=6g + volumes: + - /var/cache/dimos-nix:/nix + - /var/cache/dimos-root-cache:/root/.cache + permissions: + contents: read + id-token: write # OIDC upload to CodSpeed (tokenless for public repos) + steps: - name: Checkout uses: actions/checkout@v7 - # After checkout (lfs: false) the archive is a ~130-byte LFS pointer; - # hashFiles() of the pointer is a stable content-addressed key that - # changes exactly when the recording changes (arch-independent asset). - - name: Cache the replay dataset archive - uses: actions/cache@v6 - with: - path: data/.lfs/go2_hongkong_office.db.tar.gz - key: lfs-go2-hongkong-office-${{ hashFiles('data/.lfs/go2_hongkong_office.db.tar.gz') }} - # Self-healing whether or not the cache restored anything: pull only when - # the file is still a pointer. Anonymous read from the .lfsconfig endpoint. + + # The runner's workspace .git/lfs cache persists between runs, so this + # is a local re-smudge after the first pull. Anonymous read from the + # .lfsconfig endpoint. - name: Fetch the replay dataset from LFS if not cached run: | if git lfs pointer --check --file data/.lfs/go2_hongkong_office.db.tar.gz; then git lfs pull --include="data/.lfs/go2_hongkong_office.db.tar.gz" --exclude="" else - echo "archive restored from cache" + echo "archive already smudged" fi - # portaudio/turbojpeg mirror the tests job; libgl1/libglib2.0-0 (open3d) - # and libgfortran5 (scipy) are preinstalled on GitHub-hosted images but - # not necessarily on this runner. - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y portaudio19-dev libturbojpeg libgl1 libglib2.0-0 libgfortran5 - - name: Raise UDP receive buffers for LCM - run: sudo sysctl -w net.core.rmem_max=67108864 net.core.rmem_default=67108864 + - name: Install uv uses: astral-sh/setup-uv@v8.3.2 with: - enable-cache: true - # uv-managed (python-build-standalone) rather than setup-python: it - # statically bundles a modern sqlite (>=3.45), which reading the replay - # DB's JSONB tags requires; setup-python links the runner's libsqlite3. + # /root/.cache is a persisted volume; the GH cache service would be + # slower than local disk. + enable-cache: false + # uv-managed (python-build-standalone) rather than the container python: + # it statically bundles a modern sqlite (>=3.45), which reading the + # replay DB's JSONB tags requires; jammy's libsqlite3 is 3.37. - name: Setup Python run: uv python install 3.12 - name: Install dependencies run: uv sync --group tests --frozen + + # Keep the ~2.5 GB extraction (and any download failure) out of the + # measured benchmark process. - name: Pre-extract the replay database run: uv run python -c "from dimos.utils.data import get_data; print(get_data('go2_hongkong_office.db'))" - # Temporary diagnostics: the plain (runner-less) smoke SIGILLed too, so a - # native wheel dies on this silicon. Import each in a fresh interpreter; - # the one that exits 132 is the culprit (the loop survives it). - - name: Bisect native imports - run: | - for m in numpy scipy cv2 sqlite_vec lcm zenoh rerun open3d torch onnxruntime; do - echo "=== import $m" - uv run python -X faulthandler -c "import $m; print('$m ok')" || echo "=== $m FAILED with $?" - done - - name: Probe the sqlite read path - run: uv run python -X faulthandler -c "import os; os.environ['DIMOS_BENCH_DURATION']='10'; from dimos.robot.unitree.go2.test_replay_benchmark import _expected_counts; print(_expected_counts('data/go2_hongkong_office.db'))" - # If both probes pass, re-check the full path without the runner wrap. - - name: Smoke the benchmark without the codspeed runner - run: env DIMOS_BENCH_DURATION=10 uv run pytest --codspeed -m '' --no-cov dimos/robot/unitree/go2/test_replay_benchmark.py - - # The kernel names the killer authoritatively: OOM kills log - # "Out of memory: Killed process", SIGILL logs an undefined-instruction - # fault with the offending library mapping. - - name: Kernel log tail - if: always() - run: sudo dmesg | tail -60 || true - name: Run benchmarks uses: CodSpeedHQ/action@v5 env: - # The benchmark process dies with SIGILL (exit 132) under the default - # samply-based walltime profiler on this ARM64 runner; the same stack - # runs clean on linux-aarch64 without it. Drop once samply behaves. - CODSPEED_WALLTIME_PROFILER: perf + # In this rootful container the runner would otherwise autodetect + # elevation and wrap the benchmark in systemd-run, which does not + # exist here. No cpuset hooks either, so run unisolated — the job + # already has the machine to itself. + CODSPEED_ISOLATION: "false" with: mode: walltime + # -m '' overrides the addopts deselection of self_hosted-marked tests. run: uv run pytest --codspeed -m '' --no-cov dimos/robot/unitree/go2/test_replay_benchmark.py From 3ef4edb56199bee1ca59acc598c1aed4192e8c49 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Fri, 7 Aug 2026 16:31:58 +0100 Subject: [PATCH 10/23] Fix --- .github/workflows/codspeed.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index cc6642e8a1..eb1d1e9859 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -107,6 +107,12 @@ jobs: # exist here. No cpuset hooks either, so run unisolated — the job # already has the machine to itself. CODSPEED_ISOLATION: "false" + # The runner hosts set kernel.perf_event_paranoid=4, so samply can't + # start in the container and the runner treats that as fatal. The + # profiler only adds flamegraphs; walltime measurement is unaffected. + # To get flamegraphs back: paranoid<=1 on the hosts (or CAP_PERFMON + # on the container) and drop this. + CODSPEED_PROFILER_ENABLED: "false" with: mode: walltime # -m '' overrides the addopts deselection of self_hosted-marked tests. From 5c03ee6b717e724f6a78b913738c79428f239cfe Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Fri, 7 Aug 2026 17:48:10 +0100 Subject: [PATCH 11/23] Update codspeed.yml --- .github/workflows/codspeed.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index cc6642e8a1..d5d843b615 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -107,6 +107,7 @@ jobs: # exist here. No cpuset hooks either, so run unisolated — the job # already has the machine to itself. CODSPEED_ISOLATION: "false" + CODSPEED_PROFILER_ENABLED: "false" with: mode: walltime # -m '' overrides the addopts deselection of self_hosted-marked tests. From d63aafba62963b3dbf4c36636c48b81527766a1c Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Mon, 10 Aug 2026 14:31:26 +0100 Subject: [PATCH 12/23] Fix --- .github/workflows/codspeed.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index eb1d1e9859..622369cd9b 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -80,6 +80,14 @@ jobs: echo "archive already smudged" fi + # The ros-dev image ships without libturbojpeg (no self-hosted test used + # it before); the replay pipeline needs it to decode the recording's + # JPEG frames in GO2Connection. + - name: Install system dependencies + run: | + apt-get update + apt-get install -y libturbojpeg + - name: Install uv uses: astral-sh/setup-uv@v8.3.2 with: From 227bd81af9cdd16da16fd8cddc4a26172a384c3a Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Mon, 10 Aug 2026 15:07:07 +0100 Subject: [PATCH 13/23] Fix --- .github/workflows/codspeed.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 26db7dc6ca..ddd737120c 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -74,6 +74,11 @@ jobs: # .lfsconfig endpoint. - name: Fetch the replay dataset from LFS if not cached run: | + # The host-owned workspace trips git's dubious-ownership refusal for + # the container's root user, and git-lfs then reports it as not being + # in a repository. Global config, so get_data's own `git lfs pull` + # in the pre-extract step is covered too. + git config --global --add safe.directory "$GITHUB_WORKSPACE" if git lfs pointer --check --file data/.lfs/go2_hongkong_office.db.tar.gz; then git lfs pull --include="data/.lfs/go2_hongkong_office.db.tar.gz" --exclude="" else From fec1a351ef295665807d59504362044af2a1cff4 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Mon, 10 Aug 2026 16:18:53 +0100 Subject: [PATCH 14/23] Fix --- .../robot/unitree/go2/test_replay_benchmark.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/dimos/robot/unitree/go2/test_replay_benchmark.py b/dimos/robot/unitree/go2/test_replay_benchmark.py index 729dae6a59..44c52c59ec 100644 --- a/dimos/robot/unitree/go2/test_replay_benchmark.py +++ b/dimos/robot/unitree/go2/test_replay_benchmark.py @@ -53,7 +53,6 @@ from dimos.core.global_config import global_config from dimos.core.transport_factory import make_transport from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped -from dimos.msgs.sensor_msgs.Image import Image from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 if TYPE_CHECKING: @@ -64,12 +63,17 @@ SPEED = 1000.0 # every emission delay clamps to 0: a pure CPU-bound drain QUIET_S = 3.0 # all watched streams silent this long => drained DRAIN_TIMEOUT = 420.0 # build+drain deadline, inside the test timeout -# GO2Connection source streams. camera_info is deliberately absent: it is -# published by a 1 Hz forever-loop thread and never quiesces. -WATCHED = (("odom", PoseStamped), ("lidar", PointCloud2), ("color_image", Image)) -# Validity gates, not the timing edge: odom is small and near-lossless; the -# heavy latest-wins streams legitimately drop under the flood. -FLOOR_FRACTION = {"odom": 0.9, "lidar": 0.5, "color_image": 0.5} +# GO2Connection source streams. camera_info is deliberately absent (published +# by a 1 Hz forever-loop thread — never quiesces). color_image is too: raw +# decoded Image frames (~MBs each) at max replay speed are a multi-GB/s LCM +# fragment flood of which essentially nothing survives to an observer, and the +# video path is already validity-checked synchronously — the first frame +# decodes inside GO2Connection.start(), so a broken codec fails build() +# loudly (see the missing-libturbojpeg incident). +WATCHED = (("odom", PoseStamped), ("lidar", PointCloud2)) +# Validity gates, not the timing edge: odom is small and near-lossless; lidar +# frames are compact enough to survive the flood with the 64MB rmem tuning. +FLOOR_FRACTION = {"odom": 0.9, "lidar": 0.5} def _expected_counts(db_path: str) -> dict[str, int]: From 8fcddca1ffd1190ce36a97409dda2b9e03b6ec08 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Mon, 10 Aug 2026 17:19:00 +0100 Subject: [PATCH 15/23] Fix --- dimos/robot/unitree/go2/test_replay_benchmark.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dimos/robot/unitree/go2/test_replay_benchmark.py b/dimos/robot/unitree/go2/test_replay_benchmark.py index 44c52c59ec..494ade5878 100644 --- a/dimos/robot/unitree/go2/test_replay_benchmark.py +++ b/dimos/robot/unitree/go2/test_replay_benchmark.py @@ -131,7 +131,7 @@ def test_go2_replay_drain_walltime(benchmark: BenchmarkFixture) -> None: db_path = str(resolve_db_path(REPLAY_DB)) # LFS pull/extract on miss expected = _expected_counts(db_path) assert all(count > 0 for count in expected.values()), f"empty window: {expected}" - floors = {name: int(count * FLOOR_FRACTION[name]) for name, count in expected.items()} + floors = {name: int(expected[name] * fraction) for name, fraction in FLOOR_FRACTION.items()} blueprint = get_blueprint_by_name("unitree-go2") From 4374d1e0dcb7dc7562800b6dba6de87d2a30fcf9 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Tue, 11 Aug 2026 17:18:00 +0100 Subject: [PATCH 16/23] Apply suggestions from code review Co-authored-by: Sam Bull --- .github/workflows/codspeed.yml | 35 ++++--------------- .../unitree/go2/test_replay_benchmark.py | 6 ++-- 2 files changed, 8 insertions(+), 33 deletions(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index ddd737120c..87fd31d69e 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -13,14 +13,12 @@ permissions: {} env: UV_NO_SYNC: "1" - # Walltime benchmarks die without a traceback otherwise (a native-lib crash - # on the CodSpeed macro fleet cost several blind CI cycles to diagnose). + # Walltime benchmarks die without a traceback otherwise. PYTHONFAULTHANDLER: "1" jobs: compute-ros-pin: - # Same as ci.yml: extracts the ros-dev image digest pinned in - # docker/ros-dev-pin/Dockerfile for the `container:` field below. + # Same as ci.yml. runs-on: ubuntu-latest permissions: contents: read # For checkout @@ -40,8 +38,7 @@ jobs: benchmarks: # Same-repo PRs and pushes only: fork PRs must not run on the self-hosted - # runner (mirrors the self-hosted-tests gate in ci.yml). Fork PRs simply - # get no benchmark comparison. + # runner (mirrors the self-hosted-tests gate in ci.yml). # Walltime on our own runner rather than `codspeed-macro`: the CodSpeed # macro fleet is AWS a1.metal (Graviton1, ARMv8.0) where ARMv8.2-compiled # wheels (torch et al.) SIGILL at import. One job per runner agent means @@ -49,15 +46,13 @@ jobs: if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository needs: compute-ros-pin timeout-minutes: 30 + # codspeed-macro doesn't support ARMv8.2+ needed for ML dependencies. runs-on: - self-hosted - Linux - base container: image: ghcr.io/dimensionalos/ros-dev@${{ needs.compute-ros-pin.outputs.digest }} - # Fleet-standard cap (base runners have 8 GB). If the 10-worker - # blueprint doesn't fit, the run dies with a contained cgroup OOM - # (exit 137) and this job moves to the `large` host-mode runner. options: --memory=6g --memory-swap=6g volumes: - /var/cache/dimos-nix:/nix @@ -68,26 +63,14 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 - - # The runner's workspace .git/lfs cache persists between runs, so this - # is a local re-smudge after the first pull. Anonymous read from the - # .lfsconfig endpoint. - name: Fetch the replay dataset from LFS if not cached run: | - # The host-owned workspace trips git's dubious-ownership refusal for - # the container's root user, and git-lfs then reports it as not being - # in a repository. Global config, so get_data's own `git lfs pull` - # in the pre-extract step is covered too. git config --global --add safe.directory "$GITHUB_WORKSPACE" if git lfs pointer --check --file data/.lfs/go2_hongkong_office.db.tar.gz; then git lfs pull --include="data/.lfs/go2_hongkong_office.db.tar.gz" --exclude="" else echo "archive already smudged" fi - - # The ros-dev image ships without libturbojpeg (no self-hosted test used - # it before); the replay pipeline needs it to decode the recording's - # JPEG frames in GO2Connection. - name: Install system dependencies run: | apt-get update @@ -96,8 +79,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v8.3.2 with: - # /root/.cache is a persisted volume; the GH cache service would be - # slower than local disk. + # /root/.cache is a persisted volume enable-cache: false # uv-managed (python-build-standalone) rather than the container python: # it statically bundles a modern sqlite (>=3.45), which reading the @@ -106,12 +88,8 @@ jobs: run: uv python install 3.12 - name: Install dependencies run: uv sync --group tests --frozen - - # Keep the ~2.5 GB extraction (and any download failure) out of the - # measured benchmark process. - name: Pre-extract the replay database run: uv run python -c "from dimos.utils.data import get_data; print(get_data('go2_hongkong_office.db'))" - - name: Run benchmarks uses: CodSpeedHQ/action@v5 env: @@ -123,5 +101,4 @@ jobs: CODSPEED_PROFILER_ENABLED: "false" with: mode: walltime - # -m '' overrides the addopts deselection of self_hosted-marked tests. - run: uv run pytest --codspeed -m '' --no-cov dimos/robot/unitree/go2/test_replay_benchmark.py + run: uv run pytest --codspeed -m '' --no-cov diff --git a/dimos/robot/unitree/go2/test_replay_benchmark.py b/dimos/robot/unitree/go2/test_replay_benchmark.py index 494ade5878..60ee332df5 100644 --- a/dimos/robot/unitree/go2/test_replay_benchmark.py +++ b/dimos/robot/unitree/go2/test_replay_benchmark.py @@ -41,8 +41,6 @@ -m self_hosted --no-cov -v """ -from __future__ import annotations - import os import threading import time @@ -59,8 +57,8 @@ from pytest_codspeed import BenchmarkFixture REPLAY_DB = os.environ.get("DIMOS_BENCH_REPLAY_DB", "go2_hongkong_office") -DURATION = float(os.environ.get("DIMOS_BENCH_DURATION", "60")) -SPEED = 1000.0 # every emission delay clamps to 0: a pure CPU-bound drain +DURATION = float(os.environ.get("DIMOS_BENCH_DURATION", 60)) +SPEED = 1000.0 QUIET_S = 3.0 # all watched streams silent this long => drained DRAIN_TIMEOUT = 420.0 # build+drain deadline, inside the test timeout # GO2Connection source streams. camera_info is deliberately absent (published From 1b706d53f71cd1ce5c2eca7777d9acbf46aabc6a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:18:24 +0000 Subject: [PATCH 17/23] [autofix.ci] apply automated fixes --- dimos/robot/unitree/go2/test_replay_benchmark.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/dimos/robot/unitree/go2/test_replay_benchmark.py b/dimos/robot/unitree/go2/test_replay_benchmark.py index 60ee332df5..d04f2e8404 100644 --- a/dimos/robot/unitree/go2/test_replay_benchmark.py +++ b/dimos/robot/unitree/go2/test_replay_benchmark.py @@ -44,18 +44,15 @@ import os import threading import time -from typing import TYPE_CHECKING import pytest +from pytest_codspeed import BenchmarkFixture from dimos.core.global_config import global_config from dimos.core.transport_factory import make_transport from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 -if TYPE_CHECKING: - from pytest_codspeed import BenchmarkFixture - REPLAY_DB = os.environ.get("DIMOS_BENCH_REPLAY_DB", "go2_hongkong_office") DURATION = float(os.environ.get("DIMOS_BENCH_DURATION", 60)) SPEED = 1000.0 From 8fcece431f944c75d276f12aaec490180f3772b5 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Tue, 11 Aug 2026 17:18:52 +0100 Subject: [PATCH 18/23] Update .github/workflows/codspeed.yml --- .github/workflows/codspeed.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 87fd31d69e..e186c369e2 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -39,10 +39,6 @@ jobs: benchmarks: # Same-repo PRs and pushes only: fork PRs must not run on the self-hosted # runner (mirrors the self-hosted-tests gate in ci.yml). - # Walltime on our own runner rather than `codspeed-macro`: the CodSpeed - # macro fleet is AWS a1.metal (Graviton1, ARMv8.0) where ARMv8.2-compiled - # wheels (torch et al.) SIGILL at import. One job per runner agent means - # the benchmark has the machine to itself while it runs. if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository needs: compute-ros-pin timeout-minutes: 30 From 45db7f8a874cbf375eefb6b25963d0d2670fbf90 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Tue, 11 Aug 2026 17:19:08 +0100 Subject: [PATCH 19/23] Update .github/workflows/codspeed.yml --- .github/workflows/codspeed.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index e186c369e2..c34e5adc5a 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -71,7 +71,6 @@ jobs: run: | apt-get update apt-get install -y libturbojpeg - - name: Install uv uses: astral-sh/setup-uv@v8.3.2 with: From 9da3d05dde29c11f8baac25711c2aac899a62914 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Tue, 11 Aug 2026 17:19:23 +0100 Subject: [PATCH 20/23] Update .github/workflows/codspeed.yml --- .github/workflows/codspeed.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index c34e5adc5a..ebaf49cb58 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -76,9 +76,8 @@ jobs: with: # /root/.cache is a persisted volume enable-cache: false - # uv-managed (python-build-standalone) rather than the container python: - # it statically bundles a modern sqlite (>=3.45), which reading the - # replay DB's JSONB tags requires; jammy's libsqlite3 is 3.37. + # uv-managed rather than the container's python for sqlite (>=3.45). + # TODO: Can be dropped after upgrading to 26.04. - name: Setup Python run: uv python install 3.12 - name: Install dependencies From 5ebb0137a10f012d5ba1bfc7d6a92690c3b73bf6 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Tue, 11 Aug 2026 21:53:16 +0100 Subject: [PATCH 21/23] Single-process simulation --- .github/workflows/codspeed.yml | 37 +++- .../unitree/go2/test_replay_benchmark.py | 4 +- .../go2/test_replay_pipeline_benchmark.py | 181 ++++++++++++++++++ 3 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 dimos/robot/unitree/go2/test_replay_pipeline_benchmark.py diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index ebaf49cb58..91d43bc5c6 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -95,4 +95,39 @@ jobs: CODSPEED_PROFILER_ENABLED: "false" with: mode: walltime - run: uv run pytest --codspeed -m '' --no-cov + run: uv run pytest --codspeed -m '' --no-cov dimos/robot/unitree/go2/test_replay_benchmark.py + + simulation: + # Single-process instruction-count benchmark (Valgrind) of the replay + # compute pipeline; runs on hosted runners — no self-hosted, no workers. + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + timeout-minutes: 45 + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # OIDC upload to CodSpeed + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Fetch the replay dataset + run: git lfs pull --include="data/.lfs/go2_short.db.tar.gz" --exclude="" + # pyaudio build needs portaudio; the recording's JPEG frames need turbojpeg. + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y portaudio19-dev libturbojpeg + - name: Install uv + uses: astral-sh/setup-uv@v8.3.2 + with: + enable-cache: true + - name: Setup Python + run: uv python install 3.12 + - name: Install dependencies + run: uv sync --group tests --frozen + - name: Pre-extract the replay database + run: uv run python -c "from dimos.utils.data import get_data; print(get_data('go2_short.db'))" + - name: Run benchmarks + uses: CodSpeedHQ/action@v5 + with: + mode: simulation + run: uv run pytest --codspeed -m '' --no-cov dimos/robot/unitree/go2/test_replay_pipeline_benchmark.py diff --git a/dimos/robot/unitree/go2/test_replay_benchmark.py b/dimos/robot/unitree/go2/test_replay_benchmark.py index d04f2e8404..b33f5aa04b 100644 --- a/dimos/robot/unitree/go2/test_replay_benchmark.py +++ b/dimos/robot/unitree/go2/test_replay_benchmark.py @@ -71,7 +71,7 @@ FLOOR_FRACTION = {"odom": 0.9, "lidar": 0.5} -def _expected_counts(db_path: str) -> dict[str, int]: +def _expected_counts(db_path: str, duration: float = DURATION) -> dict[str, int]: """Windowed per-stream counts straight from the DB. Mirrors ReplayConnection's stream-name fallback (mid360-era recordings use @@ -82,7 +82,7 @@ def _expected_counts(db_path: str) -> dict[str, int]: store = SqliteStore(path=db_path, must_exist=True) store.start() try: - replay = store.replay(duration=DURATION) + replay = store.replay(duration=duration) available = replay.list_streams() def first_present(*names: str) -> str: diff --git a/dimos/robot/unitree/go2/test_replay_pipeline_benchmark.py b/dimos/robot/unitree/go2/test_replay_pipeline_benchmark.py new file mode 100644 index 0000000000..6d9d5bf451 --- /dev/null +++ b/dimos/robot/unitree/go2/test_replay_pipeline_benchmark.py @@ -0,0 +1,181 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CodSpeed simulation benchmark: the go2 replay compute pipeline, one process. + +The single-process complement to test_replay_benchmark.py. Simulation mode +counts instructions in one process under Valgrind, so this runs the pipeline's +computational core in-process: GO2Connection (replay decode: JPEG frames, +lidar, odom) -> VoxelGridMapper -> CostMapper. The planners/patrol/movement +modules are omitted — with no goals published they are idle in the e2e run +too, so this covers the same work that actually executes there, minus worker +processes and UDP transports. + +Modules are constructed directly (no coordinator, no workers) and wired with +a synchronous in-test LocalTransport shared per topic, so delivery is +lossless and the end condition is exact: the run completes when every frame +of the replay window has been observed. Deterministic enough for instruction +counting; also runs as a plain self_hosted test for coverage. + +Local smoke (small bundled recording): + + DIMOS_SIM_BENCH_REPLAY_DB=go2_short DIMOS_SIM_BENCH_DURATION=5 \ + uv run pytest dimos/robot/unitree/go2/test_replay_pipeline_benchmark.py \ + -m self_hosted --no-cov -v +""" + +from collections.abc import Callable +import os +import threading +import time +from typing import Any + +import pytest +from pytest_codspeed import BenchmarkFixture + +from dimos.core.global_config import global_config +from dimos.core.transport import PubSubTransport +from dimos.robot.unitree.go2.test_replay_benchmark import _expected_counts + +REPLAY_DB = os.environ.get("DIMOS_SIM_BENCH_REPLAY_DB", "go2_short") +DURATION = float(os.environ.get("DIMOS_SIM_BENCH_DURATION", 10)) +SPEED = 1000.0 # every emission delay clamps to 0: a pure CPU-bound drain +DRAIN_TIMEOUT = 420.0 # start+drain deadline, inside the test timeout + + +class LocalTransport(PubSubTransport[Any]): + """Synchronous in-process pub/sub: broadcast() calls subscribers inline. + + Lossless and deterministic — no sockets, no queues, no threads of its + own — which is what makes exact-count completion and instruction + counting under Valgrind meaningful. + """ + + def __init__(self, topic: str) -> None: + super().__init__(topic) + self._subscribers: list[Callable[[Any], Any]] = [] + + def broadcast(self, stream: Any, msg: Any) -> None: + for callback in list(self._subscribers): + callback(msg) + + def subscribe( + self, callback: Callable[[Any], Any], selfstream: Any = None + ) -> Callable[[], None]: + self._subscribers.append(callback) + return lambda: self._subscribers.remove(callback) + + def start(self) -> None: + pass + + def stop(self) -> None: + self._subscribers.clear() + + +@pytest.mark.self_hosted +@pytest.mark.timeout(600) +def test_go2_pipeline_simulation(benchmark: BenchmarkFixture) -> None: + """Drain a replay window through decode -> voxel map -> costmap, in-process.""" + from dimos.mapping.costmapper import CostMapper + from dimos.mapping.voxels.module import VoxelGridMapper + from dimos.memory2.replay import resolve_db_path + from dimos.robot.unitree.go2.connection import GO2Connection + + saved = global_config.model_dump() + global_config.update( + replay=True, + replay_db=REPLAY_DB, + replay_speed=SPEED, + replay_duration=DURATION, + viewer="none", + ) + modules: list[Any] = [] + try: + db_path = str(resolve_db_path(REPLAY_DB)) # LFS pull/extract on miss + expected = _expected_counts(db_path, duration=DURATION) + assert all(count > 0 for count in expected.values()), f"empty window: {expected}" + + go2 = GO2Connection(g=global_config) + voxel = VoxelGridMapper(g=global_config, emit_every=5, device="CPU:0") + cost = CostMapper(g=global_config) + modules = [go2, voxel, cost] + + # One shared transport per topic; producer Out and consumer In point + # at the same object, exactly like the transport-pinning pattern in + # test_basic_deployment — just with in-process dispatch. + topics = { + name: LocalTransport(name) + for name in ( + "odom", + "lidar", + "color_image", + "camera_info", + "pointcloud", + "tf", + "cmd_vel", + "global_map", + "merged_map", + "global_costmap", + ) + } + go2.odom.transport = topics["odom"] + go2.lidar.transport = topics["lidar"] + go2.color_image.transport = topics["color_image"] + go2.camera_info.transport = topics["camera_info"] + go2.pointcloud.transport = topics["pointcloud"] + go2.tf.transport = topics["tf"] + go2.cmd_vel.transport = topics["cmd_vel"] + voxel.lidar.transport = topics["lidar"] + voxel.global_map.transport = topics["global_map"] + cost.global_map.transport = topics["global_map"] + cost.merged_map.transport = topics["merged_map"] # no producer: stays silent + cost.global_costmap.transport = topics["global_costmap"] + + counts = {"odom": 0, "lidar": 0, "color_image": 0, "global_costmap": 0} + lock = threading.Lock() + + def record(name: str) -> None: + with lock: + counts[name] += 1 + + for name in counts: + topics[name].subscribe(lambda _msg, _name=name: record(_name)) + + def start_and_drain() -> None: + # Consumers first so no frame is emitted before its subscriber + # exists; the replay starts inside go2.start(). + voxel.start() + cost.start() + go2.start() + deadline = time.monotonic() + DRAIN_TIMEOUT + while time.monotonic() < deadline: + with lock: + done = ( + counts["odom"] >= expected["odom"] + and counts["lidar"] >= expected["lidar"] + and counts["color_image"] >= expected["color_image"] + and counts["global_costmap"] > 0 + ) + if done: + return + time.sleep(0.05) + pytest.fail(f"window did not drain: counts={counts}, expected={expected}") + + def teardown() -> None: + for module in reversed(modules): + module.stop() + + benchmark.pedantic(start_and_drain, teardown=teardown, rounds=1, warmup_rounds=0) + finally: + global_config.update(**saved) From 83893fb92de763bbe6954ec1fbae5652b87fb656 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Wed, 12 Aug 2026 16:16:15 +0100 Subject: [PATCH 22/23] Fix --- .../go2/test_replay_pipeline_benchmark.py | 206 ++++++++---------- 1 file changed, 85 insertions(+), 121 deletions(-) diff --git a/dimos/robot/unitree/go2/test_replay_pipeline_benchmark.py b/dimos/robot/unitree/go2/test_replay_pipeline_benchmark.py index 6d9d5bf451..6e15875b49 100644 --- a/dimos/robot/unitree/go2/test_replay_pipeline_benchmark.py +++ b/dimos/robot/unitree/go2/test_replay_pipeline_benchmark.py @@ -15,167 +15,131 @@ """CodSpeed simulation benchmark: the go2 replay compute pipeline, one process. The single-process complement to test_replay_benchmark.py. Simulation mode -counts instructions in one process under Valgrind, so this runs the pipeline's -computational core in-process: GO2Connection (replay decode: JPEG frames, -lidar, odom) -> VoxelGridMapper -> CostMapper. The planners/patrol/movement -modules are omitted — with no goals published they are idle in the e2e run -too, so this covers the same work that actually executes there, minus worker -processes and UDP transports. - -Modules are constructed directly (no coordinator, no workers) and wired with -a synchronous in-test LocalTransport shared per topic, so delivery is -lossless and the end condition is exact: the run completes when every frame -of the replay window has been observed. Deterministic enough for instruction -counting; also runs as a plain self_hosted test for coverage. +counts instructions under Valgrind in one process, so this drives the +pipeline's compute directly and fully synchronously: decode the replay +window's frames (JPEG images, lidar pointclouds, odometry) and pull the lidar +through VoxelMapTransformer -> CostMapper._calculate_costmap — the same work +the e2e blueprint performs (its planner/patrol/movement modules idle without +goals), minus worker processes, transports, and schedulers. + +Deliberately no Module/rx machinery in the measured region: the timed replay's +shared wall-clock anchor skips late subscribers at high speed (fatal under +Valgrind's dilation), and module RPC/loop threads pollute the count. Pure +iterator pulls make completion exact by construction. Also runs as a plain +self_hosted test for coverage. Local smoke (small bundled recording): - DIMOS_SIM_BENCH_REPLAY_DB=go2_short DIMOS_SIM_BENCH_DURATION=5 \ + DIMOS_SIM_BENCH_DURATION=5 \ uv run pytest dimos/robot/unitree/go2/test_replay_pipeline_benchmark.py \ -m self_hosted --no-cov -v """ -from collections.abc import Callable +from collections.abc import Iterator import os -import threading -import time from typing import Any import pytest from pytest_codspeed import BenchmarkFixture from dimos.core.global_config import global_config -from dimos.core.transport import PubSubTransport +from dimos.protocol.rpc.spec import RPCSpec from dimos.robot.unitree.go2.test_replay_benchmark import _expected_counts REPLAY_DB = os.environ.get("DIMOS_SIM_BENCH_REPLAY_DB", "go2_short") DURATION = float(os.environ.get("DIMOS_SIM_BENCH_DURATION", 10)) -SPEED = 1000.0 # every emission delay clamps to 0: a pure CPU-bound drain -DRAIN_TIMEOUT = 420.0 # start+drain deadline, inside the test timeout -class LocalTransport(PubSubTransport[Any]): - """Synchronous in-process pub/sub: broadcast() calls subscribers inline. +class _NoRpc(RPCSpec): + """Disables the module RPC service (ModuleBase catches this ValueError). - Lossless and deterministic — no sockets, no queues, no threads of its - own — which is what makes exact-count completion and instruction - counting under Valgrind meaningful. + The benchmark only needs CostMapper for its configured compute; an RPC + service would add zenoh/LCM threads (and hangs zenoh setup on macOS). """ - def __init__(self, topic: str) -> None: - super().__init__(topic) - self._subscribers: list[Callable[[Any], Any]] = [] - - def broadcast(self, stream: Any, msg: Any) -> None: - for callback in list(self._subscribers): - callback(msg) - - def subscribe( - self, callback: Callable[[Any], Any], selfstream: Any = None - ) -> Callable[[], None]: - self._subscribers.append(callback) - return lambda: self._subscribers.remove(callback) - - def start(self) -> None: - pass - - def stop(self) -> None: - self._subscribers.clear() + def __init__(self, **_kwargs: Any) -> None: + raise ValueError("module RPC disabled for the benchmark") @pytest.mark.self_hosted @pytest.mark.timeout(600) def test_go2_pipeline_simulation(benchmark: BenchmarkFixture) -> None: - """Drain a replay window through decode -> voxel map -> costmap, in-process.""" + """Decode a replay window and pull it through voxel map -> costmap.""" from dimos.mapping.costmapper import CostMapper - from dimos.mapping.voxels.module import VoxelGridMapper + from dimos.mapping.voxels.module import VoxelMapTransformer from dimos.memory2.replay import resolve_db_path - from dimos.robot.unitree.go2.connection import GO2Connection + from dimos.memory2.store.sqlite import SqliteStore saved = global_config.model_dump() - global_config.update( - replay=True, - replay_db=REPLAY_DB, - replay_speed=SPEED, - replay_duration=DURATION, - viewer="none", - ) - modules: list[Any] = [] + global_config.update(viewer="none") + cost = None + store = None try: db_path = str(resolve_db_path(REPLAY_DB)) # LFS pull/extract on miss expected = _expected_counts(db_path, duration=DURATION) assert all(count > 0 for count in expected.values()), f"empty window: {expected}" - go2 = GO2Connection(g=global_config) - voxel = VoxelGridMapper(g=global_config, emit_every=5, device="CPU:0") - cost = CostMapper(g=global_config) - modules = [go2, voxel, cost] - - # One shared transport per topic; producer Out and consumer In point - # at the same object, exactly like the transport-pinning pattern in - # test_basic_deployment — just with in-process dispatch. - topics = { - name: LocalTransport(name) - for name in ( - "odom", - "lidar", - "color_image", - "camera_info", - "pointcloud", - "tf", - "cmd_vel", - "global_map", - "merged_map", - "global_costmap", + store = SqliteStore(path=db_path, must_exist=True) + store.start() + replay = store.replay(duration=DURATION) + available = replay.list_streams() + lidar_name = "go2_lidar" if "go2_lidar" in available else "lidar" + odom_name = "go2_odom" if "go2_odom" in available else "odom" + window_end = replay.first_ts() + DURATION # type: ignore[operator] + + def windowed(name: str) -> Any: + # The duration-only window of Replay._base_stream: everything + # before recording start + duration, in timestamp order. + return store.stream(name).before(window_end).order_by("ts") + + # Only the config'd compute is used (never started as a module). + cost = CostMapper(g=global_config, rpc_transport=_NoRpc) + # Suppressed rpc setup never assigns the attribute; _close_rpc guards + # on truthiness, so seed it for a clean _close_module in the finally. + cost.rpc = None + + counts = {"odom": 0, "lidar": 0, "color_image": 0} + produced = {"global_map": 0, "global_costmap": 0} + + def counted(observations: Iterator[Any], key: str) -> Iterator[Any]: + for obs in observations: + counts[key] += 1 + yield obs + + def drain() -> None: + # The blueprint's voxel pipeline: accumulate every lidar frame, + # emit the global map every 5th, costmap each emitted map. + transformer = VoxelMapTransformer( + emit_every=5, + voxel_size=0.05, + block_count=2_000_000, + device="CPU:0", + carve_columns=True, + frame_id="world", ) - } - go2.odom.transport = topics["odom"] - go2.lidar.transport = topics["lidar"] - go2.color_image.transport = topics["color_image"] - go2.camera_info.transport = topics["camera_info"] - go2.pointcloud.transport = topics["pointcloud"] - go2.tf.transport = topics["tf"] - go2.cmd_vel.transport = topics["cmd_vel"] - voxel.lidar.transport = topics["lidar"] - voxel.global_map.transport = topics["global_map"] - cost.global_map.transport = topics["global_map"] - cost.merged_map.transport = topics["merged_map"] # no producer: stays silent - cost.global_costmap.transport = topics["global_costmap"] - - counts = {"odom": 0, "lidar": 0, "color_image": 0, "global_costmap": 0} - lock = threading.Lock() - - def record(name: str) -> None: - with lock: - counts[name] += 1 - - for name in counts: - topics[name].subscribe(lambda _msg, _name=name: record(_name)) - - def start_and_drain() -> None: - # Consumers first so no frame is emitted before its subscriber - # exists; the replay starts inside go2.start(). - voxel.start() - cost.start() - go2.start() - deadline = time.monotonic() + DRAIN_TIMEOUT - while time.monotonic() < deadline: - with lock: - done = ( - counts["odom"] >= expected["odom"] - and counts["lidar"] >= expected["lidar"] - and counts["color_image"] >= expected["color_image"] - and counts["global_costmap"] > 0 - ) - if done: - return - time.sleep(0.05) - pytest.fail(f"window did not drain: counts={counts}, expected={expected}") + for map_obs in transformer(counted(iter(windowed(lidar_name)), "lidar")): + produced["global_map"] += 1 + cost._calculate_costmap(map_obs.data) + produced["global_costmap"] += 1 + # Decode odom and camera frames like GO2Connection does; their + # consumers idle in the e2e too. + for obs in counted(iter(windowed(odom_name)), "odom"): + _ = obs.data + for obs in counted(iter(windowed("color_image")), "color_image"): + _ = obs.data + if counts != expected or produced["global_costmap"] == 0: + pytest.fail(f"incomplete drain: counts={counts}, expected={expected}, {produced}") def teardown() -> None: - for module in reversed(modules): - module.stop() + for key in counts: + counts[key] = 0 + for key in produced: + produced[key] = 0 - benchmark.pedantic(start_and_drain, teardown=teardown, rounds=1, warmup_rounds=0) + benchmark.pedantic(drain, teardown=teardown, rounds=1, warmup_rounds=0) finally: + if cost is not None: + cost._close_module() + if store is not None: + store.stop() global_config.update(**saved) From ce1756377d4267d45eb173695bc559eea5b307b1 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Wed, 12 Aug 2026 18:04:44 +0100 Subject: [PATCH 23/23] Enable profiler --- .github/workflows/codspeed.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 91d43bc5c6..a9acd87c59 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -49,7 +49,7 @@ jobs: - base container: image: ghcr.io/dimensionalos/ros-dev@${{ needs.compute-ros-pin.outputs.digest }} - options: --memory=6g --memory-swap=6g + options: --memory=6g --memory-swap=6g --cap-add PERFMON volumes: - /var/cache/dimos-nix:/nix - /var/cache/dimos-root-cache:/root/.cache @@ -92,7 +92,6 @@ jobs: # exist here. No cpuset hooks either, so run unisolated — the job # already has the machine to itself. CODSPEED_ISOLATION: "false" - CODSPEED_PROFILER_ENABLED: "false" with: mode: walltime run: uv run pytest --codspeed -m '' --no-cov dimos/robot/unitree/go2/test_replay_benchmark.py