Skip to content

[3/9] Add disk and shm weight sync for engines without NCCL or CUDA IPC - #4210

Open
HJSang wants to merge 5 commits into
mainfrom
hejian/tpu-host-weight-sync
Open

HJSang wants to merge 5 commits into
mainfrom
hejian/tpu-host-weight-sync

Conversation

@HJSang

@HJSang HJSang commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Why

An engine on another accelerator stack, such as sglang-torchtpu, shares neither NCCL nor CUDA IPC with the trainer and serves no /pull_weights, so no existing --update-weight-transfer-mode can reach it. This adds two modes built on endpoints every sglang engine already serves. The TPU stack above (#4213 onward) syncs through them.

What changes

Two new values of --update-weight-transfer-mode; existing modes and the default (broadcast) are unchanged.

Mode Data path Engine endpoint Reaches
disk each sync writes the gathered HF weights as a fresh safetensors checkpoint under --update-weight-disk-dir; the engines reload it /update_weights_from_disk engines on any host sharing a filesystem
shm each bucket is copied into a /dev/shm tensor that the engines map in place /update_weights_from_tensor (flattened_bucket) engines on the trainer's host

Reading order:

  1. protocols/utils/data_replica.py (OwnedBuckets): after each gather, every rank of a pipeline stage holds the same bucket; each bucket goes to the replica rank with the fewest bytes so far (the rule Megatron's fully parallel save and PyTorch DCP's dedup_save_plans use), so the ranks share the host copies and writes. A rank copies its buckets to unpinned host tensors as they arrive (non-blocking), so it does not hold its share of the model on the device until the stream ends.
  2. protocols/disk.py: each rank writes its shards; rank 0 merges the index, copies the non-weight files of --hf-checkpoint, pauses the engines, reloads them and resumes, then deletes the previous version. The first sync also clears versions a crashed run left. One call replaces every weight, so it runs outside the begin/end session and without the per-request timeout (submit_unbounded_client_call).
  3. protocols/shm.py: runs inside the regular weight-update session. Each bucket is laid out on the host in the engine's flattened layout (from meta tensors; flattening on the device fails on TPU, which pads each tensor's last dimension). At most a few buckets are in shared memory at once. Each receiving process gets its own serialization, since each takes its own reference to the /dev/shm file. connect fails fast when an engine is not on this host.
  4. protocols/utils/rank_errors.py (fail_on_every_rank): a write, publish or reload error on one rank is raised on every rank, so none waits in a collective the failed rank never joins (the pattern disk-delta uses inline).
  5. protocol.py, arguments.py, multi_policy/utils.py: selection and startup checks. broadcast.py's flattening helper moves to protocols/utils/flattened_bucket.py, shared with shm.

Startup checks

Check Why
no --colocate, no LoRA, no PD disaggregation colocate always uses CUDA IPC; both modes push full base weights only; PD is untested
shm: one trainer node, each engine on one node, --sglang-pp-size 1 engines open the tensors by /dev/shm name; PP stages reuse TP ranks, so one payload would be opened twice
disk: --update-weight-disk-dir set, --hf-checkpoint a local dir, no speculative draft unless --enable-mtp-training where versions go; its config and tokenizer are copied; the reload also reads the draft from that directory
disk: not in multi-policy runs every policy would write the same version directories

Tests

Weights arrive exactly: with --check-weight-update-equal, each engine snapshots the checkpoint weights, overwrites them, and the first sync must restore every tensor bit for bit.

Platform Setup disk shm broadcast (reference)
GPU, H200 Qwen3-0.6B, FSDP, 2 trainer GPUs + 2 engines, sglang 0.5.23 exact; log-prob gap 0.0154 / 0.0159 / 0.0143 exact; 0.0152 / 0.0158 / 0.0143 exact; 0.0153 / 0.0160 / 0.0144
TPU v7x Qwen3-4B, torchtitan, 4 trainer + 4 engine devices, stack top exact on all 4 engine TP ranks exact on all 4 engine TP ranks —

The TPU check needs sglang-torchtpu's weight checker to compare off CUDA (fix pending upstream); the sync itself needs nothing new from the engine.

Sync time and memory on TPU v7x, Qwen3-4B:

Result
shm sync 3.1–4.1 s
disk sync, /dev/shm target / NFS /work target 4.1–6.0 s / 28–32 s (the NFS client connection is the limit)
Writers: all replica ranks vs one shm 3.1–4.1 s vs 9.8–10.1 s; disk on NFS about equal
Trainer device memory at the end of the bucket stream +0.2 GiB per rank (+2.1–2.3 GiB before owned buckets moved to the host at once)

Unit tests: test_disk.py (each tensor written once and found through the merged index, reload between pause and resume, previous and stale versions deleted, failed reload raises), test_shm.py (another process rebuilds every TP rank's bucket, each receiver can open the file after the others released it, the window bounds host copies, the sharing strategy is restored, an errored cell gets no payload, only engines on this host pass), test_data_replica.py (ownership balances bytes), test_rank_errors.py, test_rollout_cell_updater.py (the unbounded call outlives the per-request timeout), and the multi-policy refusal.

Limits and follow-ups

  • shm reaches only engines on the trainer's host; cross-host sync goes through disk. A network or ICI transport is a follow-up.
  • On a CUDA trainer the host copy into unpinned memory is effectively synchronous; these modes target engines without NCCL, where it does not matter much.

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@HJSang HJSang changed the title Add disk and shared-memory weight sync for engines without NCCL or CUDA IPC [2/8] Add disk and shared-memory weight sync for engines without NCCL or CUDA IPC Oct 20, 2026
@HJSang
HJSang added this pull request to stack #4219 October 20, 2026 05:09
@HJSang
HJSang force-pushed the hejian/tpu-host-weight-sync branch from 5aee0fa to 55ba5dd Compare October 20, 2026 14:46
@HJSang
HJSang removed this pull request from stack #4219 October 20, 2026 14:47
@HJSang HJSang changed the title [2/8] Add disk and shared-memory weight sync for engines without NCCL or CUDA IPC [2/9] Add disk and shared-memory weight sync for engines without NCCL or CUDA IPC Oct 20, 2026
@HJSang
HJSang force-pushed the hejian/tpu-host-weight-sync branch 3 times, most recently from 37f99f2 to 6e10e42 Compare October 20, 2026 18:15
@HJSang
HJSang added this pull request to stack #4227 October 20, 2026 18:16
@HJSang
HJSang force-pushed the hejian/tpu-host-weight-sync branch 2 times, most recently from 73ccc35 to 792c4be Compare October 20, 2026 22:09
Base automatically changed from hejian/tpu-accelerator to main October 20, 2026 22:11
@HJSang
HJSang requested a review from Rockdu as a code owner October 20, 2026 22:11
@HJSang
HJSang force-pushed the hejian/tpu-host-weight-sync branch from 792c4be to dbbf24a Compare October 20, 2026 22:11
@HJSang

HJSang commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread miles/backends/training_utils/weight_update/protocols/shm.py Outdated
Comment thread miles/backends/training_utils/weight_update/protocols/disk.py Outdated
Comment thread miles/backends/training_utils/weight_update/protocols/shm.py Outdated
Comment thread miles/backends/training_utils/weight_update/protocols/shm.py Outdated
Comment thread miles/utils/arguments.py
@HJSang
HJSang force-pushed the hejian/tpu-host-weight-sync branch from dbbf24a to 9c399bb Compare October 20, 2026 23:08
@HJSang HJSang changed the title [2/9] Add disk and shared-memory weight sync for engines without NCCL or CUDA IPC [2/9] Add disk and shm weight sync for engines without NCCL or CUDA IPC Oct 20, 2026
@HJSang
HJSang force-pushed the hejian/tpu-host-weight-sync branch 5 times, most recently from 8820713 to f64aa02 Compare October 26, 2026 20:37
@HJSang
HJSang force-pushed the hejian/tpu-host-weight-sync branch from f64aa02 to a43f1ac Compare October 26, 2026 21:44
@HJSang
HJSang force-pushed the hejian/tpu-host-weight-sync branch from a43f1ac to 4144bf7 Compare October 26, 2026 22:09
Base automatically changed from hejian/tpu-image to main October 27, 2026 17:17
@HJSang
HJSang force-pushed the hejian/tpu-host-weight-sync branch from ff35dff to 63d3daa Compare October 27, 2026 17:17
@HJSang

HJSang commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@HJSang

HJSang commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-28T03:07:56.712334Z 3daf866 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 63d3daa961

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🔴 miles/backends/training_utils/weight_update/protocols/shm.py — On IPv6 clusters, shm mode fails at connect even when every engine shares the trainer's host. _engines_off_this_host resolves each engine host with socket.gethostbyname, which supports only IPv4. An IPv6 literal such as [fd00::1] (from wrap_ipv6) raises socket.gaierror instead of returning False. _is_local_address also probes with an AF_INET socket.socket(), so an IPv6 address can never bind. Fix: resolve with socket.getaddrinfo and probe with a socket of the matching family, so IPv4, IPv6 and dual-stack hosts all classify correctly.

    Why this was flagged

    The trigger is --update-weight-transfer-mode shm on a cluster whose engine URLs carry IPv6 addresses. The repo builds those with wrap_ipv6 (kubernetes/core/cell_view.py:132, ray_worker_manager.py:364, external_engine_provider.py:197). On every rank, connect (shm.py:57) calls _engines_off_this_host. That function passes urlparse(server_url).hostname to socket.gethostbyname (shm.py:124), which raises socket.gaierror for IPv6 addresses. The exception escapes connect_rollout_engines, so the trainer fails at the first connect or reconnect, even when the engines really are on the trainer's host. Even past name resolution, _is_local_address (shm.py:358) creates an AF_INET socket, so binding an IPv6 address always fails and the engine would be reported as "run elsewhere". The base branch has no shm mode; the other modes do not do this check, so they work on the same IPv6 cluster.

    Verification: Trigger: --update-weight-transfer-mode shm with IPv6 engine URLs. In miles/backends/training_utils/weight_update/protocols/shm.py, line 124 calls socket.gethostbyname, which raises socket.gaierror for fd00::1. _is_local_address (line 130) probes with AF_INET socket.socket(), so IPv6 never binds. The base branch has no shm mode, so this is a new mode that does not work in a supported setup.

  • 🟡 miles/backends/training_utils/weight_update/protocols/utils/data_replica.py — On CUDA trainers, disk and shm syncs pin model-sized host memory and never release it between syncs. The take() docstring promises nothing stays alive after the sync, but this memory does. tensor.to("cpu", non_blocking=True) on a CUDA tensor allocates pinned memory through PyTorch's caching host allocator. That allocator keeps freed blocks cached instead of returning them to the OS. Fix: copy into pageable host memory, or free the host allocator cache after take()'s buckets are consumed, so host RAM drops back once the sync ends. The CPU-only fast tests must keep passing.

    Why this was flagged

    The trigger is a CUDA trainer with --update-weight-transfer-mode disk or shm. Every rank receives every gathered bucket (is_sender = True, disk.py:68, shm.py:67). OwnedBuckets.offer (data_replica.py:41) copies its owned share to the host with non_blocking=True. For a CUDA source and a CPU destination, ATen's _to_copy sets pin_out, so each copy is a page-locked block from the CachingHostAllocator, rounded up in size. In disk mode all owned buckets stay alive until after_base_weights. Across the ranks on a node that is up to one full model copy (with full gather, replica_size = world_size, data_replica.py:15). After the buckets are dropped, the allocator caches those pinned blocks for reuse and does not unpin them. So the trainer host permanently loses that RAM, and it cannot be swapped. The PR text says pinned memory was not wanted ("pinned memory does not change it on torch_tpu"). The base branch has no such path.

    Verification: Trigger: a CUDA trainer with --update-weight-transfer-mode disk or shm. OwnedBuckets.offer (data_replica.py:41) runs tensor.to("cpu", non_blocking=True), so each host copy is pinned memory from the CachingHostAllocator, which keeps freed blocks cached. Nothing in the diff empties it. That memory stays reserved in the trainer process between syncs.

Comment thread miles/backends/training_utils/weight_update/protocols/disk.py
@HJSang

HJSang commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the two findings outside the diff in a3e9d0c: IPv6 engine hosts now resolve with getaddrinfo and probe a socket of the matching family; owned buckets copy non-blocking into unpinned host tensors, so no pinned memory stays cached. Thanks.

@HJSang

HJSang commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: a3e9d0c5a4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

HJSang and others added 5 commits October 27, 2026 16:34
…DA IPC

An engine on another accelerator stack (sglang-torchtpu) shares neither NCCL
nor CUDA IPC with the trainer, and serves no /pull_weights, so none of the
existing transfer modes reach it.

--update-weight-transfer-mode disk writes the gathered HF buckets as a fresh
safetensors checkpoint on a shared filesystem and has the engines reload it
through /update_weights_from_disk. --update-weight-transfer-mode shm copies each
flattened bucket into a shared-memory tensor and hands its handle to engines on
the trainer's hosts through /update_weights_from_tensor, so nothing is written
or parsed. In both, the ranks holding identical gathered buckets split them
round-robin and copy only after the collective gathers, so the host copies run
in parallel.

shm runs inside the begin/end weight-update session; disk pauses the engines
around its one reload call, as disk-delta does. Engine calls go through the
rollout-cell updaters, so a failed engine is reported per cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…owth

- Copy each owned bucket to the host when it is offered instead of holding
  it on the device until the stream ends, which added model/replica_size to
  every rank's peak device memory.
- Raise a rank's write, publish or reload error on every rank, so the others
  do not wait in a collective the failed rank never joins.
- Clear the versions a crashed run left in --update-weight-disk-dir on the
  first sync; each is a full checkpoint.
- Fail at connect when an shm engine is not on the trainer's host, and in
  validation when an shm engine spans nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A worker must stay reachable over rpc, so it may not import ray
(tests/fast/utils/workers/test_ray_communication_boundary.py). Binding the
engine's address answers the actual question, whether it belongs to this
machine, also on hosts with several addresses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…c for multi-policy

- Resolve shm engine hosts with getaddrinfo and probe each address with a
  socket of its family, so IPv6 engine URLs no longer fail at connect.
- Copy owned buckets non-blocking into unpinned host tensors:
  `.to("cpu", non_blocking=True)` pins memory that CUDA's host allocator keeps
  after the sync. A blocking copy would also avoid it, but took Qwen3-4B shm
  syncs on TPU from 3.1-3.9 s to 6.5-6.7 s.
- Refuse --update-weight-transfer-mode disk in multi policy runs, where every
  policy would write the same version directories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each bucket goes to the replica rank with the fewest bytes so far, as
Megatron's fully parallel save and PyTorch DCP's dedup_save_plans balance
replicated writes; round-robin by count let an embedding-sized bucket skew
one rank's share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@HJSang
HJSang force-pushed the hejian/tpu-host-weight-sync branch from 6e84a57 to 3daf866 Compare October 28, 2026 00:43
@HJSang

HJSang commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@HJSang

HJSang commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3daf86628d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# a crashed run leaves full checkpoints behind, and partial shards in the version a resume rewrites
for stale_dir in glob.glob(os.path.join(self.root_dir, "weight_v*")):
shutil.rmtree(stale_dir, ignore_errors=True)
os.makedirs(self._version_dir, exist_ok=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Propagate version-directory creation failures to every rank

In a multi-rank run, if the shared filesystem is unwritable, full, or unavailable, os.makedirs raises only on rank 0. Other ranks return from begin_sync and enter _mark_cells_errored_on_any_rank in WeightUpdater.update_weights, whose collective rank 0 will never join, leaving the workers blocked until the distributed timeout instead of reporting the filesystem failure promptly. Wrap the rank-0 directory preparation in fail_on_every_rank, as the subsequent shard-writing and reload steps already do.

Useful? React with 👍 / 👎.

Comment on lines +116 to +118
updater.submit_unbounded_client_call(
"update_weights_from_disk", model_path=self._version_dir, weight_version=str(weight_version)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Disk-mode runs whose engines start with --sglang-load-format dummy (or MILES_SGLANG_DUMMY_LOAD=1) fail every weight sync, so the engines never receive the trained weights. The update_weights_from_disk call at disk.py:116-118 passes no load_format. When the request leaves it unset, SGLang falls back to the engine's startup load format, and its reload accepts only DefaultModelLoader. Fix: pass the format of the files disk mode writes, e.g. load_format="safetensors", so the reload never inherits the engine's startup loader.

Why this was flagged

This needs an SGLang build whose /update_weights_from_disk falls back to server_args.load_format and rejects loaders other than DefaultModelLoader. The repo starts engines that way when they get their weights from the trainer: sglang_engine.py:129-130 (MILES_SGLANG_DUMMY_LOAD), examples/infra_features/p2p_weight_transfer/run.py:984, and scripts/run_deepseek_v4.py:717. The trigger is --update-weight-transfer-mode disk with dummy-loaded engines. _reload_engines (disk.py:110-122) then gets HTTP 400 from every engine, and _make_request raises. With FT off, mark_errored re-raises and every rank fails at the first sync, leaving the engines paused. With weight_update FT on, every cell is marked errored and keeps serving dummy weights. No argument check rejects this pairing. On the base branch disk mode does not exist, and broadcast/shm push the tensors directly, so they work with dummy-loaded engines.

Verification: _reload_engines (disk.py:116-118) passes no load_format. SGLangApiClient.update_weights_from_disk (sglang_api_client.py:344-358) adds load_format to the payload only when it is not None. sglang_engine.py:129-130 sets kwargs["load_format"] = "dummy" when MILES_SGLANG_DUMMY_LOAD == "1". The new disk asserts in arguments.py:1296-1310 do not cover the load format.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-ci-weight-update Run weight update tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant