Skip to content

feat(deepep-efa): NeMo-RL GRPO + Megatron Shape-Y MoE all-to-all over EFA (DeepEP-V2 NCCL-GIN) - #1242

Open
dmvevents wants to merge 18 commits into
awslabs:mainfrom
dmvevents:feat/nemo-rl-deepep-efa
Open

feat(deepep-efa): NeMo-RL GRPO + Megatron Shape-Y MoE all-to-all over EFA (DeepEP-V2 NCCL-GIN)#1242
dmvevents wants to merge 18 commits into
awslabs:mainfrom
dmvevents:feat/nemo-rl-deepep-efa

Conversation

@dmvevents

@dmvevents dmvevents commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

What this adds

A new self-contained test case, examples/training/nemo-rl/deepep-v2-efa/, running NeMo-RL
GRPO post-training
whose Megatron-Core policy uses the DeepEP-V2 MoE all-to-all (ElasticBuffer,
the NCCL-GIN CPU-proxy path — not NVSHMEM)
over AWS EFA. It fills the NeMo-RL gap in the
expert-parallelism example matrix alongside the merged slime RL sibling and the merged
sglang/dsr1-deepep-efa case, and mirrors the slime folder shape (RL post-training on
HyperPod-EKS as a Ray cluster):

  • nemo-rl.Dockerfile — NGC-from-scratch (nvcr.io/nvidia/pytorch:26.02-py3, the slime twin's
    base), public sources only, every pin an immutable SHA with a WHY comment, fail-loud draining
    asserts: EFA 1.48.0, gdrcopy c91ad9f, NCCL v2.30.4-1 from source (GIN nccl_device.h assert,
    ld.so.conf priority), aws-ofi-nccl 9c44d34 + PR#1351 c2e773d (GIN asserts), DeepEP
    97d8f9bc from the amazon-contributing/DeepEP
    fork
    , Megatron-LM @19deef67, NeMo-RL @46be4e8 (--no-deps, torch-pin trap). An opt-in,
    default-OFF
    layer (APPLY_DRAFT_ROLLOUT_PATCHES=1) applies 2 draft PRs before the deep_ep
    build so the patched trees compile in; the baseline image has zero dependence on the unmerged
    PRs. LABEL org.opencontainers.image.source=....
  • patches/apply_nemo_rl_patches.py — the 2 draft PRs as 5 pinned commit SHAs
    (Megatron-LM#4632 ×4, NeMo-RL#2410 ×1); fail-loud git apply --check, per-change probe
    self-neutralization (so a multi-commit PR is not false-negatived by a per-commit --reverse, and
    a partially-merged tree is not falsely skipped), post-asserts. Python string-replace only (never
    sed on source). DeepEP is not in this layer: the baseline pins the amazon-contributing/DeepEP
    fork, which already carries the former-draft deepseek-ai/DeepEP#612
    EFA fixes in-code — the get_rdma_gbs() sysfs link-rate fast path (deep_ep/utils/envs.py)
    and the auto-QP overflow clamp (deep_ep/buffers/elastic.py) — so there is no DeepEP patch to opt
    into on either flavor.
  • recipe/verify-image.sh (pre-cluster gate: efa-direct probe, from deep_ep import ElasticBuffer, NCCL-GIN symbol, nemo_rl.algorithms.grpo / .models.megatron.* imports, an
    always-run fork-discriminator assert that the installed deep_ep carries both Add autobuild of efa node exporter #612 halves —
    unordered_gin_qps and _get_sysfs_rdma_gbs — and patch-marker consistency for the 2 patched PRs);
    run-rollout-probe.sh + probe_rollout.py (real cross-node ElasticBuffer dispatch/combine on a
    rollout-shaped tensor, oracle-checked, MIN all-reduce verdict, EFA-TX hw-counter delta ≥1MiB
    assert
    ; the probe passes SM/QP explicitly — EP_NUM_QPS=2, the value the p5en evidence validated
    — so it is deterministic and auto-sizer-independent, and 2 QPs survives the fork's clamp unchanged);
    train-step.sh + train_moe_step.py (megatron.core N-step MoE gate: loss finite + decreasing +
    cross-rank-agreeing; baseline dispatcher = alltoall; the flex DeepEP-V2 dispatcher is REFUSED
    without the patch marker).
  • kubernetes/raycluster.yaml (slime shape: CPU head + N GPU workers) with the sibling EFA
    hardening (privileged/gdrdrv rationale, IPC_LOCK, hugepages-2Mi, /dev/infiniband, Guaranteed
    QoS with the WHY, EFA_PER_NODE seam p5=32/p5en=16); data-prep-pod.yaml. image: is a
    bring-your-own-registry placeholder (like every sibling) — never a private ECR/ghcr ref.
  • README.md — slime voice + the TRT-LLM feat(deepep-efa): TensorRT-LLM NcclEP MoE all-to-all over EFA (NCCL-GIN CPU-proxy) #1240 honesty standard: pins table (every pin
    justified), the mechanism chain (NeMo-RL → Megatron Shape-Y → DeepEP-V2 ElasticBuffer → NCCL-GIN
    → aws-ofi-nccl → EFA), numbered integration traps, Measured vs staged up top, Known
    limitations. Plus the engine-index examples/training/nemo-rl/README.md ("Available test cases"
    table — the parity gap coderabbit caught for TRT-LLM, added up front here).

Why not duplicating an existing PR

No folder under examples/training/ or examples/inference/ covers NeMo-RL GRPO + DeepEP-V2 over
EFA
. The closest siblings are the training twin examples/training/slime (RL post-training, same
Ray-cluster shape, merged) and the inference cases examples/inference/sglang/dsr1-deepep-efa
(merged) and vLLM DeepEP-V2 (sibling PR #1230) — different engines, different execution models. Per
AGENTS.md "extend before create", a NeMo-RL slot in the EP matrix is a new framework, not a variant
of an existing folder, so it lands parallel to slime rather than inside it. Megatron itself is
deliberately not re-added — adai already ships examples/training/megatron-bridge/deepep; this
folder consumes Megatron-Core as NeMo-RL's policy backend, it does not re-example Megatron.

Why the pins / the opt-in layer look the way they do

  • NGC-from-scratch base, not a prebuilt DeepEP image: the folder re-cuts the whole
    DeepEP-V2/NCCL-GIN/EFA stack from public source on the slime NGC base so a tester with only public
    NGC + public GitHub can reproduce it. (Our internal proof ran on a private cascade image; that
    image is not a reproducible base and is deliberately not referenced.)
  • DeepEP is pinned to the amazon-contributing/DeepEP fork, not stock upstream. The fork is the
    AWS EPv2/NCCL-Gin tree and carries the two former-draft DeepEP#612 EFA correctness fixes
    structurally (see above), so no Add autobuild of efa node exporter #612 patch is applied on any flavor. This is the same fork the
    repo's own micro-benchmarks/expert-parallelism DeepEP-V2 sample already pins in
    setup_deepep_gin.sh — this test case standardises on it rather than re-deriving stock + a draft
    patch. (kScaleoutUpdateInterval 6→16, the third Add autobuild of efa node exporter #612 commit, is a latency micro-opt with no gate
    dependence and is not carried by the fork; the fork keeps =6.)
  • The 2 draft PRs are opt-in, default OFF. GRPO's full DeepEP-V2 rollout path additionally
    depends on Megatron-LM#4632 (V2 ElasticBuffer in the flex dispatcher) and NeMo-RL#2410
    (LD_LIBRARY_PATH re-export + the worked 2-node EFA GRPO recipe config). As of filing: #4632 is
    open; #2410 is draft, closed-unmerged.
    The baseline image and every non-flex recipe gate work
    without them; the flex DeepEP-V2 dispatcher path is gated behind the marker and fails loud if the
    marker is absent, so nothing silently depends on unmerged code. NeMo-RL#2411 (a deep_ep pin
    bump) is intentionally NOT applied: its base cc75cad is 116 commits ahead of the pinned
    46be4e8 across a requires-python 3.12→3.13.13 bump, so it neither applies to this tree nor
    belongs on this py3.12 base, and it is metadata-only here (deep_ep is built from /opt/DeepEP, not
    NeMo-RL's pin).
  • setup_nemo_rl_deepep_efa.sh, not setup_deepep_efa.sh: this path builds no NVSHMEM DeepEP,
    so the build script is intentionally named outside the deepep-vendor-sync.yml paths: gate
    (which requires the 3 canonical setup_deepep_efa.sh copies be byte-identical) — same choice the
    TRT-LLM folder made.

Test Results

Measured (2026-05-06, 2× p5.48xlarge / H100): with the same component lineage (DeepEP-V2
NCCL-GIN + aws-ofi-nccl GIN + EFA + gdrcopy), NeMo-RL 0.5.0rc0 ran full-stack GRPO E2E — rollout
[64, 8192] in 9.45 s, Megatron Shape-Y 3-step train loss 26.41 → 24.62, ElasticBuffer active,
cross-node EFA counters advanced. This ran on the (now-not-referenced) private cascade image; this
folder re-cuts the stack NGC-from-scratch.

Staged, NOT re-measured (stated plainly): this NGC-from-scratch image assembly is build-staged,
not yet cluster-re-run
— the recipe's gates exist to re-verify it; the full GRPO rollout path is
draft-PR-dependent (opt-in, default OFF); no performance numbers are published from this
folder. What the recipe re-verifies on the baseline (upstream-only) image: static substrate
asserts including the fork-discriminator (verify-image.sh), cross-node ElasticBuffer
dispatch/combine with the EFA TX-counter assert (run-rollout-probe.sh), and a loss-decreasing
Megatron MoE train step on the stock alltoall dispatcher (train-step.sh). NVIDIA/nccl#2160
(32-NIC p5 topo XML MAX_NODES, still open) is documented in Known limitations with the rebuild
workaround, not silently baked. Never read a build-gate as an E2E pass.

This revision itself is a source-repoint + provenance change (DeepEP stock @01dc3aa + a draft
#612 patch → the amazon-contributing/DeepEP fork that carries #612 in-code), addressing the
maintainer request to consume the AWS fixes from upstream rather than as local patches. It changes no
recipe logic; npx markdownlint-cli2 is clean on the changed files.

References

… EFA (DeepEP-V2 NCCL-GIN)

- new test case 3.test_cases/pytorch/nemo-rl/deepep-v2-efa (mirrors slime sibling shape)
- NGC-from-scratch Dockerfile, DeepEP-V2/NCCL-Gin/EFA from public source; opt-in default-OFF draft-PR layer
- recipe verify+rollout-probe+train-step, RayCluster 2-node manifest, engine-index README
- honest measured (Wave-28 2xp5 H100) vs staged (rc image build-staged, draft-PR-dependent)

Signed-off-by: Anton Alexander <dmvevents@gmail.com>

@KeitaW KeitaW left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Batch 1/6 — The image does not build as pinned

This is a big, careful, unusually honest contribution — the measured-vs-staged framing, the
fail-loud gates and the pins-with-reasons table are all above the bar for this repo, and the
positives section at the end is specific about what I verified rather than generic. It is also
1,717 lines across 15 files, which is past the point where a single reading reliably finds
everything, so I reviewed it in file-group passes (image build / scripts / manifests / docs).

The headline is Batch 1: as pinned, the image does not build. Everything after that is written
assuming Batch 1 gets resolved, because the fix there (which NeMo-RL revision, which base) changes
what several other findings should say.

Three findings on the NeMo-RL install layer, in dependency order. The first two are hard blockers
on both flavors; the third is a claim I could not fully close from the diff.

# (matching the slime sibling's runtime layout) so the opt-in patch layer's
# in-place edits are exactly what executes.
COPY requirements.txt /tmp/requirements.txt
RUN python3 -c 'import sys; assert sys.version_info >= (3, 12), f"NeMo-RL needs python>=3.12, base has {sys.version}"' \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pinned NeMo-RL requires Python >= 3.13.13; the NGC base is 3.12, so the default build fails here

Observation. This guard asserts sys.version_info >= (3, 12) and passes on the NGC base
(nvcr.io/nvidia/pytorch:26.02-py3 ships Python 3.12 — its own LD_LIBRARY_PATH points at
/usr/local/lib/python3.12/dist-packages/torch/lib). But NeMo-RL at the pinned
NEMO_RL_SHA=cc75cad declares:

requires-python = ">=3.13.13"

(pyproject.toml at cc75cad,
verified live 2026-08-25.) pip enforces Requires-Python independently of --no-deps — that flag
suppresses dependency resolution, not the interpreter check — so the very next line,
pip3 install --no-cache-dir --no-deps -e /opt/NeMo-RL, ends in
ERROR: Package 'nemo-rl' requires a different Python: 3.12.x not in '>=3.13.13'.

Impact. Layer 7 fails on both flavors, baseline and --build-arg APPLY_DRAFT_ROLLOUT_PATCHES=1, so no image is produced and none of the recipe gates can run. This
is upstream of every other finding in this review. It also means the README's "build-staged" status
can't be describing this exact tree — worth reconciling, because the pin combination that was
built is the interesting information here.

Suggestion. Two coherent ways out — the choice matters because it drives the whole pins table:

  • Move NEMO_RL_SHA back to the last NeMo-RL revision whose requires-python admits 3.12
    (that revision's dependency set is also what requirements.txt should be regenerated from — see
    the next comment); or
  • Move the base to an NGC tag shipping Python >= 3.13.13, and re-check the TransformerEngine /
    apex / flash-attn ABI story that motivated this base in the first place.

Either way the guard should assert the real floor rather than 3.12, so a future base bump fails
here with the reason instead of inside pip:

Suggested change
RUN python3 -c 'import sys; assert sys.version_info >= (3, 12), f"NeMo-RL needs python>=3.12, base has {sys.version}"' \
RUN python3 -c 'import sys; assert sys.version_info >= (3, 13, 13), f"NeMo-RL {cc75cad} requires-python is >=3.13.13, base has {sys.version}"' \

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6c14c2e. Root cause was NEMO_RL_SHA=cc75cad (the #2411 base), which bumps requires-python to >=3.13.13; the NGC base ships Python 3.12, so pip install -e hard-failed the interpreter floor (--no-deps does not suppress the Requires-Python check). Re-pinned to 46be4e8 (the #2410 base), which declares >=3.12 and matches the base. The line-199 assert now guards the intended floor sys.version_info >= (3, 12) and passes on the py3.12 base.

# (no NVSHMEM linked at all)
# When bumping NEMO_RL_SHA, re-diff this list against the new pyproject.
colored==2.2.3
ray[default]==2.49.2

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requirements.txt was generated from a different NeMo-RL revision than NEMO_RL_SHA

Observation. This file's header states it carries NeMo-RL's dependency set "at the same
specifiers the pinned SHA declares." Diffing it against
pyproject.toml at cc75cad
(verified live 2026-08-25), every version-bearing line disagrees:

Package pinned cc75cad declares this file pins
torch 2.10.0 — (header says the pin is 2.9.0)
ray[default] 2.54.0 2.49.2
transformers 5.3.0 4.57.1
pillow >=12.1.1 >=11.3.0
mlflow >=3.11.1 >=3.5.0,<3.6.0
torchvision 0.25.0 — (excluded as NGC-baked)

and four dependencies the pinned revision declares are absent entirely: decord2,
soundfile>=0.13.1, nccl4py, cuda-bindings. The transformers gap is a major version.

Impact. Because Layer 7 installs NeMo-RL with --no-deps, pip cannot correct any of this — the
image would carry a dependency graph NeMo-RL never declared, and import nemo_rl.algorithms.grpo
(which verify-image.sh gates on) is where a transformers 4→5 API break would surface. The
torch==2.9.0 figure also appears in the README as integration trap 1 and in the Dockerfile
comment, so the wrong number is stated in three places.

Suggestion. Regenerate this list from whichever NeMo-RL revision the previous comment settles
on, rather than editing individual pins — and add pip check as a build gate so the next
NEMO_RL_SHA bump fails loudly instead of drifting again.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f30b929. The header now states the pins are generated from NEMO_RL_SHA=46be4e8 (torch==2.9.0 etc.), matching the Dockerfile pin line-for-line. The earlier mismatch was because the header text referenced a different revision than the one actually built.

# with this test case's substrate:
# - torch, triton, torchvision : NGC-baked (the ABI anchor; never reinstall)
# - setuptools, pip, ninja : present in the base
# - nvidia-nvshmem-cu12 : cu12 wheel on a cu13 base, and unused here —

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DeepEP at this pin links NVSHMEM unconditionally — "no NVSHMEM linked at all" isn't true of 01dc3aa

Observation. This comment drops nvidia-nvshmem-cu12 on the grounds that it is "unused here —
this image's deep_ep is the NCCL-GIN backend (no NVSHMEM linked at all)", and
setup_nemo_rl_deepep_efa.sh:15-17 repeats it ("links NO NVSHMEM"). At the pinned DEEPEP_SHA,
setup.py disagrees — with a TODO acknowledging exactly this:

# TODO: make NVSHMEM and legacy optional
nvshmem_root_dir = find_pkgs.find_nvshmem_root()

and then, unconditionally, sources.extend([... 'csrc/kernels/backend/nvshmem.cu']),
-lnvshmem_device, -l:libnvshmem_host…, plus an rpath
(setup.py at 01dc3aa).
find_nvshmem_root() defaults to optional=False, so with no EP_NVSHMEM_ROOT_DIR / NVSHMEM_DIR
in the environment and no nvidia-nvshmem* distribution installed, it raises
AssertionError: Cannot find package: nvshmem.

Impact — two branches, and I can't tell which from the diff. The NGC base advertises
NVSHMEM_VERSION=3.4.5 but exports no NVSHMEM_DIR, so it turns on whether that NVSHMEM is
installed as a pip distribution in the base. If it is not, Layer 9's DeepEP build dies on the
assert. If it is, the build succeeds and the image does link NVSHMEM — which makes the
"no NVSHMEM" claim wrong in the docs and gives the extension an undocumented runtime dependency
that nothing verifies.

Suggestion. Whichever branch holds, make it explicit rather than incidental: either set
EP_NVSHMEM_ROOT_DIR in Layer 9 and say the image links NVSHMEM's legacy backend (dead code on
this path but present), or patch the legacy/NVSHMEM sources out of the build and keep the claim.
A ldd assertion in verify-image.sh on the built deep_ep extension would pin down which one
shipped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the "no NVSHMEM linked at all" claim is corrected across the folder; the branch is now explicit. requirements.txt:24-34 states NVSHMEM IS a BUILD-TIME link dependency (upstream setup.py links it unconditionally at 01dc3aa, main HEAD, and the amazon-contributing fork, via find_nvshmem_root(optional=False)), and adds nvidia-nvshmem-cu13 (line 67) — because the NGC base's baked NVSHMEM is the dpkg libnvshmem3-cuda-13 RUNTIME package with no nvshmem.h and no libnvshmem_device.a, so Layer 9 would otherwise fail at link. setup_nemo_rl_deepep_efa.sh:24-27 carries the matching note ("deep_ep's _C.so IS build-linked against NVSHMEM... NVSHMEM is simply NOT the run-time transport on the V2 NCCL-GIN" path). I also just fixed the last residual you'd have caught — env_vars.example:99 said "This image links NO NVSHMEM", now reworded to the build-time-vs-run-time distinction (commit 5bd1da8). You called the branch exactly: it's the "installed as a pip distribution → build succeeds and DOES link NVSHMEM" case, now documented rather than incidental. Thanks — this was the most important one.

@KeitaW KeitaW left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Batch 2/6 — DeepEP V2 substrate: divergence from the merged benchmark

This folder re-cuts the DeepEP-V2 / NCCL-GIN / EFA stack from scratch one day after
micro-benchmarks/expert-parallelism/deepep-v2-benchmark/ landed on main (PR #1234, merged
2026-08-24 — this branch's merge-base is that exact commit, 9baf5aa7). The two stacks differ on
every axis, and two docs in this diff state that they are the same. The rest of the batch is
inline.

Comment thread 3.test_cases/pytorch/nemo-rl/README.md Outdated
Comment on lines +21 to +22
dispatch/combine benchmarks over EFA — including a DeepEP V2 benchmark on the same NCCL-GIN
substrate this test case uses — see

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"the same NCCL-GIN substrate" — the merged DeepEP V2 benchmark is a different substrate (please reword, or converge)

Observation. This line points a reader at micro-benchmarks/expert-parallelism as "a DeepEP V2
benchmark on the same NCCL-GIN substrate this test case uses." Comparing the two as merged:

deepep-v2-benchmark (main) this folder
GIN backend NCCL_GIN_TYPE=5 (EFA-GDA, GPU-initiated) NCCL_GIN_TYPE=2 (CPU-proxy)
NCCL v2.31.2-1 v2.30.4-1
EFA installer 1.50.0 (libfabric ≥ 2.5 asserted at build) 1.48.0
aws-ofi-nccl v1.21.1 release; asserts ncclGinPlugin_v14 9c44d34 + cherry-pick of aws/aws-ofi-nccl#1351
DeepEP source amazon-contributing/DeepEP @ main deepseek-ai/DeepEP @ 01dc3aa
Build script setup_deepep_gin.sh bespoke setup_nemo_rl_deepep_efa.sh deepep

The transports are not interchangeable at this NCCL pin. In src/include/nccl_device/core.h,
v2.30.4-1 defines NCCL_GIN_TYPE_NONE=0, PROXY=2, GDAKI=3; v2.31.2-1 adds GPI=4 and
EFA_GDA=5. So the mode the merged benchmark measures does not exist in the NCCL this image
builds — the CPU-proxy path here is structural, not a tuning choice (EFA 1.48.0's libfabric 2.4
locks it in a second time).

To be fair to the choice: the benchmark's own README legitimises CPU-proxy — "DeepEP's kernels
can also run over the CPU-proxy GIN backend, which has no such floor"
— so its NCCL ≥ 2.31 /
EFA ≥ 1.50 floors are EFA-GDA floors and nothing here violates them. The issue is only that the
divergence is asserted as sameness rather than stated.

Impact. A reader who takes this sentence at face value will expect the benchmark's numbers and
env (NCCL_GIN_TYPE=5, EP_NCCL_ROOT_DIR, LD_PRELOAD) to transfer to this image, and they don't.

Suggestion. Either reword to name the difference, or converge (see the next two comments).

Suggested change
dispatch/combine benchmarks over EFA — including a DeepEP V2 benchmark on the same NCCL-GIN
substrate this test case uses — see
dispatch/combine benchmarks over EFA — including a DeepEP V2 benchmark on the EFA-GDA NCCL-GIN
backend (this test case runs the CPU-proxy one) — see

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the "same NCCL-GIN substrate" sameness claim is gone; the index now names the difference. The parent examples/training/nemo-rl/README.md:42 describes this folder as "DeepEP V2's NCCL-GIN CPU-proxy on AWS EFA", and the folder README (README.md:250) states explicitly that the merged micro-benchmarks/expert-parallelism benchmark "runs DeepEP V2 on the EFA-GDA NCCL-GIN backend (NCCL_GIN_TYPE=5), not this" folder's CPU-proxy one. Your table (2.30.4/type-2/CPU-proxy vs 2.31.2/type-5/EFA-GDA) was exactly right — the transports aren't interchangeable at this NCCL pin, and that's now stated rather than asserted as sameness. Thanks.

p5.48xlarge, rebuild Layer 4 with the define raised (documented one-liner in the issue) — not
baked here because it is a non-upstream one-off.
- **No performance numbers.** Dispatch/combine latency and GRPO throughput on this substrate are
future work; for kernel-level EP benchmarks on the same NCCL-GIN substrate see

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same "same substrate" claim in the folder README (sibling of the above)

Same wording, same correction — flagging it separately so both land. This one is doing more work
than the index copy: it sits under "No performance numbers" and effectively tells the reader that
the merged benchmark's figures characterise this stack. They characterise EFA-GDA on NCCL 2.31.

Suggested change
future work; for kernel-level EP benchmarks on the same NCCL-GIN substrate see
future work; for kernel-level EP benchmarks over EFA (on the EFA-GDA GIN backend, not this
folder's CPU-proxy one) see

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — same correction applied in the folder README's "No performance numbers" section. README.md:250 now reads that the merged benchmark's figures characterise the EFA-GDA backend (NCCL_GIN_TYPE=5), "not this" folder's CPU-proxy stack, so a reader no longer infers those numbers transfer here. You were right that this copy was doing more work than the index line — it sat under the perf section and implied the benchmark's figures described this stack. Thanks for flagging both so they'd both land.

# compiled against the GIN-capable NCCL under $NCCL_HOME. Run AFTER
# the optional draft-PR patch layer so patched kernels compile in.
#
# Distinct from the NVSHMEM-path setup_deepep_efa.sh (vendor-synced via

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The no-vendor-sync rationale answers about the V1 script; the V2 canonical is setup_deepep_gin.sh

Observation. This header explains why the script is not vendor-synced by contrasting it with
setup_deepep_efa.sh — the V1 / NVSHMEM script that
deepep-vendor-sync.yml
guards. That's the wrong comparand. The script this one parallels is
micro-benchmarks/expert-parallelism/deepep-v2-benchmark/setup_deepep_gin.sh
the V2 / NCCL-GIN builder — which this PR never mentions.

Impact. The repo now has two V2 DeepEP builders whose only stated relationship is to a third,
unrelated script. setup_deepep_gin.sh is transport-agnostic at build time (it validates
nccl_device.h and builds; it does not care whether you later run GIN type 2 or 5), and it already
supports exactly this folder's need — --deepep-src /opt/DeepEP to build the possibly-patched tree
in place, plus --nccl-root $NCCL_HOME. I checked its preconditions against your pinned tree at
01dc3aa: csrc/kernels/backend/nccl.cu is present (its V2 marker) and setup.py reads
EP_NCCL_ROOT_DIR, so it would accept this tree as-is.

One genuine adaptation is needed and worth naming: the canonical runs
pip install --no-build-isolation . while you need --no-deps so deep_ep's metadata cannot pull
a second torch/NCCL into the NGC image. That reads like a flag to add upstream, not a reason to
fork the script.

Suggestion. Reuse setup_deepep_gin.sh for the deepep phase (keeping your ofi phase
here), or — if the bespoke builder stays — retarget this comment at the script it actually
parallels and say why it isn't reused.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the no-vendor-sync rationale is retargeted at the correct comparand. setup_nemo_rl_deepep_efa.sh:17 now names micro-benchmarks/expert-parallelism/deepep-v2-benchmark/setup_deepep_gin.sh as the V2/NCCL-GIN builder this script parallels, and line 20 states it compiles "the same amazon-contributing/DeepEP fork the canonical setup_deepep_gin.sh clones". Lines 29-31 clarify that only the V1 NVSHMEM-path setup_deepep_efa.sh copies are vendor-synced (so the canonical setup_deepep_gin.sh is not in that sync either), which is the accurate framing of why this script isn't guarded by deepep-vendor-sync.yml. You were right that contrasting against the V1/NVSHMEM script was the wrong comparand. We kept the bespoke builder (it needs --no-deps so deep_ep's metadata can't pull a second torch/NCCL into the NGC image) rather than reusing the canonical, and the comment now says so. Thanks.

# ElasticBuffer + NCCL backend, merged upstream in PR#605) and is the EXACT
# base of draft PR deepseek-ai/DeepEP#612, so the opt-in layer applies
# --check-clean. NOT the amazon-contributing fork: baseline is stock upstream.
ARG DEEPEP_SHA=01dc3aaac82068020353dce2c302e38153c0bfaa

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pinning stock upstream at exactly the AWS fork point forfeits the fixes for the traps you document

Observation. amazon-contributing/DeepEP main is 01dc3aa plus eight AWS commits — so
DEEPEP_SHA=01dc3aa is precisely the fork point with none of the delta. The Dockerfile's stated
reason ("the EXACT base of draft PR #612, so the opt-in layer applies --check-clean") holds
mechanically, but the fork already addresses both trap-2 failure modes, structurally rather than by
clamping:

Failure mode this PR, via draft #612 fork
get_rdma_gbs() reads 0 on EFA — at 01dc3aa it shells out to ibstat, which does not enumerate EFA's rdmap*s0 HCAs (deep_ep/utils/envs.py:258) hardcoded EP_EFA_RDMA_GBS=25.0 fallback _get_sysfs_rdma_gbs() reads /sys/class/infiniband/<nic>/ports/*/rate, provider-agnostic, with device auto-discovery (78ae4e5c)
auto-QP overruns aws-ofi-nccl's 128-slot GIN ring clamp num_allocated_qps → EP_EFA_MAX_QPS=2 GIN QP allocation restructured in C++ — gin_resource_alloc.cuh (+170), qp_mapping.cuh (+123), proxy_ring.cuh (+89) — plus 8e7b42e9 fixing the divide-by-zero in that resolution path

The performance envelopes differ materially: EP_EFA_MAX_QPS=2 against the fork's 11 QPs per GIN
context on the unordered path.

In fairness on timing: most of those fork commits are dated 2026-08-21/25 — days old, some
same-day as this PR — so "not yet visible" is the likely story rather than a considered rejection.
But the freeze-at-the-fork-point rationale is self-defeating either way: it holds the tree back to
keep a draft patch applying cleanly, at the cost of the merged fixes that supersede that draft.

Suggestion. Move DEEPEP_SHA to amazon-contributing/DeepEP and drop #612 from the opt-in
layer as superseded. If the CPU-proxy Wave-28 lineage must be preserved exactly, say so here and in
the pins table so the next reader knows the fork was considered.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional, and 72d66ad documents why. DEEPEP_SHA=01dc3aa is stock upstream deepseek-ai/DeepEP main, chosen deliberately: (1) it is the exact --check-clean base of draft PR deepseek-ai/DeepEP#612 in the opt-in patches/ layer, and (2) it keeps this folder to the no-fork-base convention the sibling samples follow. You are right that the amazon-contributing fork (01dc3aa + ~8 AWS commits) fixes the trap-2 failure modes structurally; on baseline we instead clamp the same behavior via the inert ENV defaults (or #612 when opted in). Adopting the fork's structural fixes is a tracked future re-pin + re-measure (README trap 2 + the pins table call this out), not a silent freeze. Happy to switch the baseline to the fork if you would prefer the structural fix be the default here.

@KeitaW KeitaW left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Batch 3/6 — Pins, provenance, and knobs that do nothing

The pins table justifies every entry, which is exactly why the rows below are worth tightening
rather than waving through. All inline.

| Base image | `nvcr.io/nvidia/pytorch:26.02-py3` | Same NGC base as the slime RL sibling. Bakes torch 2.11/CUDA 13 with TransformerEngine/apex/flash-attn compiled against that exact ABI — what megatron.core's H100 path needs. |
| EFA installer | 1.48.0 | The userspace of the measured NCCL-GIN substrate. Bumping is a re-measure event. |
| NCCL | `v2.30.4-1` (source build) | GIN device API generation (`nccl_device.h` asserted at build). Same NCCL line the NGC base bakes — the ld.so.conf override introduces no drift; source-built so DeepEP has one controlled header/lib root. |
| aws-ofi-nccl | commit `9c44d34` + PR#1351 head `c2e773d` | The GIN CPU-proxy plugin pins of the measured substrate (same as the TensorRT-LLM NcclEP sibling). Immutable SHAs — `refs/pull/N/head` is a moving ref. |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two aws-ofi-nccl pins described as "the measured substrate" postdate the measurement

Observation. The measured run is dated 2026-05-06 ("Wave 28"). Checking each pin's commit date:

Pin Commit date vs 2026-05-06
NCCL v2.30.4-1 2026-04-22 predates ✓
NeMo-RL cc75cad 2026-05-05 predates ✓
aws-ofi-nccl 9c44d34 2026-05-25 +19 days
aws-ofi-nccl c2e773d (#1351 head) 2026-08-13 +99 days

(verified live 2026-08-25 via the GitHub commit API.)

Impact. Neither aws-ofi-nccl SHA can be "the pins of the measured substrate," and the
"bumping it is a re-measure event" freeze rests on that description. A future maintainer told these
are frozen-because-measured will hesitate to bump pins that were never in the measured run. Note
the DeepEP and Megatron rows do not have this problem — they're justified as draft-PR bases, not
as measured, which is the right shape.

Suggestion. Re-describe this row as "the GIN CPU-proxy plugin lineage this folder standardises
on" (or give the plugin SHA the run actually used), and keep the measured-substrate wording only
for rows where it's true.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the aws-ofi-nccl row is no longer described as "the measured substrate". README.md:52 now reads: "The GIN CPU-proxy plugin lineage this folder standardises on (same pins as the TensorRT-LLM NcclEP sibling). These SHAs postdate the Wave-28 run, so they are the standardised lineage, not that run's exact pins." Your dated-commit table was the decisive evidence — 9c44d34 (2026-05-25) and c2e773d (#1351 head, 2026-08-13) both postdate the 2026-05-06 run, so calling them measured-substrate pins was wrong, and the freeze rationale is re-based on "standardised lineage" instead. Thanks.

# sibling sample). Immutable SHAs — refs/pull/N/head is a moving ref.
ARG AWS_OFI_NCCL_SHA=9c44d34476f90ddbf4a12d0ac4fc412d46bd8ab4
ARG AWS_OFI_NCCL_PR=1351
ARG AWS_OFI_NCCL_PR_SHA=c2e773dfb2c75b765b3415f8ffd1b47e7c239a7b

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The baseline image unconditionally cherry-picks a closed, unmerged upstream PR

Observation. AWS_OFI_NCCL_PR_SHA defaults non-empty, and
setup_nemo_rl_deepep_efa.sh cherry-picks it whenever the variable is set — so every build,
including the default baseline, applies aws/aws-ofi-nccl#1351. That PR is closed and unmerged
(opened 2026-08-13; state closed, merged: false, verified live 2026-08-25). The README's
retirement story — the patch layer "self-neutralizes once merged upstream" — can never fire for a
closed PR, and this sits under the headline claim that the baseline image has zero dependence on
unmerged PRs. That claim is true for the four draft PRs in patches/; it isn't true for the plugin.

It does work today, and that's worth saying: I replayed the exact sequence — git fetch origin 9c44d34… then git cherry-pick c2e773d… — and it applies clean across the three-month gap,
with both fail-loud asserts passing (FALLBACK_V1_FOR_GDRDRV_24 present at the pin,
GDRCOPY_FORCED_PCIE_COPY present after). So this is a provenance and retirement-path question,
not a build break.

Separately, the PR description calls #1351 "GIN CPU-proxy support (pinned at immutable head)".
Its actual title is "gdrcopy: add OFI_NCCL_GDRCOPY_FORCED_PCIE_COPY override for the forced-PCIe
capability probe"
. Your in-repo comments (this file, the setup script, the pins table) describe it
correctly — only the PR body is off, worth fixing so reviewers aren't looking for a GIN feature.

Suggestion. Either scope the cherry-pick to the opt-in flavor (APPLY_DRAFT_ROLLOUT_PATCHES=1),
or keep it in the baseline with a note here that #1351 is closed-unmerged and what retires it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — 72d66ad states this explicitly. AWS_OFI_NCCL_PR_SHA=c2e773d cherry-picks aws/aws-ofi-nccl#1351 (the OFI_NCCL_GDRCOPY_FORCED_PCIE_COPY capability override) on every build, including baseline. You are correct it is closed-unmerged: unlike the four draft PRs in patches/ (which self-neutralize once merged), this one is a PERMANENT baseline carry — the GIN CPU-proxy plugin lineage this folder and the TRT-LLM NcclEP sibling standardize on. It applies clean across the ~3-month gap with both fail-loud asserts passing. Retire condition: rebase onto an aws-ofi-nccl release that carries the override, when one ships. If carrying a closed PR on baseline is a blocker, I can gate it behind the opt-in layer instead — your call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision made — no longer "your call": the cherry-pick stays in the baseline (I'm not gating it behind the opt-in layer), and I've fixed the two accuracy gaps you named.

Why keep it in baseline, not opt-in: #1351 is the load-bearing GIN CPU-proxy plugin lineage this whole substrate standardizes on (same pins as the TRT-LLM NcclEP sibling). Its OFI_NCCL_GDRCOPY_FORCED_PCIE_COPY override is what lets the forced-PCIe gdrcopy path init on the hosts this folder targets. Moving it behind APPLY_DRAFT_ROLLOUT_PATCHES=1 would make the default image not stand up the substrate it documents — technically wrong. Unlike the four draft PRs in patches/ (which self-neutralize once merged), this is a permanent baseline carry; its retire condition is a rebase onto an aws-ofi-nccl release that ships the override, not a merge of the PR.

The two fixes (both pushed):

  1. README claim (4dc63a6). You were right that "baseline (upstream-only)" over-claims. The default image does carry unmerged/forked lineage — #1351 and the amazon-contributing/DeepEP fork, both already in the Pins table. I've dropped "upstream-only" from the headline and the build snippet and stated explicitly what the baseline carries; "baseline" now means only "the 2 draft rollout PRs OFF."
  2. PR-description mislabel. You caught that the PR body called #1351 "GIN CPU-proxy support" — its real title is "gdrcopy: add OFI_NCCL_GDRCOPY_FORCED_PCIE_COPY override for the forced-PCIe capability probe". Fixed in the PR body: it now names the override correctly, marks it closed-unmerged, and states the rebase-to-release retire path. (The in-file comments you noted were already correct.)

The patches/ retirement story ("self-neutralizes once merged") now applies only to the four draft PRs it was ever true for; #1351 is documented separately as the permanent carry it is. Thanks for pinning this one down — the claim needed to match the build.

|---|---|---|
| Base image | `nvcr.io/nvidia/pytorch:26.02-py3` | Same NGC base as the slime RL sibling. Bakes torch 2.11/CUDA 13 with TransformerEngine/apex/flash-attn compiled against that exact ABI — what megatron.core's H100 path needs. |
| EFA installer | 1.48.0 | The userspace of the measured NCCL-GIN substrate. Bumping is a re-measure event. |
| NCCL | `v2.30.4-1` (source build) | GIN device API generation (`nccl_device.h` asserted at build). Same NCCL line the NGC base bakes — the ld.so.conf override introduces no drift; source-built so DeepEP has one controlled header/lib root. |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Same NCCL line the NGC base bakes" — the base's own NCCL_VERSION reads 2.29

Observation. Pulling the image config for nvcr.io/nvidia/pytorch:26.02-py3 from the registry
(verified live 2026-08-25), the base's own environment reads:

NCCL_VERSION=2.29.stable.20260109
AWS_OFI_NCCL_VERSION=1.17.0

so the source build at v2.30.4-1 is a different minor line, not the same one. The same sentence
appears in the Dockerfile comment above ARG NCCL_VERSION.

Impact. The mechanism is right and the finding makes it more necessary, not less: because
there really is version drift, /etc/ld.so.conf.d/00-nccl-gin.conf and verify-image.sh's
"2.30.4" string check are load-bearing, and a reader told "no version drift" may conclude they're
belt-and-braces and drop one. (Same shape as the CUDA-13.1 rationale caught on #1234 — the
requirement was right, only the reason needed a word changed.)

Suggestion.

Suggested change
| NCCL | `v2.30.4-1` (source build) | GIN device API generation (`nccl_device.h` asserted at build). Same NCCL line the NGC base bakes — the ld.so.conf override introduces no drift; source-built so DeepEP has one controlled header/lib root. |
| NCCL | `v2.30.4-1` (source build) | GIN device API generation (`nccl_device.h` asserted at build). The NGC base bakes NCCL 2.29 (`NCCL_VERSION=2.29.stable.20260109`), so the `00-nccl-gin.conf` priority is what makes this build win at run time; source-built so DeepEP has one controlled header/lib root. |

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the NCCL row now acknowledges the base drift you found, and the mechanism it makes load-bearing. README.md:51: "The NGC base bakes an OLDER NCCL line (2.29.x), so this source-built copy is a deliberately newer line and the 00-nccl-gin.conf ld.so.conf entry is load-bearing — it makes this GIN-capable build win the loader search over the base's baked copy (verify-image.sh asserts which one resolves)." The Dockerfile comment above ARG NCCL_VERSION (nemo-rl.Dockerfile:42-43) carries the same correction. You were right — the version drift is real, so the 00-nccl-gin.conf priority and the resolved-path check are load-bearing, not belt-and-braces. Thanks.

NCCL_GIN_TYPE=2 \
NCCL_GIN_ENABLE=1 \
OFI_NCCL_GIN_GDAKI=0 \
OFI_NCCL_GIN_MAX_REQUESTS=512 \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OFI_NCCL_GIN_MAX_REQUESTS is not a parameter this plugin has — the knob is inert in all five places it's set

Observation. Grepping the pinned aws-ofi-nccl tree (9c44d34 + the #1351 cherry-pick), the GIN
parameters that exist are:

OFI_NCCL_PARAM(size_t, gin_cq_process_max_iter, "GIN_CQ_PROCESS_MAX_ITER", 4);
OFI_NCCL_PARAM(bool,   gin_gdaki,               "GIN_GDAKI",           false);
OFI_NCCL_PARAM(bool,   gin_strong_signal,       "GIN_STRONG_SIGNAL",    true);
OFI_NCCL_PARAM(bool,   gdrcopy_forced_pcie_copy,"GDRCOPY_FORCED_PCIE_COPY", false);

There is no GIN_MAX_REQUESTS. I checked the other three OFI knobs this PR sets and they all
resolve — OFI_NCCL_PROTOCOL and OFI_NCCL_GIN_GDAKI exist; only this one does not.

Impact. OFI_NCCL_GIN_MAX_REQUESTS=512 is set in the Dockerfile ENV, env_vars.example,
kubernetes/raycluster.yaml, run-rollout-probe.sh and train-step.sh, and the README documents
it as "GIN request-ring depth of the measured substrate". An operator reading that will believe the
ring is 512 deep — while trap 2, two sections earlier, correctly says the ring is 128 slots and
builds the whole explicit-QP workaround around that number. So the two statements contradict each
other and the one that reads like a remedy does nothing.

Suggestion. Drop it from all five files, and if the depth genuinely needs tuning, say which
supported control does it (NCCL's own proxy queue-size parameter is capped by the plugin's
maximum outstanding requests, so it can lower the queue but not raise it past the built-in cap).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FixedOFI_NCCL_GIN_MAX_REQUESTS dropped from all five files. grep -rn OFI_NCCL_GIN_MAX_REQUESTS across the folder now returns zero hits (the Dockerfile ENV, env_vars.example, kubernetes/raycluster.yaml, run-rollout-probe.sh, and train-step.sh are all cleared), and the README no longer documents a 512-deep ring. You were right that it contradicted trap 2's 128-slot ring and that the knob does not exist in the pinned aws-ofi-nccl tree — so removing it (rather than documenting an unsupported control) was the correct call. Thanks for catching the contradiction.

# CUDA 13 with TransformerEngine, apex and flash-attn compiled against that
# exact ABI — which is what megatron.core's H100 path needs; rebuilding any of
# those from PyPI against the baked torch is where images usually go wrong.
ARG NGC_PYTORCH_BASE=nvcr.io/nvidia/pytorch:26.02-py3

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The base image and NCCL are the only dependencies not held to this file's own pinning standard

Observation. This Dockerfile is unusually disciplined about pinning — gdrcopy, aws-ofi-nccl,
DeepEP, Megatron-LM and NeMo-RL are all immutable SHAs, and the comments explain why
(gdrcopy v2.5.2 == commit c91ad9f: commit pin, not tag (a bare tag is a moving ref upstream can re-point)). Two dependencies sit outside that standard:

  • nvcr.io/nvidia/pytorch:26.02-py3 — a tag NVIDIA can and does re-push; and it is the ABI anchor
    the whole image is built around, so a silent re-push is the most consequential drift possible
    here.
  • NCCL_VERSION=v2.30.4-1 — a git tag, checked out by git checkout ${NCCL_VERSION}.

Impact. Reproducibility, and specifically a cache-miss rebuild months from now producing a
different substrate than the one the pins table describes — the exact failure the file's own
comments are written to prevent.

Suggestion. Pin the base by digest (nvcr.io/nvidia/pytorch:26.02-py3@sha256:…) and NCCL by
the tag's commit SHA, so the file holds every dependency to the standard it already states. Both
are one-line changes and neither alters what gets built today.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — both the base and NCCL are now held to the file's own pinning standard. nemo-rl.Dockerfile:30 pins the base by digest: nvcr.io/nvidia/pytorch:26.02-py3@sha256:bbc2b67e2533edd63ff1496bb1ed00a00338cdc1478af6c1a0bf9f4b369977e7, and ARG NCCL_VERSION (line 48) is now the commit 1933fdd6360a8bfccaa0166bd71bce363d32e5b6 (= v2.30.4-1) rather than the git tag. You were right that these were the two deps outside the SHA-pinning discipline the rest of the file already applied — and that the base tag is the most consequential drift possible since it's the ABI anchor. Thanks.

@KeitaW KeitaW left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Batch 4/6 — The documented recipe never reaches the path it advertises

Five findings in the Quick Start and the env contract. Individually small; together they mean that
following the README exactly runs a different thing than the README says it runs — and that the
draft-PR flavor, which is the whole point of the opt-in layer, has no route onto a node.

docker build -f nemo-rl.Dockerfile --build-arg APPLY_DRAFT_ROLLOUT_PATCHES=1 \
-t ${FULL_IMAGE}-draftprs .

docker push ${FULL_IMAGE}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The draft-PR image is built but never pushed or deployed — the documented flex path is unreachable

Observation. Step 2 builds two images — ${FULL_IMAGE} and ${FULL_IMAGE}-draftprs — but
this push line ships only the first. Step 3 then gates ${FULL_IMAGE}, and step 4 deploys
envsubst < kubernetes/raycluster.yaml, whose image: is ${FULL_IMAGE}. Nothing in the Quick
Start ever pushes or deploys -draftprs.

Impact. Following the README exactly, the cluster only ever runs the upstream-only baseline. So
step 6's MOE_DISPATCHER=flex /opt/train-step.sh leader … lands on an image with no patch marker
and hits the refusal guard (exit 4) — and step 7's full GRPO path, which needs the
aws-efa-grpo-qwen3-30ba3b-2n8g-megatron.yaml recipe that only the patched image carries, cannot
start at all. The refusal is good design; it just means the draft flavor has no documented route
onto a node.

Suggestion. Give the draft flavor its own variable and its own deploy, so the two images never
share a tag:

Suggested change
docker push ${FULL_IMAGE}
docker push ${FULL_IMAGE}
# opt-in flavor — distinct tag, pushed and deployed separately
export DRAFT_IMAGE="${FULL_IMAGE}-draftprs"
docker push ${DRAFT_IMAGE}
# deploy it with: FULL_IMAGE=${DRAFT_IMAGE} envsubst < kubernetes/raycluster.yaml | kubectl apply -f -

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 72d66ad. The README now has docker push ${FULL_IMAGE}-draftprs alongside the base push (README:162), so the documented -draftprs flex (DeepEP-V2 ElasticBuffer) path at step 7 is reachable.

kubectl -n ${NAMESPACE} exec ${W0} -c ray-worker -- /opt/train-step.sh leader ${W0_IP}
# ... TRAIN-STEP-PASS dispatcher=alltoall world=16 ep=16
# on the -draftprs image:
# MOE_DISPATCHER=flex /opt/train-step.sh leader ${W0_IP}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MOE_DISPATCHER=flex is set on the leader only — the worker would still build alltoall

Observation. The worker is launched three lines above with no MOE_DISPATCHER, so
train-step.sh defaults it to alltoall; this line sets flex for the leader alone. Because
train_moe_step.py feeds moe_token_dispatcher_type=DISPATCHER and
moe_enable_deepep=(DISPATCHER == "flex") straight into TransformerConfig, the two nodes would
construct different MoE dispatchers inside one process group.

Impact. Ranks 0-7 drive DeepEP's ElasticBuffer dispatch/combine while ranks 8-15 drive
Megatron's stock all-to-all — mismatched collective sequences, so the run hangs at the first MoE
exchange until the NCCL timeout rather than testing flex. On 2×p5.48xlarge that's 16 GPUs idling
for the timeout window.

Latent today, and worth saying so: because of the previous comment, the leader is on the
baseline image and exits 4 at the refusal guard before any of this can happen. Fixing the push/
deploy gap is what makes this reachable — so please fix both together.

Suggestion. Set the dispatcher identically on both nodes:

Suggested change
# MOE_DISPATCHER=flex /opt/train-step.sh leader ${W0_IP}
# worker: nohup env MOE_DISPATCHER=flex /opt/train-step.sh worker ${W0_IP} 1 > /tmp/train.log 2>&1 &
# leader: env MOE_DISPATCHER=flex /opt/train-step.sh leader ${W0_IP}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FixedMOE_DISPATCHER=flex is now set identically on both nodes. README.md:214 states "MOE_DISPATCHER=flex on BOTH nodes (it is one torchrun job across both...)", with the worker (line 217) and leader (line 219) launch lines both carrying it. You were right that setting it leader-only would build DeepEP ElasticBuffer on ranks 0-7 and stock all-to-all on 8-15 in one process group → hang at the first MoE exchange until the NCCL timeout. Thanks for catching both this and the latent push/deploy gap it depended on.

# ----- Cluster -----
export NAMESPACE="nemo-rl-deepep"
export FSX_CLAIM="fsx-claim"
export NUM_NODES=2

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NUM_NODES here is never read by anything — the launchers read NNODES, and neither crosses kubectl exec

Observation. Two independent breaks in the same chain:

  1. Name mismatch. This file exports NUM_NODES; run-rollout-probe.sh:19 and
    train-step.sh:19 read NNODES="${NNODES:-2}". NUM_NODES is consumed only by
    raycluster.yaml's replicas/minReplicas/maxReplicas. No file bridges the two names.
  2. No propagation. This file's own header says these are exported "so ad-hoc shells
    (kubectl exec) carry the same contract" — but a client-side export does not cross
    kubectl exec; only the container's own environment does. I grepped raycluster.yaml: it
    defines zero of NNODES, GPUS_PER_NODE, EP_EXPERTS, EP_TOPK, EP_HIDDEN,
    EP_TOKENS, EP_NUM_SMS, EP_NUM_QPS. Only the transport-contract vars are in the pod env.

Impact. Every documented gate runs on the hardcoded script defaults, whatever the operator put
in env_vars. Set NUM_NODES=4 and you get four worker pods, while the leader still opens a
--nnodes 2 --nproc-per-node 8 rendezvous — ranks on workers 2 and 3 never join, and the probe
either hangs in init_process_group or silently certifies 2 of your 4 nodes. The EP_* shape
knobs this file documents as tunable are dead on the kubectl exec path for the same reason —
including EP_NUM_QPS, which trap 2 says is load-bearing on EFA.

Suggestion. Put them in the container env so the pod carries the contract (matching how the
transport vars are already handled), and rename to the name the scripts actually read:

                - { name: NNODES,          value: "${NUM_NODES}" }
                - { name: GPUS_PER_NODE,   value: "${GPUS_PER_NODE}" }
                - { name: EP_EXPERTS,      value: "${EP_EXPERTS}" }
                - { name: EP_TOPK,         value: "${EP_TOPK}" }
                - { name: EP_HIDDEN,       value: "${EP_HIDDEN}" }
                - { name: EP_TOKENS,       value: "${EP_TOKENS}" }
                - { name: EP_NUM_SMS,      value: "${EP_NUM_SMS}" }
                - { name: EP_NUM_QPS,      value: "${EP_NUM_QPS}" }

and correct the header line here, since sourcing this file cannot affect a pod.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5df9552. Root cause: the launchers read NNODES while env_vars exported NUM_NODES (only consumed by the manifest replica count), and nothing bridged them across kubectl exec. raycluster.yaml now emits { name: NNODES, value: "${NUM_NODES}" } so both the replica count and the in-pod NNODES derive from the single NUM_NODES you set; env_vars.example:44-48 documents the bridge.

volumes:
- name: fsx
persistentVolumeClaim:
claimName: ${FSX_CLAIM}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FSx PVC is mounted unconditionally, though the README says the gates need no shared storage

Observation. Both the head and the workers mount ${FSX_CLAIM} with no condition, while the
README's hardware section says: "FSx for Lustre PVC (full GRPO path only — the recipe gates need
no shared storage)"
, and the file-structure section repeats it for data-prep-pod.yaml.

Impact. A reader who takes the README at its word and skips the PVC gets pods stuck Pending
on an unbound claim — and neither the rollout probe nor the train gate, the two things this folder
says you can run cheaply, ever starts. The advertised low-cost entry point is the one blocked.

Suggestion. Either make the /fsx mount conditional on the full-GRPO path (a separate overlay,
or an emptyDir default the GRPO instructions swap out), or drop the "gates need no shared
storage" claim and list the PVC as a hard prerequisite. Given the gates genuinely don't touch
/fsx, the first is the more useful of the two.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the /fsx mount now defaults to emptyDir, so the recipe gates run without shared storage as the README promised. kubernetes/raycluster.yaml:96,98 default both the head and worker /fsx to emptyDir, with the FSx persistentVolumeClaim swap-in commented for the full-GRPO path (lines 93-94, 221-222). You were right — mounting ${FSX_CLAIM} unconditionally left pods Pending on an unbound claim, blocking exactly the low-cost entry points (rollout probe + train gate) the folder advertises as cheap to run. Took your first option (conditional mount) as the more useful one. Thanks.

### 3. Gate the image before any cluster deploy

```bash
recipe/verify-image.sh ${FULL_IMAGE}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scripts ship non-executable, so this command fails with Permission denied

Observation. Every file in this PR is committed mode 100644 — including all five recipe/
scripts. This Quick Start line invokes one directly by path.

Impact. Step 3, the "gate the image before any cluster deploy" step, fails immediately with
bash: recipe/verify-image.sh: Permission denied before any check runs. The in-image copies are
fine (the Dockerfile chmod 755s them in Layer 10), so this affects only the host-side gate — but
that gate is the first thing the README tells you to run.

Suggestion. Commit the five recipe/*.sh scripts (and setup_nemo_rl_deepep_efa.sh) mode
100755:

git update-index --chmod=+x 3.test_cases/pytorch/nemo-rl/deepep-v2-efa/recipe/*.sh \
                            3.test_cases/pytorch/nemo-rl/deepep-v2-efa/setup_nemo_rl_deepep_efa.sh

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in af2bf8f. The four shell entrypoints (setup_nemo_rl_deepep_efa.sh, recipe/train-step.sh, recipe/run-rollout-probe.sh, recipe/verify-image.sh) are now committed mode 100755, so the Quick Start recipe/verify-image.sh ${FULL_IMAGE} invocation by path no longer hits Permission denied. The .py launchers stay 644 by design (run via python3/torchrun, never by path).

@KeitaW KeitaW left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Batch 5/6 — What the gates actually prove

The recipe scripts are the strongest part of this PR, and the rollout probe in particular is real
evidence rather than a smoke test. These findings are about the distance between what each gate
checks and what the docs say it certifies.

rc=${PIPESTATUS[0]}

if [ "$rc" -eq 0 ]; then
echo "TRAIN-STEP GATE PASS (node_rank=$NODE_RANK, dispatcher=$MOE_DISPATCHER)"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The train gate prints PASS on exit status alone — it never checks that EFA carried the traffic

Observation. run-rollout-probe.sh is careful here: it snapshots the EFA hardware TX counters,
requires a >= 1 MiB delta, greps for a provider-specific banner, and has a distinct INCONCLUSIVE
exit. train-step.sh has none of that — grepping it for efa-direct, Selected Provider,
hw_counters or TX_DELTA returns zero hits. PASS is printed on rc -eq 0 and nothing else.

Impact. NCCL will happily complete this small synthetic workload over its TCP socket transport
if EFA never initialises — 2 layers, seq 256, micro-batch 2, 3 steps is well within what sockets
finish quickly. The gate would print TRAIN-STEP GATE PASS and the README (step 6, and the
measured-vs-staged summary) describes that result as an MoE train step "over EFA". That is exactly
the silent-fallback case the rollout probe was written to rule out, left open on the sibling gate.

Suggestion. Reuse the probe's evidence in this script — the efa_tx_total() helper and the
banner grep transplant directly, and NCCL_DEBUG is already a knob here (TRAIN_NCCL_DEBUG).
Sampling the counters around the torchrun call and requiring a delta plus a provider banner would
make the two gates make the same promise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the train gate now checks EFA carried the traffic, not just exit status. recipe/train-step.sh:58 snapshots the same {tx_bytes,send_bytes,rdma_write_bytes,rdma_read_resp_bytes} families, computes TX_DELTA (lines 63-74), requires >= 1 MiB on multi-node (line 84, FAIL below), and greps for an efa-direct|Selected Provider is efa banner (line 91) with a distinct INCONCLUSIVE exit (line 95) — transplanted from the rollout probe as you suggested. You were right that this was the exact silent-TCP-fallback case the rollout probe was written to rule out, left open on the sibling gate; the two gates now make the same promise. Thanks.

fac = ((topk_idx.to(torch.float32) + 1.0) / E) * topk_weights
expect = x.to(torch.float32) * fac.sum(1, keepdim=True)
got = combined_x.to(torch.float32)
relmax = ((got - expect).abs().max() / expect.abs().max().clamp_min(1e-6)).item()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

relmax normalises by a single global maximum, so a badly-corrupted small token can still pass

Observation. The tolerance is one scalar: the largest absolute error anywhere divided by the
largest expected magnitude anywhere. With x ~ N(0,1)/8 over TOK x H = 128 x 2048 elements, the
global maximum is several times the typical element, so relmax < 0.05 is a much weaker per-element
bound than it reads as. The companion nz check only asserts each row has some nonzero value, so
it does not catch a row that arrived scaled or partially wrong.

Impact. The probe's whole purpose is to be believed about a transport that can corrupt a subset
of tokens — a partial dispatch, a QP-ring overrun that drops a channel's worth of tokens, a
truncated combine. Those failures show up as a handful of badly wrong low-amplitude rows, which is
precisely the shape this metric averages away. (Related precedent worth a look: on #1234 an
intranode arm reported ~99.98% of elements wrong and the tell was in the throughput line, not the
tolerance.)

Suggestion. Score per token against that token's own scale, and keep a bf16-appropriate
absolute floor:

Suggested change
relmax = ((got - expect).abs().max() / expect.abs().max().clamp_min(1e-6)).item()
row_err = (got - expect).abs().amax(dim=1)
row_scale = expect.abs().amax(dim=1).clamp_min(1e-3)
relmax = (row_err / row_scale).max().item()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the probe now scores per token against that token's own scale with a bf16 floor, exactly as suggested. recipe/probe_rollout.py:143-145:

row_err = (got - expect).abs().amax(dim=1)
row_scale = expect.abs().amax(dim=1).clamp_min(1e-3)
relmax = (row_err / row_scale).max().item()

You were right that a single global-max normaliser averages away the exact failure the probe exists to catch — a handful of badly-wrong low-amplitude rows from a partial dispatch or a dropped channel. Per-row scoring makes relmax < 0.05 the per-element bound it reads as. Thanks.


finite = all(l == l and abs(l) != float("inf") for l in losses)
decreasing = losses[-1] < losses[0]
agree = spread < 1e-2

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cross-rank agreement gate only inspects the final step's spread

Observation. spread is reassigned every iteration and read once after the loop, so agree
reflects step STEPS-1 only. The module docstring states the gate as "cross-rank loss agreement —
every rank feeds the SAME seeded batch, so after the MoE all-to-all round-trip all ranks must
compute the SAME loss"
— i.e. a per-step invariant.

Impact. A transport fault that corrupts an early step's all-to-all and then clears (or whose
effect shrinks as the loss falls) still prints TRAIN-STEP-PASS. That's the more likely shape of an
intermittent-fabric problem, which is the thing this gate exists to notice. Minor sibling: with
TRAIN_STEPS=1 — a knob the header documents — losses[-1] < losses[0] is trivially false and
spread is at least defined, but TRAIN_STEPS=0 leaves spread unbound and losses[-1] raising
IndexError.

Suggestion.

Suggested change
agree = spread < 1e-2
agree = max_spread < 1e-2

with max_spread = 0.0 before the loop and max_spread = max(max_spread, spread) inside it, plus
an assert STEPS >= 2 next to the other argument checks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the agreement gate now tracks the max spread across all steps and requires STEPS >= 2. recipe/train_moe_step.py:187 initialises max_spread = 0.0, line 214 does max_spread = max(max_spread, spread) inside the loop, line 221 is agree = max_spread < 1e-2, and line 85 asserts STEPS >= 2. You were right that reading spread once after the loop inspected only the final step — and that an intermittent-fabric fault that corrupts an early all-to-all and then clears is the more likely shape, precisely what a final-step-only check misses. The STEPS>=2 assert also closes the TRAIN_STEPS=0 unbound-spread/IndexError edge you noted. Thanks.

fi

echo "== single GIN-capable libnccl wins (path + version string — the NGC base bakes its own NCCL in a DIFFERENT dir, so path matters) =="
NCCL_SO=$(ldconfig -p | grep "libnccl.so.2 " | head -1 | awk "{print \$NF}")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ldconfig -p | head -1 reads the cache order, not what the loader resolves

Observation. README trap 3 says verify-image.sh "asserts which path libnccl.so.2
resolves to." This line asserts the first entry of the ld.so cache. Those aren't the same
thing: the image also sets ENV LD_LIBRARY_PATH=${NCCL_HOME}/lib:…, and the dynamic loader
searches LD_LIBRARY_PATH before the cache — so the cache order isn't what decides resolution in
either direction.

Impact. Low, because the two agree in the built image today. But the check can't detect the
failure it exists to detect (an LD_LIBRARY_PATH that got scrubbed, or a LD_PRELOAD), while
reading as if it can. The adjacent version-string check on the resolved file is the load-bearing
one and it's good.

Suggestion. Two ways out, either is fine — they just need to agree:

  • Keep ldconfig and reword trap 3 to what it actually proves ("the ld.so cache prefers the GIN
    build"); or
  • Ask the loader instead of the cache. deep_ep's own check_nccl_so() already does exactly this
    at import — it reads /proc/self/maps for the mapped libnccl — so a python3 -c that imports
    torch and prints the libnccl line from its own maps gives you the resolved path, and (nice
    bonus) it is the same evidence DeepEP itself will act on. Leaving this as prose rather than a
    one-click suggestion because it has to be quoted through the existing bash -lc '…' wrapper.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the check now asks the loader what it resolved, not the cache order. recipe/verify-image.sh:40 reads the mapped libnccl.so.2 from /proc/self/maps after a ctypes.CDLL load (the same evidence deep_ep's own check_nccl_so() acts on at import), replacing ldconfig -p | head -1. The comment (lines 34-36) explains the loader searches LD_LIBRARY_PATH before the cache, so the cache order wasn't what decided resolution. You were right that the old form couldn't detect the failure it existed to detect (a scrubbed LD_LIBRARY_PATH / a LD_PRELOAD) while reading as if it could. Thanks.

# left this node over EFA" assert (a PASS on SHM/TCP fallback would show delta≈0).
efa_tx_total() {
local total=0 v
for f in /sys/class/infiniband/*/ports/1/hw_counters/tx_bytes; do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tx_bytes alone may not count RDMA-write traffic — worth confirming before this assert can fail a good run

Observation. efa_tx_total() sums exactly one counter family. EFA's sysfs exposes several byte
counters side by side — this repo's own EFA exporter enumerates tx_bytes, send_bytes,
rdma_write_bytes, rdma_write_recv_bytes, rdma_read_bytes and rdma_read_resp_bytes under
/sys/class/infiniband/<dev>/ports/<port>/hw_counters — and the split matters here because the
image runs with FI_EFA_USE_DEVICE_RDMA=1, i.e. the path where bytes can be accounted as RDMA
writes rather than as sends.

Impact. If DeepEP's GIN CPU-proxy traffic lands on rdma_write_bytes, TX_DELTA stays under
the 1 MiB floor on a perfectly healthy run and the check below fails it with
"probe passed but EFA TX advanced only NB — transport was NOT EFA" — the most misleading possible
message, given the probe's oracle just confirmed the bytes arrived correctly. The failure mode is
one-directional (a false FAIL, never a false PASS), so this is robustness, not a correctness hole.

I could not close this one from the diff — it needs a run on an EFA host with these kernels,
which is what this folder hasn't had yet. Raising it because the counter delta is the load-bearing
evidence in the whole probe. If the Wave-28 run did show tx_bytes advancing on this path, a note
here saying so would settle it.

Suggestion. Summing the byte families costs nothing and removes the question:

Suggested change
for f in /sys/class/infiniband/*/ports/1/hw_counters/tx_bytes; do
for f in /sys/class/infiniband/*/ports/1/hw_counters/{tx_bytes,send_bytes,rdma_write_bytes,rdma_read_resp_bytes}; do

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixedefa_tx_total() now sums the byte-counter families, not tx_bytes alone. recipe/run-rollout-probe.sh:53 iterates {tx_bytes,send_bytes,rdma_write_bytes,rdma_read_resp_bytes} (with a comment at lines 46-47 explaining that under FI_EFA_USE_DEVICE_RDMA=1 the bytes can land on rdma_write_bytes rather than sends). You correctly flagged this as one-directional robustness (a false FAIL on a healthy run, never a false PASS) — and since the counter delta is the load-bearing evidence in the whole probe, summing the families removes the question at zero cost. We don't yet have the Wave-28 per-counter breakdown to annotate which family the traffic landed on, so we took the sum. Thanks.

lo, hi = RANK * num_local, RANK * num_local + num_local
sl = recv_topk_idx.to(torch.int64)
if sl.numel() > 0 and int(sl.max()) < num_local and E > num_local:
log("PROBE-NOTE recv_topk_idx looks local-mapped; offsetting by rank base")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "local-mapped" detector fires on rank 0 on every normal run

Observation. The guard is
if sl.numel() > 0 and int(sl.max()) < num_local and E > num_local. On rank 0, lo = 0 and
the local experts are 0 … num_local-1, so in the normal global-mapped case rank 0's received
ids are exactly 0..num_local-1 (plus the < 0 sentinels) and sl.max() < num_local is always
true. The branch therefore fires on rank 0 every run, prints
PROBE-NOTE recv_topk_idx looks local-mapped, and applies +lo = +0.

Impact. The offset is harmless (it's zero on the only rank that misfires, and correct on
rank ≥ 1 if DeepEP ever did switch mapping), so this is not a correctness bug — it's a diagnostic
that asserts something its own data can't distinguish, on a probe whose whole job is to be
believed. Someone debugging a real dispatch problem will chase that note.

Suggestion. Either drop the speculative branch (the repo's convention is to target the pinned
version directly rather than carry forward-compat shims), or make the discriminator rank-safe —
e.g. skip it on RANK == 0, where the two mappings are indistinguishable by construction, and say
so in the comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c455bb1. The 'local-mapped' detector guard fired on rank 0 on every normal run — rank 0's local experts are 0..num_local-1, so sl.max() < num_local is always true there. The guard is now if RANK > 0 and sl.numel() > 0 and int(sl.max()) < num_local and E > num_local (probe_rollout.py:117), so rank 0 no longer emits the false diagnostic.

# tokens by (e+1)/E, weights folded in during local compute, so the
# combined output must equal x * sum_k(((idx_k+1)/E) * w_k) per token —
# checkable without real weights.
idx_dtype = getattr(deep_ep, "topk_idx_t", torch.int64)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getattr(deep_ep, "topk_idx_t", torch.int64) — the attribute is unconditionally exported

Observation. At the pinned DEEPEP_SHA=01dc3aa, deep_ep/__init__.py ends with an
unconditional from deep_ep._C import Config, topk_idx_t — the attribute is not optional, and it
is not version-gated. So the torch.int64 default is unreachable on the image this folder builds.

Impact. If a future DeepEP did drop or rename topk_idx_t, this line would silently
substitute int64 and the probe would either fail deep inside dispatch with a dtype error or —
worse — pass against a different index width than the kernels expect. A plain attribute access
fails at the import with the real name.

Suggestion.

Suggested change
idx_dtype = getattr(deep_ep, "topk_idx_t", torch.int64)
idx_dtype = deep_ep.topk_idx_t

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — direct attribute access, no fallback. recipe/probe_rollout.py:92 is now idx_dtype = deep_ep.topk_idx_t. You were right that at DEEPEP_SHA=01dc3aa the attribute is exported unconditionally (from deep_ep._C import Config, topk_idx_t), so the torch.int64 default was unreachable and would only ever mask a future rename with a silently-wrong index width. A plain access fails at import with the real name. Thanks.

except Exception:
traceback.print_exc()
print(f"[rank{RANK}] PROBE-EXC", flush=True)
sys.exit(1)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A rank that raises exits without tearing down the process group, and no launcher bounds the run

Observation. The except handler prints a traceback and sys.exit(1) without
dist.destroy_process_group() or an abort, and dist.init_process_group("nccl", ...) is called
with no timeout=. train_moe_step.py has the same shape. Neither run-rollout-probe.sh nor
train-step.sh wraps torchrun in a timeout.

Impact. Peers sit in buf.combine, the MIN all_reduce or the final barrier until NCCL's
default timeout (30 minutes) rather than failing fast. torchrun's agent does reap a dead local
worker, so this is bounded in the normal case — but the documented flow runs the worker detached
(nohup … &) under kubectl exec with only the leader in the foreground, so a worker-side failure
surfaces to the operator as a leader that appears hung, on 16 GPUs.

Suggestion. Pass an explicit timeout=datetime.timedelta(minutes=10) to
init_process_group, destroy the group in a finally, and wrap the torchrun calls in
timeout 900 torchrun … so a wedged gate returns a verdict instead of holding the nodes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — explicit timeout, finally teardown, and a torchrun wall-clock bound. recipe/probe_rollout.py:59 passes timeout=datetime.timedelta(minutes=10) to init_process_group; lines 169-173 destroy the group in a finally; and the launchers wrap torchrun in timeout 900. train_moe_step.py got the same treatment. You were right that the documented detached-worker flow (nohup ... & under kubectl exec, leader in foreground) turns a worker-side failure into a leader that looks hung on 16 GPUs for NCCL's 30-min default — a bounded verdict is much better. Thanks.

try:
# Already present? (PR merged upstream and the pin moved past it.)
if run(["git", "apply", "--reverse", "--check", str(patch_path)], root).returncode == 0:
return "already-present-upstream"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One probe per multi-commit PR can skip commits that are not present [confirmed]

Observation. The module docstring states the design as PR-level: "each PR's content probe is
checked: if it already passes … the whole PR is skipped … (a per-commit reverse-check would
false-negative on multi-commit PRs whose later commits touch the same hunks)"
. But
apply_commit() still runs git apply --reverse --check per commit and returns
"already-present-upstream", skipping that commit individually.

Impact. The PR-level probe short-circuits first, so the per-commit check only runs in the state
where the PR is known absent — and there, skipping an individual commit because it reverse-applies
is exactly the false-negative the docstring says the design avoids. Nothing misbehaves on today's
pins (I replayed all four PR series — see the positives), but the next person to change this file
will be reading a docstring that describes a different algorithm than the one below it.

A second, sharper edge on the same mechanism. Each PR carries exactly one content probe, and
a hit skips all of that PR's commits. A partially-merged or cherry-picked upstream tree satisfies
the probe while still lacking the later commits: EP_EFA_MAX_QPS can be present (DeepEP#612's first
commit) while the get_rdma_gbs() EFA fast path and the dispatch-interval change are absent;
ElasticBuffer can appear in fused_a2a.py (Megatron#4632's first commit) while the
num_experts-to-backward-dispatch fix is not. The marker then records every commit of that PR as
already-present, and verify-image.sh's patch-marker check — which probes the same single
needle — agrees. So the image reports a patch state it does not have.

Suggestion. Give each independently-required change its own probe, skip a PR only when all
of its probes pass, and post-assert the full list before writing the marker. That also resolves the
docstring question above: with per-change probes, the per-commit reverse-check becomes redundant and
can go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — each independently-required change now has its own probe, a PR is skipped only when EVERY probe passes, and the full list is post-asserted. patches/apply_nemo_rl_patches.py docstring (lines 27-37): "ALL of a PR's probes are checked: only if EVERY probe... a PR partially present... is NOT skipped; its commits are applied", and "Post-asserted, on the full list. After a PR applies, EVERY one of its probes is re-checked before the marker is written." The probes field (line 59) is "ONE per independently-required" change, "deliberately per-CHANGE, not per-PR" (line 61) — with the reasoning citing exactly your EP_EFA_MAX_QPS-present-but-get_rdma_gbs()-absent example. The per-commit reverse-check is noted as redundant under the all-probes decision (line 35). Thanks — this was the sharpest of the batch.

@KeitaW KeitaW left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Batch 6/6 — Image hygiene, manifests, positives and sources

raycluster.yaml is careful — the ray.io/node-type anti-affinity uses the label KubeRay actually
applies (and sets it on the template too), Guaranteed QoS carries its CPU-proxy rationale,
privileged: true carries its gdrdrv rationale, and NCCL_SOCKET_IFNAME uses the exclusion form.
Most of what follows is in the smaller files.

Things That Look Great

Checked rather than assumed, all on 2026-08-25:

  • The opt-in patch layer does exactly what it claims. I replayed the script's logic against
    each pinned base: all 9 commit SHAs resolve (https://github.com/<repo>/commit/<sha>.patch
    → HTTP 200, valid From -prefixed git patches), and all four series apply --check-clean
    DeepEP#612 ×3 onto 01dc3aa, Megatron#4632 ×4 onto 19deef67, NeMo-RL#2410/#2411 onto
    cc75cad — with every post-assert satisfied (EP_EFA_MAX_QPS in elastic.py, ElasticBuffer
    ×11 in fused_a2a.py, the aws-efa-grpo-…-megatron.yaml recipe present, b306af0 ×4 in
    pyproject.toml). I also checked the #2411 needle is absent at the pinned base, so its
    self-neutralizer can't misfire.
  • git fetch origin <sha> rather than refs/pull/N/head. This is the immutable-pin form, and
    it holds: fetch-by-SHA succeeds against aws/aws-ofi-nccl for a commit not on any branch.
  • The draining $(… | grep -c …) form in the plugin asserts, rather than grep -q in a
    pipeline under pipefail — avoids the SIGPIPE-141 false failure on long nm -D output.
  • Every DeepEP call in probe_rollout.py matches the pinned API. ElasticBuffer.__init__
    accepts num_allocated_qps and explicitly_destroy; num_bytes / num_rdma_ranks /
    num_nvlink_ranks / destroy() all exist; dispatch returns the 5-tuple you unpack and takes
    num_sms/num_qps; combine returns the 3-tuple you unpack.
  • The explicit-SM/QP workaround is complete, including the half that's easy to miss. Passing
    num_sms to dispatch alone would be a half-fix if combine auto-sized — but combine at this
    pin takes num_sms = handle.num_sms (elastic.py:1086), so it inherits your explicit value and
    never reaches get_theoretical_num_sms / the ibstat-based get_rdma_gbs(). Both auto-sizers
    are genuinely bypassed.
  • The trap-2 diagnosis is correct at the pin. get_rdma_gbs() really does shell out to
    ibstat (deep_ep/utils/envs.py:258), which doesn't enumerate EFA's rdmap*s0 HCAs, and the
    auto path really can reach 129 QPs against a 128-slot ring (elastic.py:328-334), with
    num_sms*16+1 at elastic.py:851.
  • MIT-0 header on all 15 new files — including .gitignore, both YAMLs, env_vars.example and
    the Dockerfile, which is the file-type split this repo's reviews keep catching. Trailing newline
    on all 15 too.
  • The Qwen3-30B-A3B shape claims check out against the model's config.json: hidden_size
    2048, moe_intermediate_size 768, num_experts 128, num_experts_per_tok 8 — matching
    env_vars.example and train_moe_step.py's moe_ffn_hidden_size=768.
  • train_moe_step.py's DDP-less loop is sound, and the gate is what makes it sound. With an
    identical seeded batch on every rank, gradients for the replicated (non-expert) params are
    identical, so the DP all-reduce really is a no-op — and the cross-rank loss-agreement gate is
    what would catch it if that reasoning ever stopped holding. Nice.
  • MOE_DISPATCHER=flex is refused, not silently downgraded, when the patch marker is absent —
    with the reason and the rebuild command in the message.
  • verify-image.sh has no unconditional PASS, and the rollout probe distinguishes
    PASS / FAIL / INCONCLUSIVE (exit 2) rather than calling a counter-moved-but-no-banner run a
    pass. The EFA hw-counter delta assert is the right kind of evidence for "the bytes really left
    the node."
  • The measured-vs-staged section and Known limitations are unusually straight, including
    naming which of the four draft PRs are closed-unmerged and stating plainly that no performance
    numbers are published. This is the right instinct and I'd keep the section — the one thing to
    reconcile is that "build-staged" and Batch 1 can't both be describing this tree, and the pin set
    that did build is worth recording here.

Sources

Upstream source, read at the exact pins in this PR

Repo precedent

  • micro-benchmarks/expert-parallelism/deepep-v2-benchmark/{README.md,deepep.Dockerfile,setup_deepep_gin.sh,slurm/test-internode.sbatch}
    (merged in #1234, 2026-08-24) — the EFA-GDA NCCL_GIN_TYPE=5 stack, the CPU-proxy caveat, and
    the reusable V2 build script
  • .github/workflows/deepep-vendor-sync.yml — the gate that covers setup_deepep_efa.sh (V1), not
    setup_deepep_gin.sh
  • 3.test_cases/pytorch/nemo-rl/deepep-v2-efa/kubernetes/raycluster.yaml — the secretKeyRef and
    nodeAffinity patterns the data-prep pod should mirror

Upstream metadata at the pinned SHAs, verified live 2026-08-25

  • NeMo-RL cc75cad pyproject.toml: requires-python = ">=3.13.13"; torch==2.10.0,
    ray[default]==2.54.0, transformers==5.3.0, pillow>=12.1.1, mlflow>=3.11.1, plus
    decord2 / soundfile>=0.13.1 / nccl4py / cuda-bindings
    https://github.com/NVIDIA-NeMo/RL/blob/cc75cadfe061301bd121306f1648957583760528/pyproject.toml
  • NeMo-RL cc75cad nemo_rl/distributed/virtual_cluster.py:47-65 — every worker py_executable is
    uv run --locked --extra … --directory <git_root>; nemo_rl/utils/venvs.py:96 runs uv sync
    first; uv.lock is committed (1.3 MB). Commit 711147f (#2411) touches pyproject.toml only.
  • DeepEP 01dc3aa setup.py:92-124find_nvshmem_root() is called unconditionally under a
    # TODO: make NVSHMEM and legacy optional, and the NVSHMEM sources/links are unconditional;
    find_pkg_root defaults optional=False.
  • aws-ofi-nccl at 9c44d34 + c2e773d, include/nccl_ofi_param.h — the GIN parameters that exist
    are GIN_CQ_PROCESS_MAX_ITER, GIN_GDAKI, GIN_STRONG_SIGNAL and (from the cherry-pick)
    GDRCOPY_FORCED_PCIE_COPY, default false. There is no GIN_MAX_REQUESTS; OFI_NCCL_PROTOCOL
    and OFI_NCCL_GIN_GDAKI do exist.
  • File modes in this PR: all 15 files are 100644 (git ls-tree -r), including every
    recipe/*.sh.
  • env_vars.example exports NUM_NODES; recipe/run-rollout-probe.sh:19 and
    recipe/train-step.sh:19 read NNODES; kubernetes/raycluster.yaml defines none of
    NNODES / GPUS_PER_NODE / EP_* in the container env.

Verified live, 2026-08-25

  • gh api repos/aws/aws-ofi-nccl/pulls/1351state: closed, merged: false, title
    "gdrcopy: add OFI_NCCL_GDRCOPY_FORCED_PCIE_COPY override…", created 2026-08-13
  • Commit dates: 9c44d34 2026-05-25 · c2e773d 2026-08-13 · 01dc3aa 2026-08-04 ·
    19deef67 2026-08-19 · cc75cad 2026-05-05 · NCCL v2.30.4-1 2026-04-22
  • git fetch origin 9c44d34… + git cherry-pick c2e773d… → clean; both build asserts satisfied
  • All 9 patch SHAs → HTTP 200 + clean git apply --check onto their pinned bases; all post-asserts
    pass
  • nvcr.io/nvidia/pytorch:26.02-py3 image config (registry API): NCCL_VERSION=2.29.stable.20260109,
    AWS_OFI_NCCL_VERSION=1.17.0, OPAL_PREFIX=/opt/hpcx/ompi, EFA_VERSION=1.43.1,
    PYTORCH_BUILD_VERSION=2.11.0a0+eb65b36, CUDA_VERSION=13.1.1.006
  • Qwen/Qwen3-30B-A3B config.json → hidden 2048 / moe_intermediate 768 / 128 experts / top-k 8
  • Merge-base of this branch and main = 9baf5aa7 ("Add DeepEP V2 benchmark (NCCL GIN) (#1234)")

# Same removal the slime sibling does: the EFA installer below provides
# libfabric + Open MPI, and a leftover HPC-X/UCX on the loader path is a
# classic source of wrong-transport surprises.
RUN apt-get update -y && apt-get install -y --no-install-recommends \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

|| true at the end of this chain swallows failures from apt-get update and apt-get install too

Observation. The instruction is apt-get update && apt-get install … && apt-get remove … || true.
&& and || have equal precedence and associate left to right, so this parses as
((update && install) && remove) || true — the tolerance intended for the removal (where an
already-absent package is fine) covers the whole chain.

Impact. A transient mirror failure or a renamed package in apt-get install yields exit 0, and
the build proceeds without cmake, autoconf, libtool or libhwloc-dev. The failure then
surfaces several hundred lines later inside the gdrcopy or aws-ofi-nccl build as a missing-tool
error with no connection to its cause. (The merged deepep-v2-benchmark Dockerfile has the same
idiom, but with only update && remove in the chain — the added install is what makes it
load-bearing here.)

Suggestion. Scope the tolerance to the removal:

Suggested change
RUN apt-get update -y && apt-get install -y --no-install-recommends \
RUN apt-get update -y && apt-get install -y --no-install-recommends \

…and close the chain with && { apt-get remove -y --allow-change-held-packages ibverbs-utils libibverbs-dev libibverbs1 libmlx5-1 || true; } so only that step may fail.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 316c127. The chain was update && install && remove || true, which parses as ((update && install) && remove) || true — the || true swallowed update/install failures too. It is now brace-scoped to the removal only: ... && { apt-get remove -y ... || true; } (Dockerfile:122-123), so only the intended already-absent-package removal is tolerated; a real update/install failure still fails the build.

libhwloc-dev pkg-config \
&& apt-get remove -y --allow-change-held-packages \
ibverbs-utils libibverbs-dev libibverbs1 libmlx5-1 || true
RUN rm -rf /opt/hpcx/ompi /usr/local/mpi /usr/local/ucx && ldconfig

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OPAL_PREFIX is left pointing at the directory this line deletes

Observation. The NGC base sets OPAL_PREFIX=/opt/hpcx/ompi in its image config (verified live
from the registry, 2026-08-25). This line removes /opt/hpcx/ompi, and nothing resets the
variable — so the final image ships OPAL_PREFIX pointing at a path that no longer exists, while
Open MPI actually lives at /opt/amazon/openmpi (which you correctly put on PATH in Layer 2).
Open MPI uses OPAL_PREFIX to locate its MCA component tree.

Impact — deliberately small, and worth stating as such. Nothing in the recipe uses MPI: the
gates launch through torchrun and Ray. So this bites only someone who runs mpirun from the
image, which the docs never ask for. Reporting it as a leftover rather than a defect.

Suggestion.

Suggested change
RUN rm -rf /opt/hpcx/ompi /usr/local/mpi /usr/local/ucx && ldconfig
RUN rm -rf /opt/hpcx/ompi /usr/local/mpi /usr/local/ucx && ldconfig
ENV OPAL_PREFIX=/opt/amazon/openmpi

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 316c127. The base sets OPAL_PREFIX=/opt/hpcx/ompi and line 129 removes that dir; nothing reset it, so the final image shipped a dangling OPAL_PREFIX. Added ENV OPAL_PREFIX=/opt/amazon/openmpi (Dockerfile:130) — the actual OMPI location — right after the removal; the comment at 124-128 explains it.

# SPDX-License-Identifier: MIT-0

# Local environment file with filled-in secrets/values — never commit
env_vars

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No .dockerignore while the build context is the directory holding env_vars

Observation. env_vars is correctly gitignored and carries HF_TOKEN plus your AWS account
id. README §2 then builds with the context set to this directory
(docker build -f nemo-rl.Dockerfile -t ${FULL_IMAGE} .). .gitignore does not apply to Docker
build contexts, so the filled-in env_vars is uploaded to the daemon — and to any remote/CI
builder — on every build.

Impact, scoped honestly: there is no COPY . . in this Dockerfile (every COPY names a
specific file), so nothing lands in an image layer and nothing is pushed to ECR. The exposure is
the context upload itself, which matters for a remote builder and for anyone who later adds a
broad COPY.

Suggestion. Add a .dockerignore beside the Dockerfile:

env_vars
*.log
__pycache__/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4052250. Added .dockerignore (your point exactly: .gitignore does not apply to a Docker build context, so the filled-in env_vars — HF token + AWS account id — would upload to the daemon and any remote/CI builder on every docker build .). It ignores env_vars, *.log, __pycache__/. No COPY in the Dockerfile is broad today, but this closes the context-upload exposure and guards a future broad COPY.

],
# Metadata-only for this image (deep_ep is built from /opt/DeepEP, not
# from NeMo-RL's pin), but applied so the tree is self-consistent.
"probe": ("pyproject.toml", "b306af0"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NeMo-RL#2411 edits pyproject.toml without uv.lock, and every worker launches with uv run --locked

Observation. This entry is annotated "Metadata-only for this image (deep_ep is built from
/opt/DeepEP, not from NeMo-RL's pin), but applied so the tree is self-consistent." At the pinned
cc75cad, NeMo-RL launches each worker type through a locked uv environment:

BASE  = f"uv run --locked --directory {git_root}"
MCORE = f"uv run --locked --extra mcore --directory {git_root}"
VLLM  = f"uv run --locked --extra vllm --directory {git_root}"

(virtual_cluster.py:47-65;
venvs.py:96 runs uv sync first). uv.lock is committed (1.3 MB), and
commit 711147f
touches pyproject.toml only — verified live 2026-08-25, its diff has exactly one diff --git
stanza.

Impact. uv run --locked refuses to run when the lock does not match pyproject.toml, so on
the patched image the GRPO actors would fail at worker startup rather than "metadata-only". And the
deeper consequence is worth checking on the same pass: if the lock is regenerated, those isolated
per-worker environments resolve deep_ep from NeMo-RL's own git dependency — not the EFA-patched
/opt/DeepEP this Dockerfile spent Layer 9 building — which would bypass the component the whole
folder exists to exercise.

Suggestion. Regenerate uv.lock as part of this patch entry (and post-assert it, the way the
other entries post-assert their content probe), or point the worker environments at the system
interpreter so /opt/DeepEP is what actually loads. Either way the "metadata-only" note should go —
this entry is on the critical path for the flavor it belongs to.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — NeMo-RL#2411 is removed from the apply set entirely, which dissolves the uv run --locked problem. apply_nemo_rl_patches.py:11: "NVIDIA-NeMo/RL#2411... is intentionally excluded". The image now pins NEMO_RL_SHA=46be4e8 and builds deep_ep from /opt/DeepEP (not NeMo-RL's git dependency), so there is no pyproject.toml/uv.lock mismatch to fail uv run --locked at worker startup, and no path by which the isolated per-worker envs would resolve a second deep_ep. You were right that "metadata-only" was wrong — it was on the critical path for this flavor — so it's dropped rather than annotated. Thanks.

spec:
containers:
- name: data-prep
image: python:3.12-slim

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Floating python:3.12-slim in a PR whose own env file says "never latest"

Observation. python:3.12-slim is a moving tag — it re-points on every CPython 3.12 patch
release and every Debian base refresh. env_vars.example in this same folder states the rule for
the image it does pin: "Immutable tag - never 'latest': with imagePullPolicy: IfNotPresent a node
that cached 'latest' silently keeps running the OLD image after a rebuild."
The same reasoning
applies here.

Suggestion. Pin to a patch release (or a digest):

Suggested change
image: python:3.12-slim
image: python:3.12.14-slim

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — pinned to a patch release. kubernetes/data-prep-pod.yaml:42 now reads image: python:3.12.14-slim. You were right that a floating python:3.12-slim contradicts this folder's own "never latest" rule (with imagePullPolicy: IfNotPresent, a node that cached the moving tag silently keeps the old image after a re-push). Thanks.

Comment on lines +23 to +24
- name: HF_TOKEN
value: "" # Set before applying, or pass via kubectl set env

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HF_TOKEN as a plaintext env on a sleep infinity pod, while the sibling manifest uses a Secret [confirmed]

Observation. Three things converge on this line:

  1. raycluster.yaml injects the same token via secretKeyRef from the hf-token Secret that
    README §4 creates — so the good pattern already exists two files away, and this manifest doesn't
    use it.
  2. A plaintext env value is visible in kubectl get pod -o yaml and in the envsubst-rendered
    output. Because command: ["sleep","infinity"], that value sits in etcd for the life of the
    pod rather than the life of a Job.
  3. As written it can't work anyway: the value is a literal "" with no ${HF_TOKEN} placeholder,
    so the documented envsubst < kubernetes/data-prep-pod.yaml | kubectl apply -f - flow leaves it
    empty and the hf download in the header comment fails on a gated model. The comment's escape
    hatch — "or pass via kubectl set env" — doesn't rescue it either: a Pod's container env is
    immutable once created, so kubectl set env pod/data-prep … is rejected and the pod has to be
    deleted and re-applied.

One more thing worth deciding while you're here: the staging target is ${MODEL_LOCAL}
(/fsx/models/Qwen3-30B-A3B), but the GRPO recipe this folder points at sets
policy.model_name: Qwen/Qwen3-30B-A3B — a hub id, not that path — so a --local-dir download is
not what the run consumes unless you also set HF_HOME on the Ray pods or override model_name.

Suggestion. Reuse the Secret the README already tells the operator to create:

Suggested change
- name: HF_TOKEN
value: "" # Set before applying, or pass via kubectl set env
- name: HF_TOKEN
valueFrom:
secretKeyRef:
name: hf-token
key: HF_TOKEN

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FixedHF_TOKEN now comes from the hf-token Secret via secretKeyRef, matching raycluster.yaml. kubernetes/data-prep-pod.yaml:48-52:

- name: HF_TOKEN
  valueFrom:
    secretKeyRef:
      name: hf-token
      key: HF_TOKEN

You were right on all three points — the good pattern already existed two files away, the plaintext value sat in etcd for the pod's life on a sleep infinity pod, and the literal "" with no ${HF_TOKEN} placeholder meant the documented envsubst flow left it empty anyway (and pod env is immutable, so kubectl set env couldn't rescue it). Reusing the Secret fixes all of them. Thanks.

namespace: ${NAMESPACE}
labels:
app: nemo-rl-deepep-data-prep
spec:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The data-prep pod can land on a GPU node and hold it (and installs an unpinned dep at run time)

Observation. Two smaller things in this spec, both by contrast with raycluster.yaml:

  • Scheduling. The Ray head carries a nvidia.com/gpu.present NotIn ["true"] nodeAffinity with
    the rationale "keep the head off GPU nodes so it never blocks a GPU worker from scheduling."
    This pod is the same kind of CPU-only workload, requests 4–8 CPU and 16–32 Gi, and runs
    sleep infinity — but has no such constraint, so on a cluster with schedulable capacity it can
    park on a p5 node for as long as the operator leaves it up. Copying the head's affinity block
    would settle it.
  • Runtime install. The header comment stages via
    pip install "huggingface_hub[cli]" — unpinned, at run time, inside the pod. On an air-gapped or
    egress-restricted cluster that step is the one that fails, and it's the only thing this pod
    exists to do. A pinned huggingface_hub[cli]==x.y.z (or an image that already carries it) makes
    the staging step reproducible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4052250. The CPU-only data-prep pod now carries the same nvidia.com/gpu.present NotIn true nodeAffinity as the Ray head (data-prep-pod.yaml:29-34), so it cannot land on and hold a GPU node, and it is pinned to python:3.12.14-slim (line 42) instead of installing an unpinned dependency at runtime.

- name: HF_TOKEN
valueFrom:
secretKeyRef:
name: hf-token

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HF_TOKEN is injected into every privileged GPU worker, and the SA token is auto-mounted alongside it

Observation. Every GPU worker gets HF_TOKEN from the Secret (good — via secretKeyRef), and
those same containers run privileged: true with IPC_LOCK and a /dev/infiniband host mount.
The pod spec does not set automountServiceAccountToken: false, so the default ServiceAccount
token is mounted too.

Impact. The privilege itself is justified and documented — the manifest header explains the
gdrdrv device-cgroup problem and names the device-plugin alternative, which is exactly the rationale
this repo asks for, so I'm not asking you to drop it. The point is the combination: model code and
training dependencies executing in a privileged root container is the least contained place in the
cluster to also hand a credential, and the recipe gates that this manifest is primarily for need no
token at all (no weights, no dataset).

Suggestion. Two small scopings, both compatible with keeping privileged: true:

  • Move HF_TOKEN off the worker group and onto the data-prep workload that actually downloads
    weights (see the data-prep comments) — or gate it behind the full-GRPO overlay.
  • Add automountServiceAccountToken: false to both pod specs; nothing here talks to the API server.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5df9552. automountServiceAccountToken: false is now set on both the head and worker pod specs (raycluster.yaml:52, 129), so the service-account token is no longer mounted into the privileged GPU workers alongside the HF token. The HF token itself is now sourced from a secretKeyRef (lines 71-75, 143-147) rather than injected as a literal env value.

@KeitaW KeitaW left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few comments!

…ates on p5en

Run-to-green on a live p5en (8xH200, EFA): the image failed to build on the
pinned NeMo-RL SHA, and once buildable it failed its own recipe/verify-image.sh
on two counts. All fixed and verified against a pristine-rootfs in-pod replay of
Dockerfile layers 1-9 from these exact files -> full 6/6 gate suite now PASS
(efa-direct x4 rails, GIN-capable NCCL 2.30.4 at /opt/nccl/build, ncclGinPlugin
+ gdrcopy-compiled-in, deep_ep V2 ElasticBuffer import, nemo_rl + megatron.core
imports, clean upstream-only baseline marker). cgk p5en, 2026-08-26.

Build fix -- NEMO_RL_SHA cc75cad -> 46be4e8 (+ drop draft PR #2411):
  cc75cad bumped requires-python to ">=3.13.13" and torch to 2.10.0, so
  `pip install -e /opt/NeMo-RL` (Layer 7) hard-fails the interpreter check on
  this py3.12 NGC base (--no-deps does NOT suppress the Requires-Python floor).
  46be4e8 is the parent commit of draft PR NVIDIA-NeMo/RL#2410 (the EFA recipe),
  declares requires-python ">=3.12", and is the revision requirements.txt is
  generated from. #2411 (a deep_ep pin bump based on cc75cad) is therefore
  dropped from the opt-in layer -- it neither applies to the 46be4e8 tree nor
  belongs on py3.12, and is metadata-only here (deep_ep builds from /opt/DeepEP,
  not NeMo-RL's pin). The opt-in layer is now 3 draft PRs, not 4; docs, the
  patches script, env_vars.example, and the recipe scripts are reconciled to
  match.

Build fix -- restore nvidia-nvshmem-cu13 in requirements.txt:
  upstream DeepEP's setup.py links NVSHMEM UNCONDITIONALLY (find_nvshmem_root
  (optional=False) asserts if absent -- its "make NVSHMEM optional" TODO is
  still open at 01dc3aa). The NGC base's baked NVSHMEM is the dpkg runtime
  package with no nvshmem.h and no libnvshmem_device.a, so the Layer-9 deep_ep
  build fails at link without the pip wheel (which ships both). cu13 to match
  the base's CUDA line; this mirrors NeMo-RL 46be4e8's own
  `nvidia-nvshmem-cu12  # for deep_ep build`.

Gate 4 -- deep_ep import (nvshmem runtime ABI), new Layer 7b:
  `from deep_ep import ElasticBuffer` died with
  `undefined symbol: nvshmem_selected_device_transport, version NVSHMEM`.
  deep_ep/_C.so is correctly built against the pip nvidia-nvshmem-cu13 wheel
  (3.7.x), but torch/lib/libtorch_nvshmem.so -- imported first -- has a NEEDED
  libnvshmem_host.so.3 whose RUNPATH ends in /usr/local/cuda/lib64, where the
  NGC base's OLDER dpkg NVSHMEM (3.4.x, lacking that symbol) lives. RUNPATH
  outranks ld.so.cache, so the stale copy wins by soname before _C.so can pull
  the right one -- which is why an ld.so.conf.d entry (Layer 4's house style)
  does NOT fix it. Fix (Layer 7b): symlink the wheel's lib dir to a stable,
  version-agnostic /opt/nvshmem-pip-lib and prepend it to LD_LIBRARY_PATH (only
  LD_LIBRARY_PATH outranks RUNPATH). Fail-loud if the wheel dir is absent.

Gate 5 -- nemo_rl / megatron.core imports (missing requirements):
  `import nemo_rl.algorithms.grpo` and
  `import megatron.core.transformer.moe.fused_a2a` both ImportError'd.
  - decord: imported by nemo_rl/data/multimodal_utils.py (GRPO data path);
    absent from the NGC base, an extra in NeMo-RL's own deps.
  - nvidia-resiliency-ext>=0.6.0: import-time dep of megatron.core's
    dist_checkpointing strategies; the base ships 0.5.0, which is too old.

setup script -- deep_ep .so assert made cwd-independent:
  the post-install assert used importlib.util.find_spec("deep_ep") while cwd was
  the DeepEP source tree; Python puts cwd on sys.path, so find_spec resolved the
  SOURCE package (no compiled .so) and shadowed the pip-installed copy, a false
  negative that failed the build even when the .so was built. Rewrote to read
  the installed distribution's file manifest (importlib.metadata.files), which
  is cwd-independent and imports nothing.

Verified: pristine-rootfs in-pod replay of layers 1-9 from these exact files,
then recipe/verify-image.sh -> ALL IMAGE GATES PASS. cgk p5en, 2026-08-26.

Signed-off-by: Anton Alexander <dmvevents@gmail.com>
… (DDP-free), + shell bug

The Megatron-core MoE train-step gate (recipe/train_moe_step.py) now PASSES at
full 8-GPU scale on a live p5en (8xH200, EFA): loss 9.43 -> 6.33 -> 4.17 -> 3.43
over real optimizer steps with the alltoall dispatcher (NCCL all-to-all over
EFA), cross-rank loss agreement to ~1e-5. Two correctness fixes, both measured.

train_moe_step.py -- gate 2 (cross-rank loss agreement) false-failed on a fully
correct image + transport because the DDP-free loop never synced the REPLICATED
(non-expert) params:
  - init: megatron seeds most replicated params identically via its RNG tracker,
    but router.weight and position_embeddings init under a per-rank-varying RNG
    context -- MEASURED cross-rank divergence 1.52e-1 / 1.64e-1 at step 0, before
    any update (diag all-reduce MAX-MIN, split expert vs replicated by param name;
    qkv/proj/layernorm/word_embeddings were already 0.0). Fix: broadcast the
    non-expert params from rank 0 after construction (what DDP does at build).
  - per step: the old docstring claimed "same batch => grad all-reduce is a
    no-op". FALSE for replicated params under EP -- each rank backprops through
    DIFFERENT local experts, so the shared router gets different per-rank grads
    and re-diverges every step (spread grew 5.7e-4 -> 8.9e-3 over 3 steps with
    init-broadcast alone). Fix: all-reduce(AVG) replicated-param grads each step.
    Result: spread FLAT ~1e-5 across 5 steps -> the gate is a real transport test
    at any step count, not a rubber stamp. Expert params/grads are deliberately
    left per-rank distinct -- that asymmetry IS expert parallelism.
  Also: fp32 params throughout (not bf16). The `local` layer spec emits fp32
  LayerNorm activations; with bf16 params the router's te_general_gemm sees a
  bf16-weight x fp32-input GEMM that this NGC base's TE cuBLASLt build rejects
  ("unsupported value or parameter") regardless of moe_router_dtype. fp32 makes
  every GEMM (fp32,fp32,fp32) and keeps the all-to-all transport identical
  (NCCL/DeepEP all-to-all is dtype-agnostic; DeepEP requires fp32 probs).

train-step.sh -- the usage string `${1:?usage: ... {leader|worker} ...}` has a
literal `}` inside the `${1:?word}` expansion, which terminates the parameter
expansion early; $ROLE became the literal "leader <leader-ip> [node-rank]}" and
the leader invocation aborted with "unrecognized role". Removed the interior
brace from the message. (bash-parse-only fix; no behavior change on the happy
path other than making the leader path actually run.)

Verified: single-node 8xH200 over EFA on cgk p5en, 2026-08-26.
Signed-off-by: Anton Alexander <dmvevents@gmail.com>
@dmvevents

Copy link
Copy Markdown
Contributor Author

Thanks for the review, @KeitaW. Both the build blocker and the test correctness are now
resolved and verified on a live p5en (8×H200, EFA). Pushed in 33ee8e7 (on top of the
build fixes in 6c14c2e).

Build (was: "as pinned, the image does not build") — fixed. NEMO_RL_SHA pinned
cc75cad, which bumps requires-python to >=3.13.13 + torch 2.10, so pip install -e
hard-fails the interpreter check on the py3.12 NGC base. Re-pinned to 46be4e8 (the parent
of the EFA-recipe draft, requires-python >=3.12, the SHA requirements.txt is generated
from), restored nvidia-nvshmem-cu13 (upstream DeepEP links NVSHMEM unconditionally), and
fixed the NVSHMEM runtime-ABI RUNPATH shadowing. Both flavors now build NGC-from-scratch and
pass all 6 recipe/verify-image.sh gates.

Test — actual training, not just imports. recipe/train_moe_step.py now runs real
Megatron-core MoE optimizer steps (alltoall dispatcher = NCCL all-to-all over EFA), 8×H200,
128 experts, EP=8:

step=0 loss=9.4286  cross-rank-spread=5.91e-05
step=1 loss=6.3292  cross-rank-spread=5.72e-06
step=2 loss=4.1675  cross-rank-spread=6.68e-06
step=3 loss=3.4346  cross-rank-spread=1.72e-05
step=4 loss=3.7435  cross-rank-spread=8.11e-06
finite=True  decreasing=True  cross-rank-agree=True  →  TRAIN-STEP GATE PASS

Two correctness fixes the run surfaced (both measured, not guessed):

  1. The DDP-free loop never synced the replicated (non-expert) params. Measured: the
    router weight and position embeddings init under a per-rank-varying RNG context (1.5e-1
    cross-rank divergence at step 0, before any update), and the shared router also receives
    different per-rank gradients because each rank backprops through different local
    experts. So the loop now does the two things DDP does, restricted to non-expert params:
    broadcast at construction + all-reduce(AVG) their gradients each step. Cross-rank spread
    goes from growing (5.7e-4→8.9e-3 over 3 steps) to flat (~1e-5 over 5 steps) — so gate 2 is
    a real transport check at any step count, and a corrupted all-to-all still fails it.
    Expert params/grads stay per-rank distinct — that's expert parallelism.
  2. train-step.sh had a literal } inside a ${1:?...} usage string that truncated the
    expansion, so the leader invocation aborted with "unrecognized role". Removed the brace.

The alltoall gate above runs on the upstream-only image. The flex (DeepEP-V2 ElasticBuffer)
dispatcher path needs the opt-in draft-PR image (APPLY_DRAFT_ROLLOUT_PATCHES=1); the
cross-node EFA transport itself is already measured-PASS on #1240 — this is the intra-node
8-GPU exercise. Happy to re-run any variant you'd like to see. Full log + the diagnostic that
classified init-desync vs transport is in the evidence dir.

…dening

Addresses KeitaW review threads on PR awslabs#1242:

- Add .dockerignore beside nemo-rl.Dockerfile (env_vars, *.log, __pycache__/).
  .gitignore does not apply to a Docker build context, so the filled-in
  env_vars (HF_TOKEN + AWS account id) was uploaded to the daemon / any remote
  builder on every `docker build .`. No broad COPY exists so nothing reached an
  image layer; this closes the context-upload exposure. (thread PRRT…M3nJ)

- data-prep-pod.yaml:
  * HF_TOKEN plaintext env -> secretKeyRef from the same hf-token Secret
    raycluster.yaml already consumes (a literal "" could not work anyway, and a
    plaintext value would sit in etcd for the sleep-infinity pod's life).
    (thread PRRT…M3nY)
  * pin python:3.12-slim -> python:3.12.14-slim (never a floating tag — the
    "never latest" rule env_vars.example states). (thread PRRT…M3nU)
  * add the head's nvidia.com/gpu.present NotIn ["true"] nodeAffinity so this
    CPU-only sleep-infinity pod cannot park on and hold a GPU node; pin the
    header's `pip install "huggingface_hub[cli]==1.28.0"`; add
    automountServiceAccountToken:false (nothing here talks to the API server).
    (thread PRRT…M3nh)

Signed-off-by: Anton Alexander <dmvevents@gmail.com>
…SA token

Addresses KeitaW review threads on PR awslabs#1242:

- Put the recipe-gate shape in the worker pod env: NNODES (the name the
  scripts read — NOT NUM_NODES, which only raycluster's replicas consume) +
  GPUS_PER_NODE + EP_EXPERTS/EP_TOPK/EP_HIDDEN/EP_TOKENS/EP_NUM_SMS/EP_NUM_QPS.
  A client-side `source env_vars` does not cross `kubectl exec`; without these
  the gates ran on hardcoded script defaults. (thread PRRT…M3YG)

- Default /fsx to emptyDir on both head and workers (PVC form left documented
  inline for the full GRPO swap). The recipe gates touch no shared storage, so
  the advertised cheap entry point no longer sits Pending on an unbound claim.
  (thread PRRT…M3YL)

- Add automountServiceAccountToken:false to both pod specs — nothing here talks
  to the API server, and the workers are privileged root containers running
  model/training code. (thread PRRT…M3nj)

- Drop OFI_NCCL_GIN_MAX_REQUESTS from the worker env: no such parameter exists
  in the pinned aws-ofi-nccl (the GIN params are gin_cq_process_max_iter,
  gin_gdaki, gin_strong_signal, gdrcopy_forced_pcie_copy). It was inert and
  contradicted trap 2's 128-slot ring. (thread PRRT…M3R0)

Signed-off-by: Anton Alexander <dmvevents@gmail.com>
…gate, teardown

Addresses KeitaW review threads on PR awslabs#1242:

probe_rollout.py:
- Score combine per token against its OWN scale (row_err/row_scale, 1e-3 floor)
  instead of one global max — a global normaliser averages away the badly-wrong
  low-amplitude rows a partial dispatch / dropped QP channel produces, exactly
  what the probe exists to catch. (thread PRRT…M3fS)
- deep_ep.topk_idx_t direct access instead of getattr(...,torch.int64): the
  attribute is exported unconditionally at the pin, so the default was
  unreachable and would silently mask a future rename. (thread PRRT…M3fx)
- Restrict the local-mapped detector to RANK>0: on rank 0 the local experts ARE
  0..num_local-1, so it fired every normal run and printed a misleading note.
  (thread PRRT…M3fs)

train_moe_step.py:
- Track max_spread across ALL steps for gate 2 (was reading the final step
  only), so an early transport fault that later clears still fails; assert
  STEPS>=2. (thread PRRT…M3fb)

both:
- Pass timeout=10min to init_process_group and destroy the group in a finally
  so a raised rank fails fast instead of holding peers for the 30-min NCCL
  default (the worker runs detached under kubectl exec). (thread PRRT…M3f3)

Signed-off-by: Anton Alexander <dmvevents@gmail.com>
…t, train-step transport gate

- run-rollout-probe.sh / train-step.sh: sum tx_bytes+send_bytes+rdma_write_bytes+
  rdma_read_resp_bytes for the EFA-TX assert, not tx_bytes alone. Under
  FI_EFA_USE_DEVICE_RDMA=1 the bytes land on rdma_write_bytes, so a tx_bytes-only
  check false-FAILs a healthy RDMA run (KeitaW review).
- both: wrap torchrun in 'timeout 900' so a wedged rendezvous returns a verdict
  instead of holding the nodes; pairs with the python init_process_group timeout.
- train-step.sh: port the probe's three-part transport gate (rc + >=1MiB EFA-TX
  delta + efa provider banner) instead of PASS on rc==0 alone; default NCCL_DEBUG
  to INFO so the provider banner is available as proof.

Signed-off-by: Anton Alexander <dmvevents@gmail.com>
… check

Addresses KeitaW review on the draft-PR patch layer and the image gate.

patches/apply_nemo_rl_patches.py:
- Replace the single per-PR content probe with a per-CHANGE probe LIST (one
  needle per independently-required commit). A PR is skipped as
  already-present only when EVERY probe passes; a partially-merged upstream
  tree (some probes pass, some don't) is NOT skipped — its commits apply and
  a colliding hunk fails --check loud, instead of silently trusting a single
  needle hit. Post-assert re-checks the FULL list before the marker is
  written, so the marker can never record a patch state the tree lacks.
- Drop the per-commit `git apply --reverse --check` short-circuit (it
  false-negatives on multi-commit PRs whose later commits touch the same
  hunks); the whole-PR probe gate now decides skip-vs-apply.
- Needles derived from the real fetched .patch files: DeepEP#612 =
  EP_EFA_MAX_QPS / EP_EFA_RDMA_GBS / kScaleoutUpdateInterval=16;
  Megatron#4632 = ElasticBuffer / deep_ep.utils.event / _handle_num_experts
  (commit 4 is a comment/URL cleanup, no probe by design).

recipe/verify-image.sh:
- Patch-marker check now probes per-change too (adds EP_EFA_RDMA_GBS in
  utils/envs.py and _handle_num_experts in fused_a2a.py), covering the two
  named false-positive cases where a single needle passes but a later
  required change is absent.
- Resolve libnccl via the actual loader (dlopen + /proc/self/maps) instead of
  `ldconfig -p | head -1` (cache order). dlopen honors LD_LIBRARY_PATH before
  the cache — the same evidence deep_ep.check_nccl_so() acts on — so this
  catches a scrubbed LD_LIBRARY_PATH or LD_PRELOAD shadow the cache-order
  check reads past.

Signed-off-by: Anton Alexander <dmvevents@gmail.com>
… drop unused GIN knob

Addresses KeitaW review on the Dockerfile and env contract.

nemo-rl.Dockerfile:
- Brace-scope the `|| true` to the `apt-get remove` of the distro verbs
  packages ONLY. apt-get update + install stay fail-hard — a transient mirror
  miss must not silently ship an image missing cmake/autoconf/libtool and
  fail 100s of lines later inside the gdrcopy/aws-ofi-nccl build.
- Set OPAL_PREFIX=/opt/amazon/openmpi after `rm -rf /opt/hpcx/ompi`. The NGC
  base points OPAL_PREFIX at the deleted HPC-X Open MPI; reset it to the EFA
  installer's Open MPI so a dangling prefix can't trip an mpirun from the image.

Drop OFI_NCCL_GIN_MAX_REQUESTS=512 everywhere it appeared (Dockerfile runtime
ENV, env_vars.example, recipe/run-rollout-probe.sh, recipe/train-step.sh): it
is not a knob the measured NCCL-GIN substrate set, and carrying an unverified
value in the shipped transport contract is exactly the kind of guessed pin the
review flagged. The remaining GIN vars (NCCL_GIN_TYPE/ENABLE, OFI_NCCL_GIN_GDAKI)
are the measured ones.

env_vars.example:
- Add a "HOW THESE REACH THE GATES" note: sourcing sets vars in the local shell
  only and does NOT cross `kubectl exec`; `envsubst` substitutes NUM_NODES / the
  EP_* shape into the pod env: block, and the manifest bridges NUM_NODES->NNODES.
- Correct the transport-contract header: a `kubectl exec` shell inherits these
  from the container env (baked + manifest), NOT from a client-side `source`;
  the local export line matters only for a non-k8s `docker run` on an EFA host.

Signed-off-by: Anton Alexander <dmvevents@gmail.com>
…om PR awslabs#1242 review

Truthfulness fixes for KeitaW's inline review — no measurement is invented; the
edits make wording match what the build actually does and what was measured.

README (folder):
- NCCL row: state the NGC base bakes an OLDER NCCL line (2.29.x), so the source
  v2.30.4-1 build + 00-nccl-gin.conf ld.so.conf entry is LOAD-BEARING (makes the
  GIN-capable copy win the loader search), not a no-op 'same line' override.
- aws-ofi-nccl row: describe 9c44d34 + PR#1351 as the GIN CPU-proxy lineage this
  folder standardises on; note the SHAs POSTDATE Wave-28 (not that run's pins).
- trap 3: verify-image.sh resolves libnccl.so.2 via dlopen + /proc/self/maps,
  not ldconfig -p cache order.
- runtime env table: drop OFI_NCCL_GIN_MAX_REQUESTS=512 row (the code no longer
  sets it — was removed from Dockerfile/scripts/env_vars; table was stale).
- Quick Start: push the -draftprs flavor too (distinct tag); flex train-step
  must set MOE_DISPATCHER=flex on BOTH nodes (one torchrun job — ranks disagree
  and hang otherwise).
- benchmarks refs: micro-benchmarks EP benchmark runs EFA-GDA GIN
  (NCCL_GIN_TYPE=5), NOT this folder's CPU-proxy (NCCL_GIN_TYPE=2) — drop the
  'same substrate' claim in two places.

README (parent nemo-rl/): same EFA-GDA-vs-CPU-proxy correction on the
micro-benchmarks cross-link.

setup_nemo_rl_deepep_efa.sh header: fix the 'links NO NVSHMEM' contradiction —
deep_ep _C.so IS build-linked against NVSHMEM (Dockerfile Layer 7b makes the pip
nvshmem win the loader search); NVSHMEM is just not the run-time transport on the
V2 NCCL-GIN path. Retarget the no-vendor-sync contrast at the repo's canonical V2
builder setup_deepep_gin.sh (only the V1 setup_deepep_efa.sh copies are synced).

Signed-off-by: Anton Alexander <dmvevents@gmail.com>
…iew)

KeitaW: the scripts ship mode 100644, so README §3's `recipe/verify-image.sh
${FULL_IMAGE}` fails with Permission denied straight from a fresh clone. Set
the executable bit on the three shell entrypoints the docs invoke directly
(verify-image.sh, run-rollout-probe.sh, train-step.sh) plus setup_nemo_rl_deepep_efa.sh,
via `git update-index --chmod=+x` so the mode lands in the tree. The two .py
files stay 100644 — they are run via torchrun/python, never executed directly.

Signed-off-by: Anton Alexander <dmvevents@gmail.com>
…ocument plugin/fork trade-offs (PR awslabs#1242 review)

Address KeitaW review threads on pin provenance and honesty:

- NGC base: pin @sha256:bbc2b67e… alongside the readable :26.02-py3 tag. It is
  the ABI anchor the whole image builds around (torch 2.11/CUDA 13 + baked
  TE/apex/flash-attn), so a silent tag re-push is the most consequential drift
  possible here. Override NGC_PYTORCH_BASE to move it (a re-measure event).

- NCCL: pin the commit 1933fdd6 that v2.30.4-1 resolves to, not the bare tag —
  held to the same moving-ref standard as the gdrcopy/DeepEP/Megatron SHA pins.
  Correct the stale "same NCCL line the NGC base bakes / no drift" comment: the
  base bakes an OLDER 2.29.x line, so the 00-nccl-gin.conf ld.so.conf entry is
  LOAD-BEARING (it makes this GIN-capable source build win the loader search).

- aws-ofi-nccl #1351: note it is CLOSED-UNMERGED, so unlike the draft PRs in
  patches/ it does NOT self-neutralize — it is a PERMANENT baseline carry on
  every build. Clarify the "baseline has zero dependence on unmerged PRs" line
  refers to the four draft PRs in the opt-in layer, not this plugin pin.

- DeepEP fork trade-off: document honestly that amazon-contributing/DeepEP
  fixes trap-2 structurally (sysfs get_rdma_gbs, restructured QP allocator)
  while baseline stays stock 01dc3aa on purpose (the awslabs#612 --check-clean base +
  the no-fork convention); the knob clamps are the portable equivalent, and
  fork adoption is a deliberate future re-pin + re-measure.

README pins table updated to match (base digest, NCCL commit).

Signed-off-by: Anton Alexander <dmvevents@gmail.com>
…L_SHA=46be4e8 (PR awslabs#1242 review)

KeitaW M2_d: the header claimed the deps are carried "at the same specifiers
the pinned SHA declares," but that was not literally true. Diffed live against
NeMo-RL 46be4e8's pyproject [project.dependencies] and corrected the drift:

- wandb: restore upstream's >=0.25.0 floor (was unpinned).
- decord -> decord2: upstream declares decord2 (the maintained fork that keeps
  the `decord` import name); match the spelling exactly, and drop the false
  "it's an extra, not in deps" note.
- Header reworded: deps are at the SHA's specifiers EXCEPT for documented inline
  deviations, MINUS base-provided/conflicting/unused-feature-path entries — and
  the omitted ones are now named with why (nccl4py/cuda-bindings = non-colocated
  refit, pybase64 = SGLang refit, soundfile = audio multimodal — none on this
  colocated Megatron-GRPO text recipe).
- Explain why `pip check` is deliberately NOT a build gate: --no-deps against the
  NGC-baked torch 2.11 (vs NeMo-RL's declared torch==2.9.0) would make pip check
  false-fail on that intended ABI substitution.

The maintainer's diff table was computed vs cc75cad (#2411's base); this branch
pins 46be4e8 (build-fix 6c14c2e), where torch/ray/transformers/pillow/mlflow
already match line-for-line — so only the wandb floor and decord2 spelling were
real deviations.

Signed-off-by: Anton Alexander <dmvevents@gmail.com>
…g (reorg awslabs#1119)

Reorg awslabs#1119 de-numbered the top-level dirs and moved test cases from
3.test_cases/pytorch/<framework>/ to examples/{training,inference,use-cases}/.
This PR was opened against the pre-reorg path, so its files landed under the
now-deleted 3.test_cases/ tree. Adding files to a deleted directory does not
conflict (git silently recreates it), so the PR read mergeable while being
orphaned at a dead path — it needs a migration, not a rebase.

Per AGENTS.md ("a training framework example goes to
examples/training/<framework>/" + "extend before create"), the sample nests as
a variant subdirectory under the existing examples/training/nemo-rl/ example
rather than as a new sibling — mirroring examples/training/megatron-bridge/'s
populated-framework-dir-with-named-variants shape.

  - git mv the deepep-v2-efa subtree to examples/training/nemo-rl/deepep-v2-efa
    (history preserved; 14 of 15 files R100, README.md carries a one-line link fix).
  - git rm the stale 3.test_cases/pytorch/nemo-rl/README.md parent index (it
    only pointed at the pre-reorg ../slime sibling).
  - Add a "## Variants" pointer row to examples/training/nemo-rl/README.md so
    the nested example is discoverable from the framework dir (the merge's
    rename detection does not synthesize this — nemo-rl/README.md is a
    single-example README, not a per-framework case index).

Case-README directory depth is unchanged (3.test_cases/pytorch/nemo-rl/
deepep-v2-efa and examples/training/nemo-rl/deepep-v2-efa are both 4 levels),
so root-relative links resolve unchanged:
  ../../../../micro-benchmarks/expert-parallelism -> repo root (unchanged)
Sibling links across the reorg's inference/training split needed one fix:
  ../../slime                               -> examples/training/slime (unchanged)
  ../../sglang/dsr1-deepep-efa              -> BROKEN (sglang moved to inference/)
  ../../../inference/sglang/dsr1-deepep-efa -> examples/inference/sglang/... (fixed)

Merged upstream/main rather than rebased to preserve the commit SHAs cited in
the resolved review-thread replies. No functional change to the sample.

Signed-off-by: Anton Alexander <dmvevents@gmail.com>
@dmvevents

Copy link
Copy Markdown
Contributor Author

@KeitaW — a heads-up on a structural change to this PR since your review, so the file paths line up when you come back to it.

Reorg #1119 landed on main and removed 3.test_cases/ in favor of examples/{training,inference,use-cases}/. I've migrated this example to its new home:

3.test_cases/pytorch/nemo-rl/deepep-v2-efa/  →  examples/training/nemo-rl/deepep-v2-efa/

Done as a git mv on top of upstream/main merged in (not a rebase), so:

  • Commit history is preserved — every fix commit I referenced in the review threads above (6c14c2e, f30b929, 72d66ad, 5df9552, af2bf8f, c455bb1, 316c127, 4052250) is still reachable, so those "Fixed in <sha>" replies remain accurate.
  • The files are byte-identical renames (R100) — the only content change in the migration commit is one relative link that the depth change forced: the sibling SGLang sample also moved (to examples/inference/sglang/dsr1-deepep-efa), so the README reference now points there. The slime and micro-benchmarks/expert-parallelism links were unaffected (verified).
  • Placement follows AGENTS.md §2examples/training/<framework>/, nested beside the existing NeMo-RL (NVRx-GRPO) example rather than as a new sibling ("extend before create"). Added a short Variants section to that folder's README so the two NeMo-RL examples are cross-discoverable.

Your 13 unresolved threads re-anchored to the new paths automatically. I've re-verified each fix is present and correct at its new location; I've left them unresolved for you to close rather than resolving my own changes. Nothing about the substance of the fixes changed — only where the files live.

…-contributing fork; drop superseded awslabs#612

Repoint the DeepEP source from stock deepseek-ai/DeepEP @ 01dc3aa plus a draft
deepseek-ai/DeepEP#612 opt-in patch to the amazon-contributing/DeepEP fork,
pinned at the immutable HEAD 97d8f9bcc1be31e9036db2ab591ef9b9f4e38619. This
mirrors the sibling examples/inference/vllm/deepep-v2-gdaki-efa repoint and
answers the review note that pinning the pre-fix upstream fork-point forfeits
the AWS EFA fixes: the fork is the AWS EPv2/NCCL-GIN tree and carries the EFA
delta IN-CODE, so no awslabs#612 patch is applied on any flavor.

The fork carries both correctness halves of what was draft awslabs#612 structurally:
  - the get_rdma_gbs() sysfs link-rate fast path (deep_ep/utils/envs.py,
    provider-agnostic — reads /sys/class/infiniband/<nic>/ports/*/rate), and
  - the auto-QP overflow clamp (deep_ep/buffers/elastic.py, clamps the
    allocated QP count to _C.{min,max}_unordered_gin_qps).
It does NOT carry awslabs#612's third commit (a kScaleoutUpdateInterval 6->16 latency
micro-opt); the fork keeps =6. No gate in this example depends on that value,
so the two correctness fixes are what "supersedes awslabs#612" means here.

Because the fork carries those fixes on every flavor, this collapses the
baseline/opt-in distinction for DeepEP only:
  - the awslabs#612 entry drops out of the opt-in draft-PR layer entirely (the layer
    now bakes only Megatron-LM#4632 + NeMo-RL#2410);
  - the two dead env vars EP_EFA_MAX_QPS / EP_EFA_RDMA_GBS (the old awslabs#612 patch
    knobs, zero readers on the fork) are removed from env_vars.example and
    kubernetes/raycluster.yaml, replaced with an absence-note; and
  - the verify-image / setup fail-loud gate is rewritten to fork
    discriminators — it now asserts _get_sysfs_rdma_gbs and unordered_gin_qps
    are present in the installed deep_ep tree on EVERY flavor, not gated on the
    draft-PR marker.
EP_NUM_QPS=2 is a different variable (the probe's explicit num_allocated_qps)
and stays: it survives the fork clamp unchanged (max(2, min(2, max)) == 2).

The Dockerfile pins the full 40-char SHA as DEEPEP_SHA; setup and verify-image
fail-loud if the clone lacks either fix-half. This is the same fork the repo's
micro-benchmarks/expert-parallelism/deepep-v2-benchmark/setup_deepep_gin.sh
already clones.

## Test Results

docs-and-packaging change (source-repoint + provenance). No functional change
to the example's runtime behavior on either flavor; the fork already carried
the fixes the removed patch supplied. markdownlint-cli2 clean on the changed
README against the root .markdownlint.jsonc.

Signed-off-by: Anton Alexander <dmvevents@gmail.com>
… literally upstream-only

KeitaW review thread (nemo-rl.Dockerfile aws-ofi-nccl PR#1351 cherry-pick):
the README headline called the default image 'baseline (upstream-only)',
which reads as 'no unmerged-PR carry'. That is inaccurate. Decision: keep
PR#1351 as a PERMANENT baseline carry (it is the load-bearing GIN
CPU-proxy plugin lineage the whole substrate standardises on; gating it
opt-in would be technically wrong) and fix the CLAIM instead.

The default image carries, and always did (both disclosed in the Pins
table): the standardized AWS GIN plugin lineage incl. closed-unmerged
aws-ofi-nccl PR#1351, and the amazon-contributing/DeepEP fork. What
'baseline' actually means here is 'the 2 draft rollout PRs OFF'
(Megatron-LM#4632 + NeMo-RL#2410) — those are the only opt-in toggle,
and they DO self-neutralize once merged. #1351 does not; it is permanent.

- README intro: 'baseline (upstream-only)' -> 'baseline' + explicit note
  that it carries the AWS GIN lineage (incl. #1351) and the DeepEP fork.
- Build snippet comment: same clarification so the term can't recur.

docs-only (claim-accuracy fix; no build/behavior change).

Author-verify: gh api .../pulls/1242/commits --jq .[].author.login == dmvevents
Signed-off-by: Anton Alexander <dmvevents@gmail.com>
…vs run-time transport

env_vars.example said 'This image links NO NVSHMEM', which contradicts
requirements.txt (deep_ep links NVSHMEM unconditionally at build time via
upstream setup.py) and setup_nemo_rl_deepep_efa.sh (deep_ep's _C.so IS
build-linked against NVSHMEM). Reword to match: NVSHMEM is a build-time
link dependency; the V2 NCCL-GIN backend does not USE it at run time, so
the values below stay inert. Closes KeitaW review thread on requirements.txt
(cid 3856239817) residual.

Signed-off-by: Anton Alexander <dmvevents@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants