Skip to content

feat(ascend): support full and delta weight sync over disk - #422

Draft
wangx700 wants to merge 2 commits into
vllm-project:ascendfrom
wangx700:feature/ascend-disk-full-delta
Draft

wangx700 wants to merge 2 commits into
vllm-project:ascendfrom
wangx700:feature/ascend-disk-full-delta

Conversation

@wangx700

@wangx700 wangx700 commented Sep 11, 2026

Copy link
Copy Markdown

Summary

Ascend currently selects only the colocated tensor updater or distributed collective updater. This adds non-colocated --update-weight-transport disk with both --update-weight-mode full and --update-weight-mode delta, based on ascend at 083aea6.

  • Full mode gathers HF weights using Ascend's existing conversion iterator, writes safetensors shards and an index, then pauses rollout, flushes cache, reloads the published checkpoint and resumes generation.
  • Delta mode captures the original HF checkpoint as version zero and publishes Zstandard-compressed XOR or overwrite deltas with resulting-tensor checksums. VLLMEngine.pull_weights uses /collective_rpc to materialize the checkpoint on every NPU worker host before disk reload.
  • Port the local-checkpoint receiver and its tests from main's docker/patch/latest/vllm-pull_weights.patch into the existing NPU patch series, and add the NPU worker RPC entry point. Preserve the original model path as the baseline across reloads. Existing Dockerfile/series entries already apply both updated patches.
  • Add run-qwen3-4B-full-disk.sh and run-qwen3-4B-delta-disk.sh, shared Qwen3-4B settings, configurable paths/Ray ports, and usage/flow documentation. These launchers require explicit device allocation and do not kill existing processes.
  • Cap requested pinned pool storage at 8 GiB, release partial allocations on failure, and return buffers on worker-copy and pre-submission failures. Validate checkpoint headers, tensor offsets and short reads with file/tensor context. Bound receiver extraction to its own file diff in tests.

This adapts the disk protocol to Ascend's current updater lifecycle rather than importing main's newer RayTrainGroup architecture. The default collective path is unchanged; sparse HCCL and profiling changes are outside this PR.

Validation

  • Remote Ascend container, CPU tests: python3 -m pytest -q tests/unit/backends/megatron_utils/update_weight/test_disk_weight_sync.py tests/unit/utils/test_disk_delta.py29 passed. Covers full/delta round trips, unchanged versions and repeated pulls, copy/submission failure injection, pinned allocation limits and cleanup, patch extraction boundaries, and missing/corrupted/truncated checkpoints. Pinned allocation behavior is simulated in CPU tests; this does not measure physical NPU host memory use.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces disk-based weight synchronization (both full and delta-based) for Ascend NPU environments, allowing weight updates over a shared filesystem using XOR or overwrite delta encodings with zstd compression. The review feedback highlights several critical reliability and robustness improvements: wrapping the delta compression's pinned buffer copy in a try-except block to prevent deadlocks on exception, adding robust error handling for missing or corrupted safetensors files in _tensor_locations, lowering the pinned host buffer memory limit from 32 GB to 4 GB to avoid host instability, and making the patch parser in unit tests more robust against future patch additions.

Comment on lines +227 to +234
def diff_and_compress(name, buf, nbytes, pinned):
if pinned: # copy out and free the pinned buffer before the heavy diff/compress
new = np.empty(nbytes, dtype=np.uint8)
np.copyto(new, buf.numpy()[:nbytes])
free_q.put(buf)
else:
new = buf
old = snapshot[name]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

If an exception (such as MemoryError during np.empty or np.copyto) occurs inside diff_and_compress before free_q.put(buf) is called, the pinned buffer will be leaked from the pool. If this happens, the main thread's free_q.get() call will eventually block indefinitely, causing a silent deadlock of the entire training process. Wrapping the copy operation in a try...except block ensures that the buffer is always returned to the queue under any failure condition.

        def diff_and_compress(name, buf, nbytes, pinned):
            try:
                if pinned:  # copy out and free the pinned buffer before the heavy diff/compress
                    new = np.empty(nbytes, dtype=np.uint8)
                    np.copyto(new, buf.numpy()[:nbytes])
                    free_q.put(buf)
                else:
                    new = buf
            except Exception:
                if pinned:
                    free_q.put(buf)
                raise
            old = snapshot[name]

Comment thread vime/utils/disk_delta.py
Comment on lines +60 to +72
def _tensor_locations(ckpt_dir: str) -> dict[str, tuple[str, int, int]]:
"""Map each tensor name to (file, byte offset, nbytes) by reading every safetensors header."""
locations: dict[str, tuple[str, int, int]] = {}
for path in glob.glob(os.path.join(ckpt_dir, "*.safetensors")):
with open(path, "rb") as f:
(header_len,) = struct.unpack("<Q", f.read(8))
header = json.loads(f.read(header_len))
for name, info in header.items():
if name == "__metadata__":
continue
begin, end = info["data_offsets"]
locations[name] = (path, 8 + header_len + begin, end - begin)
return locations

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The _tensor_locations function currently assumes that .safetensors files are always present and correctly formatted. If no .safetensors files are found, it silently returns an empty dictionary, which causes downstream components to raise unhelpful KeyError exceptions and flood the logs with warnings. Additionally, if any file is truncated or corrupted, struct.unpack or json.loads will raise unhandled exceptions. Adding explicit validation and robust error handling here will make the initialization process much more reliable.

def _tensor_locations(ckpt_dir: str) -> dict[str, tuple[str, int, int]]:
    """Map each tensor name to (file, byte offset, nbytes) by reading every safetensors header."""
    paths = glob.glob(os.path.join(ckpt_dir, "*.safetensors"))
    if not paths:
        raise FileNotFoundError(f"No .safetensors files found in checkpoint directory: {ckpt_dir}")
    locations: dict[str, tuple[str, int, int]] = {}
    for path in paths:
        try:
            with open(path, "rb") as f:
                header_bytes = f.read(8)
                if len(header_bytes) < 8:
                    raise ValueError("File header is truncated (less than 8 bytes)")
                (header_len,) = struct.unpack("<Q", header_bytes)
                header = json.loads(f.read(header_len))
        except (struct.error, json.JSONDecodeError, ValueError) as e:
            raise RuntimeError(f"Failed to parse safetensors header from {path}: {e}") from e
        for name, info in header.items():
            if name == "__metadata__":
                continue
            begin, end = info["data_offsets"]
            locations[name] = (path, 8 + header_len + begin, end - begin)
    return locations

Comment on lines +221 to +222
for _ in range(max(4, min(2 * NUM_WORKERS, (32 << 30) // max(max_bytes, 1)))):
free_q.put(torch.empty(max_bytes, dtype=torch.uint8, pin_memory=True))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Using 32 << 30 (32 GB) as the memory limit for calculating the number of pinned host buffers can lead to extremely large pinned memory allocations on large models (e.g., allocating dozens of buffers of several hundred megabytes each). This can easily exceed the system's memlock limit or cause host Out-Of-Memory (OOM) errors. Lowering this limit to a safer threshold like 4 GB is more than sufficient for pipelining and prevents host instability.

Suggested change
for _ in range(max(4, min(2 * NUM_WORKERS, (32 << 30) // max(max_bytes, 1)))):
free_q.put(torch.empty(max_bytes, dtype=torch.uint8, pin_memory=True))
for _ in range(max(4, min(2 * NUM_WORKERS, (4 << 30) // max(max_bytes, 1)))):
free_q.put(torch.empty(max_bytes, dtype=torch.uint8, pin_memory=True))

Comment on lines +42 to +44
patch = (ROOT / "docker/npu_patch/vllm.patch").read_text()
section = patch.split("+++ b/vllm/utils/local_checkpoint.py\n", 1)[1]
source = "\n".join(line[1:] for line in section.splitlines() if line.startswith("+")) + "\n"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The test currently parses vllm.patch by splitting at +++ b/vllm/utils/local_checkpoint.py\n and reading all subsequent lines starting with +. If any other file patches are appended to vllm.patch in the future, their diff lines will also be parsed as part of local_checkpoint.py, causing syntax errors or test failures. Splitting the section at the next diff --git line makes the parser robust and future-proof.

Suggested change
patch = (ROOT / "docker/npu_patch/vllm.patch").read_text()
section = patch.split("+++ b/vllm/utils/local_checkpoint.py\n", 1)[1]
source = "\n".join(line[1:] for line in section.splitlines() if line.startswith("+")) + "\n"
patch = (ROOT / "docker/npu_patch/vllm.patch").read_text()
section = patch.split("+++ b/vllm/utils/local_checkpoint.py\n", 1)[1]
section = section.split("\ndiff --git", 1)[0]
source = "\n".join(line[1:] for line in section.splitlines() if line.startswith("+")) + "\n"

Comment thread vime/utils/arguments.py
if args.save_interval is not None:
assert args.save is not None, "'--save' is required when save_interval is set."

if args.update_weight_mode == "delta":

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.

Are these added parameters aligned with the main architecture? If they are extra, what prevents them from being reused?

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