Skip to content

Commit cfb0bce

Browse files
Build/cache native modules (#2470)
1 parent e466dd2 commit cfb0bce

7 files changed

Lines changed: 267 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -315,11 +315,109 @@ jobs:
315315
use_pypi: true
316316
version: "11.2.8"
317317

318+
cmu-nav-natives-marker:
319+
# Check whether the modules-for-this-config marker cache
320+
# already exists. Used to skip cmu-nav-natives without using the cachix environment.
321+
if: |
322+
github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
323+
runs-on: ubuntu-latest
324+
permissions:
325+
contents: read # For checkout
326+
outputs:
327+
cache-hit: ${{ steps.marker.outputs.cache-hit }}
328+
steps:
329+
- name: Checkout files used for cache key
330+
uses: actions/checkout@v6
331+
with:
332+
sparse-checkout: |
333+
bin/build-cmu-nav-natives
334+
dimos/navigation/cmu_nav/modules
335+
sparse-checkout-cone-mode: false
336+
- name: Look up marker cache
337+
id: marker
338+
uses: actions/cache/restore@v4
339+
with:
340+
path: /tmp/cmu-nav-natives-marker
341+
key: cmu-nav-natives-${{ hashFiles('bin/build-cmu-nav-natives', 'dimos/navigation/cmu_nav/modules/**') }}
342+
lookup-only: true
343+
344+
cmu-nav-natives:
345+
# ⚠️ MUST NEVER RUN ON EXTERNAL PRs ⚠️
346+
# This job has write access to CACHIX_AUTH_TOKEN. Any workflow change that
347+
# reaches a `run:` step here could exfiltrate the token, letting an attacker
348+
# push poisoned binaries to the Cachix cache — which subsequent CI runs and
349+
# every dev with the substituter configured would unwittingly install. The
350+
# `if:` guard below is the only thing keeping fork PRs out — DO NOT REMOVE.
351+
if: |
352+
!cancelled() &&
353+
(github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) &&
354+
needs.cmu-nav-natives-marker.outputs.cache-hit != 'true'
355+
needs: cmu-nav-natives-marker
356+
timeout-minutes: 300
357+
environment: cachix
358+
runs-on: ubuntu-latest
359+
permissions:
360+
contents: read # For checkout
361+
362+
steps:
363+
- name: Checkout
364+
uses: actions/checkout@v6
365+
- name: Free runner disk space
366+
uses: BRAINSia/free-disk-space@v2.1.3
367+
- name: Install uv
368+
uses: astral-sh/setup-uv@v6
369+
with:
370+
enable-cache: true
371+
- name: Install minimal dimos for module discovery
372+
run: uv sync --no-default-groups --frozen
373+
- name: Install Nix (with Cachix substituter)
374+
env:
375+
INPUT_EXTRA_NIX_CONFIG: |
376+
extra-substituters = https://dimensionalos.cachix.org
377+
extra-trusted-public-keys = dimensionalos.cachix.org-1:20ynj6TjpoD3qTxkdNoeHtgs2G2pNvgAq1EQYLTHJXI=
378+
INPUT_SET_AS_TRUSTED_USER: "true"
379+
run: bash docker/ros/install-nix.sh
380+
- name: Cache Nix fetcher + eval cache
381+
# ~/.cache/nix holds the flake fetcher cache (the github tarballs of
382+
# each `nix build github:…` ref) and the eval cache (compiled flake
383+
# outputs). ~/.local/state/nix holds profile/gcroot bookkeeping.
384+
# Restoring these skips the ~20s/module evaluation cost, which is
385+
# paid every cold run otherwise. /nix/store is intentionally NOT
386+
# cached here — that's the slow heavy part that Cachix handles.
387+
uses: actions/cache@v4
388+
with:
389+
path: |
390+
~/.cache/nix
391+
~/.local/state/nix
392+
key: nix-fetcher-build-${{ runner.os }}-${{ hashFiles('bin/build-cmu-nav-natives', 'dimos/navigation/cmu_nav/modules/**/*.py') }}
393+
restore-keys: nix-fetcher-build-${{ runner.os }}-
394+
- name: Authenticate Cachix
395+
uses: cachix/cachix-action@v15
396+
with:
397+
name: dimensionalos
398+
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
399+
- name: Build cmu_nav native modules
400+
env:
401+
BUILD_WORKERS: "2"
402+
run: uv run bin/build-cmu-nav-natives
403+
- name: Write marker file
404+
run: install -D /dev/null /tmp/cmu-nav-natives-marker/ok
405+
- name: Save marker cache
406+
# Done as a final step (rather than via actions/cache's post-step) so
407+
# a build failure doesn't poison the cache with a marker for a config
408+
# we never actually pushed.
409+
uses: actions/cache/save@v4
410+
with:
411+
path: /tmp/cmu-nav-natives-marker
412+
key: cmu-nav-natives-${{ hashFiles('bin/build-cmu-nav-natives', 'dimos/navigation/cmu_nav/modules/**') }}
413+
318414
self-hosted-tests:
319415
# Skip on PRs from forks which would expose the self-hosted runner to untrusted code from external contributors.
320416
if: |
321-
github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
322-
needs: compute-ros-pin
417+
!cancelled() &&
418+
(github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) &&
419+
contains(fromJSON('["success", "skipped"]'), needs.cmu-nav-natives.result)
420+
needs: [compute-ros-pin, cmu-nav-natives]
323421
env:
324422
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
325423
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -333,6 +431,11 @@ jobs:
333431
# GitHub Actions only honours `container:` on Linux runners.
334432
container:
335433
image: ghcr.io/dimensionalos/ros-dev@${{ needs.compute-ros-pin.outputs.digest }}
434+
# Persist Nix state on the self-hosted runner's filesystem so
435+
# the ~3 GB module closures survive between jobs.
436+
volumes:
437+
- /var/cache/dimos-nix:/nix
438+
- /var/cache/dimos-root-cache:/root/.cache
336439
markers: "self_hosted or skipif_no_ros"
337440
experimental: false
338441
- os: macOS
@@ -396,6 +499,35 @@ jobs:
396499
echo "ROS_VERSION=$ROS_VERSION"
397500
echo "ROS_PYTHON_VERSION=$ROS_PYTHON_VERSION"
398501
} >> "$GITHUB_ENV"
502+
- name: Install Nix (with Cachix substituter)
503+
if: contains(matrix.markers, 'skipif_no_ros')
504+
# /nix is bind-mounted from the self-hosted runner's host filesystem
505+
# (see this job's matrix container.volumes). On warm runs Nix is
506+
# already installed; install-nix.sh's `type -p nix` check will exit
507+
# early, but only if the profile bin is on PATH — prepend it before
508+
# calling. If /nix is empty (first job on a fresh runner), nix isn't
509+
# on PATH and the install proceeds normally. After install-nix.sh,
510+
# we unconditionally (re-)write /etc/nix/nix.conf: on warm runs
511+
# install-nix.sh exited before it would have written the config,
512+
# which leaves `experimental-features = nix-command flakes` absent
513+
# and the build dying with "experimental Nix feature 'nix-command'
514+
# is disabled". Writing it here keeps cold and warm runs identical.
515+
run: |
516+
export PATH="/nix/var/nix/profiles/default/bin:$PATH"
517+
bash docker/ros/install-nix.sh
518+
sudo mkdir -p /etc/nix
519+
sudo tee /etc/nix/nix.conf > /dev/null <<'EOF'
520+
experimental-features = nix-command flakes
521+
extra-substituters = https://dimensionalos.cachix.org
522+
extra-trusted-public-keys = dimensionalos.cachix.org-1:20ynj6TjpoD3qTxkdNoeHtgs2G2pNvgAq1EQYLTHJXI=
523+
always-allow-substitutes = true
524+
build-users-group =
525+
max-jobs = auto
526+
EOF
527+
echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
528+
- name: Fetch cmu_nav native modules from Cachix
529+
if: contains(matrix.markers, 'skipif_no_ros')
530+
run: uv run bin/build-cmu-nav-natives
399531
- name: Run tests
400532
run: uv run pytest --cov=dimos/ --junitxml=junit.xml -m '(${{ matrix.markers }}) and not mujoco'
401533
- name: Re-run the failing tests with maximum verbosity
@@ -559,6 +691,7 @@ jobs:
559691
- md-babel
560692
- docs-validate
561693
- tests
694+
- cmu-nav-natives
562695
- self-hosted-tests
563696
#- self-hosted-large-tests
564697

@@ -570,9 +703,7 @@ jobs:
570703
- name: Decide whether the needed jobs succeeded or failed
571704
uses: re-actors/alls-green@release/v1
572705
with:
573-
allowed-skips: |
574-
self-hosted-tests
575-
self-hosted-large-tests
706+
allowed-skips: cmu-nav-natives, self-hosted-tests, self-hosted-large-tests
576707
jobs: ${{ toJSON(needs) }}
577708
- name: Trigger Codecov notifications
578709
uses: codecov/codecov-action@v6

bin/build-cmu-nav-natives

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/usr/bin/env python3
2+
"""Pre-build native modules ready for tests.
3+
4+
Discovers every cmu_nav module that defines a NativeModuleConfig with a
5+
`build_command` set, then runs that command. CI invokes this after installing
6+
Nix + the Cachix substituter so warm-cache builds are near-instant.
7+
8+
Discovery imports each module and reads the config defaults off an
9+
instantiated `NativeModuleConfig` — robust against any refactor of how those
10+
fields are declared (literal vs computed vs factory), at the cost of needing
11+
dimos importable (run via `uv run`).
12+
"""
13+
14+
from concurrent.futures import ProcessPoolExecutor, as_completed
15+
import importlib
16+
import os
17+
from pathlib import Path
18+
import re
19+
import subprocess
20+
import sys
21+
import time
22+
23+
from dimos.core.native_module import NativeModuleConfig
24+
import dimos.navigation.cmu_nav.modules as modules_pkg
25+
26+
MODULES_DIR = Path(modules_pkg.__path__[0])
27+
# Captured at import time; forked workers inherit it
28+
_T0 = time.monotonic()
29+
30+
31+
def _log(msg: str) -> None:
32+
print(f"[{time.monotonic() - _T0:6.1f}s] {msg}", flush=True)
33+
34+
35+
# Pre-filter source files for a NativeModuleConfig subclass before importing
36+
# them. Modules that don't subclass NativeModuleConfig (e.g. nav_record) may
37+
# pull in heavy deps like torch at top-level import, and we don't want to
38+
# require those just to discover.
39+
_HAS_NATIVE_CONFIG = re.compile(r"^class\s+\w+\s*\(\s*NativeModuleConfig\b", re.MULTILINE)
40+
41+
42+
def discover() -> tuple[type[NativeModuleConfig], ...]:
43+
found: list[type[NativeModuleConfig]] = []
44+
for pkg_dir in sorted(MODULES_DIR.iterdir()):
45+
if not pkg_dir.is_dir():
46+
continue
47+
leaf = pkg_dir.name
48+
src = pkg_dir / f"{leaf}.py"
49+
if not src.exists() or not _HAS_NATIVE_CONFIG.search(src.read_text()):
50+
continue
51+
mod = importlib.import_module(f"{modules_pkg.__name__}.{leaf}.{leaf}")
52+
for attr in vars(mod).values():
53+
if (
54+
isinstance(attr, type)
55+
and attr is not NativeModuleConfig
56+
and issubclass(attr, NativeModuleConfig)
57+
and attr.__module__ == mod.__name__
58+
and attr.model_fields["build_command"].default is not None
59+
):
60+
found.append(attr)
61+
return tuple(found)
62+
63+
64+
def build_one(cfg_cls: type[NativeModuleConfig]) -> str:
65+
name = cfg_cls.__name__
66+
cfg = cfg_cls()
67+
assert cfg.build_command is not None
68+
mod = importlib.import_module(cfg_cls.__module__)
69+
base_dir = Path(mod.__file__).resolve().parent # type: ignore[arg-type]
70+
cwd = Path(cfg.cwd) if cfg.cwd is not None else base_dir
71+
if not cwd.is_absolute():
72+
cwd = base_dir / cwd
73+
exe = Path(cfg.executable)
74+
if not exe.is_absolute():
75+
exe = cwd / exe
76+
if exe.exists():
77+
return f"OK: {name} already built at {exe}"
78+
_log(f"START: {name} in {cwd}: {cfg.build_command}")
79+
proc = subprocess.run(
80+
cfg.build_command,
81+
shell=True,
82+
cwd=str(cwd),
83+
capture_output=True,
84+
text=True,
85+
check=False,
86+
)
87+
body = (proc.stdout or "") + (proc.stderr or "")
88+
if proc.returncode != 0:
89+
raise RuntimeError(f"{name} exited {proc.returncode}\n{body}")
90+
return f"DONE: {name}\n{body}".rstrip()
91+
92+
93+
def main() -> None:
94+
cfgs = discover()
95+
failed = 0
96+
workers = int(os.environ.get("BUILD_WORKERS") or os.cpu_count() or 2)
97+
_log(f"Building {len(cfgs)} module(s) with up to {workers} workers")
98+
with ProcessPoolExecutor(max_workers=workers) as ex:
99+
futs = {ex.submit(build_one, c): c for c in cfgs}
100+
for fut in as_completed(futs):
101+
try:
102+
_log(fut.result())
103+
except Exception as e:
104+
_log(f"FAIL: {futs[fut].__name__}: {e}")
105+
failed += 1
106+
if failed:
107+
sys.exit(f"{failed} build(s) failed")
108+
109+
110+
if __name__ == "__main__":
111+
main()

dimos/navigation/cmu_nav/modules/far_planner/far_planner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class FarPlannerConfig(NativeModuleConfig):
3434
cwd: str | None = str(Path(__file__).resolve().parent)
3535
executable: str = "result/bin/far_planner_native"
3636
build_command: str | None = (
37-
"nix build github:dimensionalOS/dimos-module-far-planner/v0.5.0 --no-write-lock-file"
37+
"nix build github:dimensionalOS/dimos-module-far-planner/v0.7.1 --no-write-lock-file"
3838
)
3939

4040
# C++ binary uses snake_case CLI args.

dimos/navigation/cmu_nav/modules/path_follower/path_follower.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class PathFollowerConfig(NativeModuleConfig):
3434
cwd: str | None = str(Path(__file__).resolve().parent)
3535
executable: str = "result/bin/path_follower"
3636
build_command: str | None = (
37-
"nix build github:dimensionalOS/dimos-module-path-follower/v0.2.0 --no-write-lock-file"
37+
"nix build github:dimensionalOS/dimos-module-path-follower/v0.2.1 --no-write-lock-file"
3838
)
3939

4040
cli_name_override: dict[str, str] = {

dimos/navigation/cmu_nav/modules/terrain_analysis/terrain_analysis.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class TerrainAnalysisConfig(NativeModuleConfig):
2525
cwd: str | None = "."
2626
executable: str = "result/bin/terrain_analysis"
2727
build_command: str | None = (
28-
"nix build github:dimensionalOS/dimos-module-terrain-analysis/v0.1.1 --no-write-lock-file"
28+
"nix build github:dimensionalOS/dimos-module-terrain-analysis/v0.1.2 --no-write-lock-file"
2929
)
3030
cli_name_override: dict[str, str] = {
3131
"sensor_range": "sensorRange",

docker/ros/install-nix.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
#!/usr/bin/env bash
22
set -euo pipefail
33

4+
# Defaults so this script can be invoked from a plain workflow `run:` step,
5+
# not only via the composite action that exports these INPUT_* env vars.
6+
: "${INPUT_ENABLE_KVM:=false}"
7+
: "${INPUT_SET_AS_TRUSTED_USER:=false}"
8+
: "${INPUT_EXTRA_NIX_CONFIG:=}"
9+
: "${INPUT_INSTALL_OPTIONS:=}"
10+
411
if nix_path="$(type -p nix)" ; then
512
echo "Aborting: Nix is already installed at ${nix_path}"
613
exit

docs/usage/native_modules.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,13 @@ class MyLidarConfig(NativeModuleConfig):
268268
`cwd` is used for both the build command and the runtime subprocess. Relative paths are resolved against the directory of the Python file that defines the module
269269

270270
If the executable already exists, the build step is skipped entirely.
271+
272+
### Faster builds via the Cachix substituter
273+
274+
CI pre-builds the `cmu_nav` native modules and pushes the Nix store paths to the `dimensionalos` Cachix cache. Opt in locally to skip cold compiles when the cache has them:
275+
276+
```
277+
# ~/.config/nix/nix.conf (single-user) or /etc/nix/nix.conf (multi-user)
278+
extra-substituters = https://dimensionalos.cachix.org
279+
extra-trusted-public-keys = dimensionalos.cachix.org-1:20ynj6TjpoD3qTxkdNoeHtgs2G2pNvgAq1EQYLTHJXI=
280+
```

0 commit comments

Comments
 (0)