Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
1585680
feat(k8s): add multimodal Dynamo RL workflow
furionw Jul 9, 2026
ec50a63
fix(k8s): fetch Prime test branch from fork
furionw Jul 9, 2026
9aab751
fix(k8s): clone Prime submodules over HTTPS
furionw Jul 9, 2026
7d25d59
fix(k8s): use writable uv cache for runtime build
furionw Jul 9, 2026
47b2960
fix(k8s): make ARM runtime build incremental
furionw Jul 9, 2026
a0068fa
fix(k8s): retain Dynamo pytest benchmark plugin
furionw Jul 9, 2026
bb54408
fix(k8s): isolate Dynamo binding build caches
furionw Jul 9, 2026
d7e2fd7
test(k8s): scope ARM gate to generate adapter
furionw Jul 9, 2026
1c74eaf
fix(k8s): preserve pinned verifier runtime
furionw Jul 9, 2026
28cd92e
fix(k8s): select renderer for Qwen VL smoke
furionw Jul 9, 2026
b7dc163
feat(k8s): allow external multi-node RL launch
furionw Jul 9, 2026
e3ccaad
test(k8s): decouple multimodal RL smoke from DGD
furionw Jul 9, 2026
63f9516
fix(k8s): stop render polling after completion
furionw Jul 9, 2026
d694ab5
docs(k8s): describe stateful Dynamo smoke topology
furionw Jul 9, 2026
a16e1b3
fix(k8s): split GPU placement and enable generate API
furionw Jul 9, 2026
16a2d2a
fix(k8s): harden multimodal smoke reruns
furionw Jul 9, 2026
5c2a4f1
fix(k8s): support macOS smoke driver
furionw Jul 9, 2026
99d0a8f
fix(trainer): load ring attention only for context parallelism
furionw Jul 9, 2026
4892121
fix(k8s): configure single-rank trainer rendezvous
furionw Jul 9, 2026
078318e
fix(k8s): prewarm both multimodal nodes
furionw Jul 9, 2026
c0674e8
fix(inference): wait for model registration
furionw Jul 9, 2026
7eb579c
fix(k8s): use overlay-matched NIXL plugins
furionw Jul 9, 2026
8434a65
fix(k8s): expose trainer broadcast rendezvous
furionw Jul 9, 2026
2bd7b6a
fix(k8s): force socket NCCL for smoke
furionw Jul 9, 2026
be05f70
fix(k8s): disable unavailable GB200 MNNVL
furionw Jul 9, 2026
2de6d4a
fix(k8s): provision shared memory for NCCL
furionw Jul 9, 2026
50dee0a
fix(k8s): use null harness for color codeword
furionw Jul 9, 2026
4b361f7
perf(k8s): select GB200 nodes from one snapshot
furionw Jul 10, 2026
08da3f4
fix(k8s): use writable rollout uv cache
furionw Jul 10, 2026
2ced4c1
perf(k8s): persist overlay build caches
furionw Jul 10, 2026
8c09d16
test(k8s): cover generate worker capability
furionw Jul 10, 2026
b93e659
test(k8s): require completed multimodal train step
furionw Jul 10, 2026
71e54c3
test(k8s): add Qwen3.5 multimodal smoke
furionw Jul 10, 2026
e7e4762
fix(k8s): configure Qwen3.5 Mamba state transfer
furionw Jul 10, 2026
7700ade
fix(k8s): use multimodal Qwen renderers
furionw Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions k8s/prime-rl/examples/multimodal-dynamo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Multimodal Dynamo RL on Kubernetes

This on-demand integration test builds Dynamo and Prime RL into a shared-PVC
runtime overlay, runs Prime's native Dynamo backend in a two-GPU inference
StatefulSet, and runs one trainer on a second GB200 node. The native
backend launches the Dynamo frontend plus one prefill and one decode process.

The default sequence uses:

- `Qwen/Qwen3-VL-2B-Instruct` for a one-step integration smoke test.
- `Qwen/Qwen3.5-2B` for a second one-step integration smoke test, available
through the `qwen35` phase.
- `Qwen/Qwen3-VL-4B-Instruct` for the existing 15-step color-codeword test.
- Three GPUs total: one trainer, one prefill worker, and one decode worker.
- `qiwa/shared-model-cache` for models, sources, the virtual environment,
checkpoints, and run artifacts.
- The digest-pinned ARM64 toolchain image configured in `run.sh`.

Before deployment, each stage runs an explicit Hugging Face download pod;
already-cached model snapshots make that step a fast offline cache check. After
model download and config rendering, the driver refreshes auto-selected nodes
and immediately deploys the actual GPU-requesting pods. Kubernetes therefore
reserves their GPUs before any node-local image pull begins.

Run the complete sequence from this directory:

```bash
./run.sh all
```

Set `RUN_ID`, `NODE_NAME`, `TRAINER_NODE_NAME`, `DYNAMO_REF`, `PRIME_REPO`, or
`PRIME_REF` to resume or override a specific run. Reusing `RUN_ID` also reuses
the validated runtime overlay when its image and source commit manifest still
match. Individual phases are available as `preflight`, `build`, `smoke`,
`qwen35`, `learn`, and `clean`.

Run the two small-model checks in order with:

```bash
./run.sh smoke
./run.sh qwen35
```

Each stage clears only its own prior output directory before rendering, so a
reused `RUN_ID` cannot satisfy the trainer from an older rollout batch. The
runtime overlay and model cache remain intact.

For a code-unchanged smoke rerun, reuse `RUN_ID` and run `./run.sh smoke`; this
skips the overlay build. The shared PVC persists the Hugging Face model cache,
uv and pip caches, Cargo registry and target directories, and the validated
runtime overlay. Image layers are node-local, so pod startup may still take
about three minutes on a node that has not pulled the image before; the pod's
GPU request remains reserved during that pull. In a measured warm-node smoke
run, the model cache check took less than a second, the two vLLM workers became
ready about 2 minutes 45 seconds after Helm deploy, 16 multimodal rollouts took
40 seconds, and the first trainer step took 55 seconds.

Local logs and rendered manifests are written under
`~/workspace/dynamo-tmp/logs/07-09/multimodal-rl-k8s/<run-id>/`. The driver
collects evidence before uninstalling each Helm release. It preserves the PVC
runtime and model cache.
180 changes: 180 additions & 0 deletions k8s/prime-rl/examples/multimodal-dynamo/build-job.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
apiVersion: batch/v1
kind: Job
metadata:
name: ${BUILD_JOB}
namespace: ${NAMESPACE}
labels:
app.kubernetes.io/name: prime-rl-mm-overlay
prime-rl.nvidia.com/run-id: ${RUN_ID}
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app.kubernetes.io/name: prime-rl-mm-overlay
prime-rl.nvidia.com/run-id: ${RUN_ID}
spec:
restartPolicy: Never
imagePullSecrets:
- name: ngc-pull-secret
nodeSelector:
kubernetes.io/hostname: ${NODE_NAME}
containers:
- name: build
image: ${BASE_IMAGE}
imagePullPolicy: IfNotPresent
env:
- name: BASE_IMAGE
value: "${BASE_IMAGE}"
- name: DYNAMO_REF
value: "${DYNAMO_REF}"
- name: PRIME_REF
value: "${PRIME_REF}"
- name: PRIME_REPO
value: "${PRIME_REPO}"
- name: RUN_ROOT
value: "${RUN_ROOT}"
command: ["/bin/bash", "-lc"]
args:
- |
set -euo pipefail
OVERLAY="${RUN_ROOT}/runtime"
DYNAMO_SRC="${RUN_ROOT}/src/dynamo"
PRIME_SRC="${RUN_ROOT}/src/prime-rl"
MARKER="${RUN_ROOT}/runtime-manifest.json"

export UV_CACHE_DIR="${RUN_ROOT}/uv-cache"
export PIP_CACHE_DIR="${RUN_ROOT}/pip-cache"
export CARGO_HOME="${RUN_ROOT}/cargo-home"
mkdir -p \
"${RUN_ROOT}/src" \
"${RUN_ROOT}/logs" \
"${UV_CACHE_DIR}" \
"${PIP_CACHE_DIR}" \
"${CARGO_HOME}"

if [[ ! -d "${DYNAMO_SRC}/.git" ]]; then
git init "${DYNAMO_SRC}"
git -C "${DYNAMO_SRC}" remote add origin https://github.com/ai-dynamo/dynamo.git
fi
git -C "${DYNAMO_SRC}" remote set-url origin https://github.com/ai-dynamo/dynamo.git
git -C "${DYNAMO_SRC}" fetch --depth=1 origin "${DYNAMO_REF}"
git -C "${DYNAMO_SRC}" checkout --force --detach FETCH_HEAD

if [[ ! -d "${PRIME_SRC}/.git" ]]; then
git init "${PRIME_SRC}"
git -C "${PRIME_SRC}" remote add origin "${PRIME_REPO}"
fi
git -C "${PRIME_SRC}" remote set-url origin "${PRIME_REPO}"
git -C "${PRIME_SRC}" fetch --depth=1 origin "${PRIME_REF}"
git -C "${PRIME_SRC}" checkout --force --detach FETCH_HEAD
git config --global url."https://github.com/".insteadOf git@github.com:
git -C "${PRIME_SRC}" submodule update --init --recursive --depth=1

if [[ -x "${OVERLAY}/bin/python" && -f "${MARKER}" ]] &&
python - "${MARKER}" <<'PY'
import json
import os
import subprocess
import sys
from pathlib import Path

marker = json.loads(Path(sys.argv[1]).read_text())
root = Path(os.environ["RUN_ROOT"])
expected = {
"base_image": os.environ.get("BASE_IMAGE", ""),
"dynamo_sha": subprocess.check_output(
["git", "-C", str(root / "src/dynamo"), "rev-parse", "HEAD"],
text=True,
).strip(),
"prime_sha": subprocess.check_output(
["git", "-C", str(root / "src/prime-rl"), "rev-parse", "HEAD"],
text=True,
).strip(),
"runtime": str(root / "runtime"),
}
raise SystemExit(0 if marker == expected else 1)
PY
then
export VIRTUAL_ENV="${OVERLAY}"
export PATH="${OVERLAY}/bin:${PATH}"
python -c "import dynamo.llm, prime_rl, vllm; print('runtime cache hit')"
cat "${MARKER}"
exit 0
fi

if [[ ! -x "${OVERLAY}/bin/python" ]]; then
python3 -m venv --system-site-packages "${OVERLAY}"
fi
export VIRTUAL_ENV="${OVERLAY}"
export PATH="${OVERLAY}/bin:${PATH}"
python -m pip install --upgrade pip "uv>=0.11.1" pytest

cd "${PRIME_SRC}"
uv sync --active --frozen
python -m pip install pytest-benchmark
uv pip install --python "${OVERLAY}/bin/python" --no-deps \
-e deps/verifiers/environments/color_codeword_v1
python -m pip install --no-deps -e "${PRIME_SRC}"

export CARGO_TARGET_DIR="${RUN_ROOT}/cargo-target/python"
cd "${DYNAMO_SRC}/lib/bindings/python"
maturin develop --uv
python -c "from dynamo._core import AicPerfConfig; print('dynamo._core OK')"
export CARGO_TARGET_DIR="${RUN_ROOT}/cargo-target/kvbm"
cd "${DYNAMO_SRC}/lib/bindings/kvbm"
maturin develop --uv --features block-manager
python -m pip install --no-deps -e "${DYNAMO_SRC}"

python -c "import dynamo.llm; print('dynamo.llm OK')"
python -c "from dynamo import prometheus_names; print('dynamo OK')"
python -c "import prime_rl, vllm; print('prime_rl OK', prime_rl.__path__, 'vllm', vllm.__version__)"
python -c "import prime_rl.utils.cp; import prime_rl.trainer.rl.train; print('prime_rl SDPA imports OK')"
NIXL_PLUGIN_DIR="${OVERLAY}/lib/python3.12/site-packages/.nixl_cu13.mesonpy.libs/plugins" \
UCX_MODULE_DIR="${OVERLAY}/lib/python3.12/site-packages/nixl_cu13.libs/ucx" \
python -c "from nixl._api import nixl_agent; assert 'UCX' in nixl_agent('build-check').get_plugin_list(); print('NIXL UCX plugin OK')"

cd "${DYNAMO_SRC}"
python -m pytest -q \
components/src/dynamo/vllm/tests/test_vllm_engine_generate.py \
components/src/dynamo/vllm/tests/test_runtime_metadata.py

cd "${PRIME_SRC}"
python -m pytest -q \
tests/unit/inference/test_dynamo.py \
tests/unit/inference/test_dgd.py \
tests/unit/inference/test_helm_dgd.py \
tests/unit/utils/test_client.py

python - <<'PY'
import json
import os
import subprocess
from pathlib import Path

root = Path(os.environ["RUN_ROOT"])
data = {
"base_image": os.environ.get("BASE_IMAGE", ""),
"dynamo_sha": subprocess.check_output(
["git", "-C", str(root / "src/dynamo"), "rev-parse", "HEAD"],
text=True,
).strip(),
"prime_sha": subprocess.check_output(
["git", "-C", str(root / "src/prime-rl"), "rev-parse", "HEAD"],
text=True,
).strip(),
"runtime": str(root / "runtime"),
}
(root / "runtime-manifest.json").write_text(
json.dumps(data, indent=2, sort_keys=True) + "\n"
)
print(json.dumps(data, sort_keys=True))
PY
volumeMounts:
- name: shared
mountPath: /data
volumes:
- name: shared
persistentVolumeClaim:
claimName: shared-model-cache
34 changes: 34 additions & 0 deletions k8s/prime-rl/examples/multimodal-dynamo/download-pod.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
apiVersion: v1
kind: Pod
metadata:
name: ${DOWNLOAD_POD}
namespace: ${NAMESPACE}
labels:
app.kubernetes.io/name: prime-rl-mm-download
prime-rl.nvidia.com/run-id: ${RUN_ID}
spec:
restartPolicy: Never
imagePullSecrets:
- name: ngc-pull-secret
nodeSelector:
kubernetes.io/hostname: ${NODE_NAME}
containers:
- name: download
image: ${BASE_IMAGE}
imagePullPolicy: IfNotPresent
env:
- name: HF_HOME
value: /data
- name: MODEL_NAME
value: "${MODEL_NAME}"
command:
- ${RUN_ROOT}/runtime/bin/python
- -c
- "import os; from huggingface_hub import snapshot_download; print(snapshot_download(os.environ['MODEL_NAME']))"
volumeMounts:
- name: shared
mountPath: /data
volumes:
- name: shared
persistentVolumeClaim:
claimName: shared-model-cache
86 changes: 86 additions & 0 deletions k8s/prime-rl/examples/multimodal-dynamo/render-pod.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
apiVersion: v1
kind: Pod
metadata:
name: ${RENDER_POD}
namespace: ${NAMESPACE}
labels:
app.kubernetes.io/name: prime-rl-mm-render
prime-rl.nvidia.com/run-id: ${RUN_ID}
spec:
restartPolicy: Never
imagePullSecrets:
- name: ngc-pull-secret
nodeSelector:
kubernetes.io/hostname: ${NODE_NAME}
containers:
- name: render
image: ${BASE_IMAGE}
imagePullPolicy: IfNotPresent
env:
- name: RUN_ROOT
value: "${RUN_ROOT}"
- name: STAGE
value: "${STAGE}"
- name: RELEASE_NAME
value: "${RELEASE_NAME}"
- name: NAMESPACE
value: "${NAMESPACE}"
- name: NODE_NAME
value: "${NODE_NAME}"
- name: BASE_IMAGE
value: "${BASE_IMAGE}"
- name: IMAGE_DIGEST
value: "${IMAGE_DIGEST}"
command: ["/bin/bash", "-lc"]
args:
- |
set -euo pipefail
OVERLAY="${RUN_ROOT}/runtime"
PRIME_SRC="${RUN_ROOT}/src/prime-rl"
CONFIG="${PRIME_SRC}/k8s/prime-rl/examples/multimodal-dynamo/rl-${STAGE}.toml"
OUTPUT="${RUN_ROOT}/render/${STAGE}"
RUN_OUTPUT="${RUN_ROOT}/outputs/${STAGE}"
export VIRTUAL_ENV="${OVERLAY}"
export PATH="${OVERLAY}/bin:${PATH}"
rm -rf "${OUTPUT}" "${RUN_OUTPUT}"
mkdir -p "${OUTPUT}/configs" "${RUN_OUTPUT}"

python - <<'PY'
import os
from pathlib import Path

import tomli_w

from prime_rl.configs.rl import RLConfig
from prime_rl.entrypoints.rl import write_subconfigs
from prime_rl.utils.config import cli

root = Path(os.environ["RUN_ROOT"])
stage = os.environ["STAGE"]
source = root / "src/prime-rl/k8s/prime-rl/examples/multimodal-dynamo" / f"rl-{stage}.toml"
output = root / "render" / stage
run_output = root / "outputs" / stage
config = cli(
RLConfig,
args=["@", str(source), "--output-dir", str(run_output)],
)
write_subconfigs(config, output / "configs")
assert config.inference is not None
inference = config.inference.model_dump(
exclude={"slurm", "output_dir", "dry_run"},
exclude_none=True,
mode="json",
)
with (output / "inference-local.toml").open("wb") as stream:
tomli_w.dump(inference, stream)
PY

echo RENDER_COMPLETE
sleep 3600
volumeMounts:
- name: shared
mountPath: /data
volumes:
- name: shared
persistentVolumeClaim:
claimName: shared-model-cache
Loading