Skip to content

exp112: 3B Qwen3 on CoreWeave via NVIDIA NeMo — MFU comparison vs Levanter#113

Open
timodonnell wants to merge 9 commits into
mainfrom
claude/exp112-nemo-mfu
Open

exp112: 3B Qwen3 on CoreWeave via NVIDIA NeMo — MFU comparison vs Levanter#113
timodonnell wants to merge 9 commits into
mainfrom
claude/exp112-nemo-mfu

Conversation

@timodonnell

Copy link
Copy Markdown
Member

Answers issue #112: same 3B Qwen3 contacts-v1 model as exp108, but on NVIDIA NeMo / Megatron-Core instead of JAX/Levanter, to see whether we beat exp108's poor ~15% MFU.

Result — yes, ~1.75×

Single 8×H100, batch 128 × seq 8192, bf16, grad-ckpt on, mock data (MFU is compute-bound → mock is faithful; it's how NVIDIA publishes its perf tables). NeMo 2.3.2 / nvcr.io/nvidia/nemo:25.04.02.

run micro-batch s/step tokens/s MFU
NeMo 1 11.69 89,737 25.4%
NeMo 2 11.34 92,465 26.2%
NeMo 4 11.29 92,861 26.3%
exp108 Levanter ~20 ~52,000 ~15%

~26% MFU vs ~15%, ~93k vs ~52k tok/s, ~11.3 vs ~20 s/step on identical model/shape/hardware, out of the box. Micro-batch barely matters (memory-bound with grad checkpointing).

What this is / how it works

The novelty vs exp108 is the payload, not the submission: exp108 dispatched a levanter callable into marin's iris-task image; exp112 dispatches a torchrun binary into the foreign NeMo container at batch priority.

  • iris feasibility (verified): a job can override its image (ResourceConfig.image) and run a binary entrypoint (Entrypoint.from_binary). The only iris gap — no torchrun rendezvous API — bites multi-node only; single-node torchrun --standalone sidesteps it. Batch band via JobRequest.priority=3 (as exp108).
  • Code delivery: the NeMo image has no repo checkout, so dispatch_nemo.py base64-inlines the training script into the bootstrap bash; create_environment(docker_image=…) (not workspace) so the launcher env is not synced into the container.
  • Model: exact exp108 geometry as a Megatron llm.GPTConfig — 2048h/48L/32h·8kv GQA / QK-norm / RMSNorm / SwiGLU / RoPE, vocab 2845.
  • Metric: throughput (tokens/s, s/step) leads (formula-free, directly comparable to exp108); MFU (÷989 H100 bf16 peak) is the cross-check.

Validated locally in the NeMo container (--tiny) before any cluster time, then the full path ran first-try on cw-rno2a.

Files

common.py (constants), nemo_train_qwen3.py (in-container, base64-inlined), dispatch_nemo.py (batch-priority Fray dispatch), launcher-only pyproject.toml/uv.lock. Everything under one removable s3://…/MarinFold/exp112_qwen_3b_nemo_mfu/ prefix.

Deferred (documented in README)

Remaining MFU headroom (→ ~35–40%) = grad-ckpt recompute + bf16 (no fp8) + DP-replicated params — all known levers. A full run would need the real .bin/.idx data path + multi-node torchrun rendezvous.

Does not close #112 — experiments are closed by humans.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SH6m5LcVoDRKsDYVD89Fwf

timodonnell and others added 9 commits July 7, 2026 11:40
First non-JAX training path on the CoreWeave cw-rno2a cluster: dispatch a
foreign NVIDIA NeMo container (nvcr.io/nvidia/nemo:25.04.02) as a batch-priority
Fray job with a torchrun BINARY entrypoint, to benchmark MFU on the same 3B
Qwen3 contacts-v1 model as exp108's Levanter run (~15% MFU baseline).

Approach (benchmark-first, per #112 scope):
- Single 8xH100 node, torchrun --standalone (sidesteps iris's missing multi-node
  torchrun rendezvous API; only DP is needed at 2.9B).
- Mock/synthetic data for the MFU number (compute-bound, data-independent), so
  the tokenize->bin/idx pipeline is deferred to a real run.
- dispatch_nemo.py base64-inlines the in-container train script (no repo checkout
  in the NeMo image); create_environment(docker_image=...) so the launcher
  workspace is NOT synced into the container; JobRequest(priority=3) batch band.
- nemo_train_qwen3.py builds the exact exp108 geometry as a Megatron llm.GPTConfig
  (2048h/48L/32h.8kv GQA/qk_layernorm/RMSNorm/SwiGLU/RoPE, vocab 2845) and reports
  throughput (tokens/s, s/step) + MFU. Throughput leads (formula-free, directly
  comparable to exp108); MFU is the cross-check.

Validated live in the NeMo 2.3.2 container on a local GPU (--tiny): tokenizer
load, full model/data/trainer/optimizer construction, 5+ training steps, and the
throughput/MFU summary all work. Launcher dry-run builds the batch-band JobRequest
(image + nproc=8 + inlined bootstrap). Not yet run on the cluster.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH6m5LcVoDRKsDYVD89Fwf
Single 8xH100, batch 128 x seq 8192, bf16, grad-ckpt on, mock data (MFU is
compute-bound, so mock is faithful). Micro-batch sweep {1,2,4}:

  mbs  s/step  tok/s   MFU
   1   11.69   89,737  25.4%
   2   11.34   92,465  26.2%
   4   11.29   92,861  26.3%   <- best

vs exp108 Levanter ~20 s/step / ~52k tok/s / ~15% MFU  =>  ~1.75-1.79x.

The whole novel path worked first try on cw-rno2a: nvcr.io image pull, torchrun
binary entrypoint at batch band, base64-inlined script, 8-GPU bringup, S3 result
upload. Remaining MFU headroom (to ~35-40%) is grad-ckpt recompute + bf16 (no fp8)
+ DP-replicated params — all documented as deferred levers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH6m5LcVoDRKsDYVD89Fwf
…(issue #112)

Extends the benchmark scaffold toward the actual 16-epoch trained model (Tim's
ask: a trained model, sooner). Validated locally in the NeMo 2.3.2 container.

- prepare_megatron_data.py: contacts-v1 parquet `document` -> JSONL ->
  preprocess_data.py (HF tokenizer, --append-eod) -> Megatron .bin/.idx on S3.
  Ran full corpus: 4,129,682 docs -> 9.35 GB train bin (+ val).
- nemo_train_qwen3.py (--data real): PreTrainingDataModule on bin/idx with
  reset_attention_mask/reset_position_ids (block cross-doc attn); periodic val
  loss; NeMo ModelCheckpoint; an S3CheckpointSync callback that syncs the sharded
  (DCP) checkpoint per-node to S3 (no shared FS on cw-rno2a) + prunes/commits
  latest.txt; a resume preamble that mirrors the latest S3 checkpoint back before
  AutoResume. Callbacks moved to MODULE level (NeMo io-dumps the trainer context
  via fiddle, which can't pyref a main.<locals> class).
- dispatch_nemo.py: replicas=N gang; bootstrap does S3 torchrun rendezvous
  (rank-0 publishes IRIS_ADVERTISE_HOST to an attempt-scoped key; peers poll) for
  multi-node, --standalone for single; downloads bin/idx per node;
  max_retries_preemption=100 so a preempted gang auto-restarts and resumes.
- common.py: replicas param, data/ckpt/rdzv S3 prefixes, num_train_steps (71,312
  @ bs128 = 16 epochs, matches exp108).

Locally validated (single-GPU, container): real-data training, checkpoint save +
S3 sync (per-node shard upload, prune, latest.txt), and RESUME — a fresh container
pulled the S3 checkpoint and continued from the saved step with LR schedule intact.
Multi-node rendezvous is validated next on a 2-node cluster smoke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH6m5LcVoDRKsDYVD89Fwf
Validated 16-rank (2×8 H100) training + val + checkpoint sync live. Fixes for
the real multi-node / no-shared-FS gotchas found on the cluster (each was a
distinct blocker uncovered in sequence):

- Rendezvous: IRIS_TASK_ID has no ":attempt" suffix in-pod -> use a gang-common
  (run_name) key, not a per-node one; publish/poll rank-0's IP over the CONSISTENT
  object endpoint (cwobject.com), because the injected LOTA cache negative-caches
  a cross-node poll-before-write.
- Gloo: pin GLOO_SOCKET_IFNAME to the host-eth iface — found via Python stdlib
  SIOCGIFADDR (the NeMo container has no working `ip`), matching IRIS_ADVERTISE_HOST.
  Without it Gloo (Megatron CPU groups + distributed optimizer) connectFullMesh
  fails across nodes.
- Dataset index (no shared FS): Megatron builds the .npy on GLOBAL rank-0 into a
  node-local dir; patch the builder so DURING build get_rank() reports LOCAL rank
  -> each node's local-0 builds its own cache before the collective barrier.
- Checkpoint sync: detect a NEW checkpoint by its consumed_samples (NeMo's step-
  count trigger fired before ModelCheckpoint wrote the dir); each node uploads its
  own DCP shards (union = complete), rank-0 prunes + commits latest.txt; also sync
  on_train_end for the final checkpoint.
- set -e footgun in the bootstrap (`[ ] && cmd` -> if/then).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH6m5LcVoDRKsDYVD89Fwf
Validated 2-node resume live (restore sharded ckpt from S3, continue with model
state preserved — loss picked up at 5.84 exactly where it left off).

- Rendezvous freshness: a preemption-restart reuses the run_name key, so a peer
  could read the previous attempt's DEAD master IP and hang 900s in torchrun.
  The poller now stamps a probe to read the S3 server clock and only trusts
  `master` if written this attempt (age < 120s), else waits for rank-0's fresh
  write. (No attempt-unique iris env var is available in-pod.)
- Checkpoint sync throttle: NeMo bumps the `-last` ckpt's consumed_samples every
  step, so the detect-new-ckpt sync uploaded ~40 GB every step. Throttle to one
  upload per checkpoint_every global steps (deterministic across ranks ->
  barrier-safe); still catches the final ckpt on_train_end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH6m5LcVoDRKsDYVD89Fwf
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH6m5LcVoDRKsDYVD89Fwf
…rope theta

Replicate the best contacts-v1 model from #75 (Qwen3 1.47B: 2048h/8192ffn/32h·8kv/
24 layers, qk-norm, Llama3 rope theta 500000, lr 1e-3/wd 0.2) but 16 epochs. The
earlier 48-layer ~2.9B config (from a stale exp108 README) was a by-depth 3B, not
#75; the actual #75 1.5B is 24 layers at this width (exp67/exp85 protein_llama_1_5b
+ Qwen3 qk-norm/rope per marin eac/plm-exp75). Set rotary_base 500000 (the #75
Llama3 theta; NeMo's generic GPTConfig lacks the llama3 freq-scaling knob, a
context-extension feature dubious at train seq==8192 — one documented divergence).
Run renamed plm-exp112-cv1-1_5b-nemo-e16-lr1e-3-wd0p2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH6m5LcVoDRKsDYVD89Fwf
…2:0') crash)

IRIS_TASK_ID is inconsistent: some gangs get '/user/job/gang/<rank>', others
'/user/job/gang/<rank>:<attempt>'. An earlier simplification dropped the ':attempt'
strip, so a 4-node gang whose id carried ':0' crashed on int(NODE_RANK). Restore
the two-step parse (strip ':attempt' then take the last path component).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH6m5LcVoDRKsDYVD89Fwf
The 1.5B run failed at step ~35.5k when one coscheduled sibling node died and
gang semantics killed the rest with no retry (max_retries_failure=0). The code is
validated (35k steps), so a mid-run failure is transient infra, not a bug — set
max_retries_failure=20 so a failed gang auto-retries and resumes from the S3
checkpoint (each retry re-runs the bootstrap -> restore -> AutoResume). Also bump
the S3 client max_pool_connections to 32 (>= the 16-thread checkpoint uploader) to
stop the 'connection pool is full' warning flood during checkpoint sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH6m5LcVoDRKsDYVD89Fwf
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.

exp: train a 3B qwen3 model on coreweave but use nvidia nemo framework for mfu comparison

1 participant