diff --git a/docker-bake.hcl b/docker-bake.hcl index 58ac77f624..323d695d27 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -128,7 +128,7 @@ variable "NEMO_RL_REPO" { # RL pins Gym as a git submodule (-> soluwalana/Gym over https), so Gym rides in with the RL git ADD # - no separate Gym pin needed. variable "NEMO_RL_REF" { - default = "eab835b111b78d90c3629b26af9b3f12dc0d0d29" # soluwalana/RL nmp/customizer + default = "9932dc8aa63a55fd431670d1b7c9d0bf3b2d2373" # soluwalana/RL nmp/customizer } variable "RL_BASE_CONTEXT" { default = "" diff --git a/docker/rl/Dockerfile.nmp-rl-base b/docker/rl/Dockerfile.nmp-rl-base index b55865b1b2..38e8a3ce26 100644 --- a/docker/rl/Dockerfile.nmp-rl-base +++ b/docker/rl/Dockerfile.nmp-rl-base @@ -132,6 +132,14 @@ ENV RAY_USAGE_STATS_ENABLED=0 \ NEMO_RL_VENV_DIR=/opt/ray_venvs \ NEMO_GYM_VENV_DIR=/opt/gym_venvs +# ---- gym-wheel: an installable nemo-gym for the Gym per-server venvs ---- +# Gym resolves `nemo-gym==` from an index for every staged server, and this +# image's fork version is published nowhere. See scripts/build-gym-wheel.sh for the why. +FROM base AS gym-wheel +COPY --from=nemo-rl 3rdparty/Gym-workspace/Gym /build/Gym +RUN --mount=type=bind,source=docker/rl/scripts/build-gym-wheel.sh,target=/build/build-gym-wheel.sh \ + bash /build/build-gym-wheel.sh /build/Gym /opt/gym-wheels + # ---- builder: uv sync RL + Gym with the extras we use ---- # fsdp (DPO training), automodel (GRPO training on DTensor V2, the only LoRA-capable DTensor # backend), mcore (Megatron training + Megatron-native generation), vllm (generation), nemo_gym and @@ -542,12 +550,18 @@ RUN apt-get purge -y ccache vim vim-common less >/dev/null 2>&1 || true; \ /opt/nvidia/nsight-systems-cli /opt/nvidia/nsight-compute \ /usr/local/bin/nsys /usr/local/bin/nsys-ui /usr/local/bin/ncu /usr/local/bin/ncu-ui \ || true +# UV_FIND_LINKS below is additive: it adds nemo-gym as a local candidate, and everything else +# still resolves from the index. Copied here rather than in `base` so the build's own uv steps +# never see a find-links path that does not exist yet. +COPY --from=gym-wheel /opt/gym-wheels /opt/gym-wheels + # NO_VCS_VERSION=1: with no .git present, nemo_rl/package_info.py's `git rev-parse` would fail on every # `import nemo_rl` (harmlessly, but it forks a subprocess each time, and this image starts many # worker processes). The flag is package_info.py's own opt-out, so the lookup is skipped entirely. ENV VIRTUAL_ENV=/opt/nemo_rl_venv \ HF_HUB_ENABLE_HF_TRANSFER=1 \ PATH="/opt/nemo_rl_venv/bin:/usr/local/bin:${PATH}" \ + UV_FIND_LINKS=/opt/gym-wheels \ NO_VCS_VERSION=1 # The venv's python symlinks to CPython under /opt/cpython, nemo_rl is installed diff --git a/docker/rl/scripts/build-gym-wheel.sh b/docker/rl/scripts/build-gym-wheel.sh new file mode 100755 index 0000000000..9cdbe65b8d --- /dev/null +++ b/docker/rl/scripts/build-gym-wheel.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# Build an installable nemo-gym wheel for the Gym per-server venvs. +# +# Gym rewrites the install line for any server directory that is NOT inside a Gym checkout -- +# which a platform environment FileSet staged at /job/environment never is. It strips the editable +# line and substitutes `nemo-gym==`, resolved from a package INDEX +# (nemo_gym/cli/setup_command.py, _get_nemo_gym_version_spec). This image runs the soluwalana fork, +# whose version is published nowhere, so that resolution has no candidate and every native-v1 job +# dies at server spin-up with "No solution found when resolving: nemo-gym" (nvbug 6716627). +# +# The pin is exact, so a wheel built at any other version is invisible to it and the job fails +# exactly as before -- hence the version check at the end. +set -euo pipefail + +GYM_SRC=${1:?usage: build-gym-wheel.sh } +OUT_DIR=${2:?usage: build-gym-wheel.sh } + +# Upstream declares almost no package-data, relying on setuptools-scm's VCS file-finder to sweep +# the configs and READMEs into the wheel. That finder needs git, and there is none here: the RL +# source arrives by `ADD #`, which keeps no submodule .git (a submodule's .git is a +# gitdir pointer file, not a directory). Left alone the wheel builds and installs happily while +# quietly missing every YAML, including the sandbox provider configs. Declare the data explicitly +# rather than depend on VCS state this build cannot have. +PKG_DATA_LINE='nemo_gym = ["resources/*.py"]' +if ! grep -qxF "${PKG_DATA_LINE}" "${GYM_SRC}/pyproject.toml"; then + echo "build-gym-wheel: Gym's [tool.setuptools.package-data] entry moved;" \ + "re-check this substitution against ${GYM_SRC}/pyproject.toml" >&2 + exit 1 +fi +# Fully single-quoted, and the brackets/dots/stars escaped: unescaped, `["resources/*.py"]` is a +# BRE character class, which matches nothing here and would edit the file silently not at all. +sed -i 's|^nemo_gym = \["resources/\*\.py"\]$|nemo_gym = ["resources/*.py", "**/*.yaml", "**/*.yml", "**/*.md"]\n"*" = ["**/*.yaml", "**/*.yml", "**/*.json", "**/*.md", "**/*.txt"]|' \ + "${GYM_SRC}/pyproject.toml" + +# --no-config: uv would otherwise discover the platform workspace's `required-version` pin +# (docker/rl/pyproject.workspace.toml, uv <0.10) and refuse to run as this image's uv 0.11. +uv build --no-config --wheel --out-dir "${OUT_DIR}" "${GYM_SRC}" + +# Fail here rather than at spin-up on a GPU node: a wheel at the wrong version, or one missing the +# package data above, installs cleanly and only misbehaves later. +shopt -s nullglob +wheels=("${OUT_DIR}"/nemo_gym-*.whl) +if [[ ${#wheels[@]} -ne 1 ]]; then + echo "build-gym-wheel: expected exactly one wheel, got: ${wheels[*]:-none}" >&2 + exit 1 +fi +# package_info.py assembles __version__ from MAJOR/MINOR/PATCH/PRE_RELEASE and imports nothing, +# so it can be read without installing the package it describes. +expected="nemo_gym-$(python -c \ + 'import runpy, sys; print(runpy.run_path(sys.argv[1])["__version__"])' \ + "${GYM_SRC}/nemo_gym/package_info.py")-py3-none-any.whl" +if [[ "$(basename "${wheels[0]}")" != "${expected}" ]]; then + echo "build-gym-wheel: built $(basename "${wheels[0]}"), expected ${expected}" >&2 + exit 1 +fi +probe="nemo_gym/sandbox/providers/opensandbox/configs/opensandbox.yaml" +# Listed into a variable rather than piped into grep: `grep -q` exits at the first match, unzip +# takes SIGPIPE, and `set -o pipefail` then reports the pipeline as failed -- so a file that IS +# present reads as missing. +entries="$(unzip -Z1 "${wheels[0]}")" +if ! grep -qxF "${probe}" <<<"${entries}"; then + echo "build-gym-wheel: ${probe} missing from the wheel; package-data did not take" >&2 + exit 1 +fi +echo "build-gym-wheel: built $(basename "${wheels[0]}")"