support rl feature : weight checker (#51350)

Signed-off-by: shiyuan680 <yangcheng104@huawei.com> Signed-off-by: Ronald1995 <ronaldautomobile@163.com> Signed-off-by: aoshen02 <aoshen02@users.noreply.github.com> Co-authored-by: shiyuan680 <yangcheng104@huawei.com> Co-authored-by: Ronald1995 <ronaldautomobile@163.com> Co-authored-by: DeepSeek Harness <noreply@deepseek.com> Co-authored-by: aoshen02 <aoshen02@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: aoshen02 <aoshen@inferact.ai>

shiyuan680 committed Sep 30, 2026 at 11:13 UTC 42d9f8ccdbbb7e24c173272809c66ad627a0de9d
15 files changed +611 -2
docs/training/rlhf.md
+1 -1
@@ -16,7 +16,7 @@ The following open-source RL libraries use vLLM for fast rollouts (sorted alphab
16 - [Unsloth](https://github.com/unslothai/unsloth)
17 - [verl](https://github.com/volcengine/verl)
18
19 -For weight synchronization between training and inference, see the [Weight Transfer](weight_transfer/README.md) documentation, which covers the pluggable backend system with [NCCL](weight_transfer/nccl.md) (multi-GPU) and [IPC](weight_transfer/ipc.md) (same-GPU) engines.
19 +For weight synchronization between training and inference, see the [Weight Transfer](weight_transfer/README.md) documentation, which covers the pluggable backend system with [NCCL](weight_transfer/nccl.md) (multi-GPU) and [IPC](weight_transfer/ipc.md) (same-GPU) engines. To verify that an update landed on every rank, see the [Weight Checker](weight_checker.md).
20
21 For pipelining generation and training to improve GPU utilization and throughput, see the [Async Reinforcement Learning](async_rl.md) guide, which covers the pause/resume API for safely updating weights mid-flight.
22
docs/training/weight_checker.md new
+60
@@ -0,0 +1,60 @@
1 +# Weight Checker
2 +
3 +The Weight Checker verifies that an RL weight update landed on every rank. It
4 +hashes each model parameter with SHA-256, so a caller can save the original
5 +digests, reset the weights, transfer them back, and confirm they match.
6 +
7 +Enable it with `VLLM_SERVER_DEV_MODE=1`. All operations use
8 +`POST /weight_checker`:
9 +
10 +| Request | Response |
11 +| --- | --- |
12 +| `{"action": "checksum"}` | `{"checksums": {key: sha256_hex}}` |
13 +| `{"action": "reset"}` | `{"status": "reset"}` |
14 +| `{"action": "compare", "baseline": {key: sha256_hex}}` | `{"match": bool, "mismatches": [key]}` |
15 +
16 +Keys have the form `dp{dp}:pp{pp}:pcp{pcp}:tp{tp}:{tensor_name}`, so each
17 +shard is checked separately. All actions cover the target model's parameters,
18 +which is what weight loading writes; `reset` zeroes them. The endpoint keeps no
19 +state, so the caller holds the baseline. Invalid requests return HTTP 400.
20 +
21 +## RL weight-update check
22 +
23 +```bash
24 +URL=http://localhost:8000
25 +
26 +# 1. Stop serving, then save the original digests.
27 +curl -X POST "$URL/pause?mode=abort"
28 +curl -X POST $URL/weight_checker -H 'Content-Type: application/json' \
29 + -d '{"action":"checksum"}' > baseline.json
30 +
31 +# 2. Zero the weights.
32 +curl -X POST $URL/weight_checker -H 'Content-Type: application/json' \
33 + -d '{"action":"reset"}'
34 +
35 +# 3. Transfer the original weights with start/update/finish_weight_update.
36 +
37 +# 4. Compare against the baseline; expect {"match": true, "mismatches": []}.
38 +curl -X POST $URL/weight_checker -H 'Content-Type: application/json' \
39 + -d "{\"action\":\"compare\",\"baseline\":$(jq .checksums baseline.json)}"
40 +
41 +curl -X POST $URL/resume
42 +```
43 +
44 +## Limitations
45 +
46 +- Stay paused from `checksum` to `compare`: the weights are invalid between
47 + `reset` and the transfer, and EPLB moves experts while serving.
48 +- A request covers the engines managed by the API server it reaches. With
49 + several API servers (for example `--data-parallel-external-lb`), send it to
50 + each one.
51 +- Buffers, draft models and LoRA adapters are not checked.
52 +- CPU backends that repack linear weights hide them from the checker.
53 +- compressed-tensors online transforms are not supported: their Hadamard
54 + weights are normalized once per storage, so restored copies differ.
55 +- MoE weights padded beyond the checkpoint shape (DeepEP hidden size, MXFP4)
56 + keep uninitialized padding, so `compare` reports them as mismatches.
57 +- The engine must be awake: sleep levels 1 and 2 release the weight memory.
58 +- `--offload-backend prefetch` is not supported: parameters point at staging
59 + buffers, not the offloaded weights.
60 +- Hashing copies every weight to CPU, so keep it off latency-sensitive paths.
rust/src/server/src/routes.rs
+2
@@ -18,6 +18,7 @@ mod server_info;
18 mod sleep;
19 mod tokenize;
20 mod version;
21 +mod weight_checker;
22 mod weight_transfer;
23 mod world_size;
24
@@ -142,6 +143,7 @@ fn build_router_with_options(
143 post(weight_transfer::update_weight_version),
144 )
145 .route("/weight_info", get(weight_transfer::weight_info))
146 + .route("/weight_checker", post(weight_checker::weight_checker))
147 .route("/abort_requests", post(abort_requests::abort_requests))
148 .route("/sleep", post(sleep::sleep))
149 .route(
rust/src/server/src/routes/tests.rs
+76
@@ -5813,6 +5813,82 @@ async fn collective_rpc_route_sends_expected_utility_call_and_returns_results()
5813 );
5814 }
5815
5816 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5817 +#[serial]
5818 +async fn weight_checker_route_merges_worker_checksums_and_compares() {
5819 + let (mut app, engine_task) = test_admin_app_with_engine_script(|dealer, push| {
5820 + boxed_test_future(async move {
5821 + let workers = Value::Array(vec![
5822 + Value::Map(vec![(Value::from("tp0:a"), Value::from("1"))]),
5823 + Value::Map(vec![(Value::from("tp1:a"), Value::from("2"))]),
5824 + ]);
5825 + for (method, result) in [
5826 + ("compute_weight_checksums", workers.clone()),
5827 + ("compute_weight_checksums", workers),
5828 + ("reset_weights", Value::Array(vec![Value::Nil, Value::Nil])),
5829 + ] {
5830 + let utility = recv_engine_message(dealer).await;
5831 + let payload = decode_value(&utility[1]).expect("decode utility payload");
5832 + let array = payload.as_array().expect("utility payload array");
5833 + assert_eq!(array[2], Value::from("collective_rpc"));
5834 + assert_eq!(
5835 + array[3].as_array().expect("rpc args")[0],
5836 + Value::from(method)
5837 + );
5838 + let call_id = array[1].as_u64().expect("call id");
5839 + let envelope = UtilityResultEnvelope::without_type_info(result);
5840 + send_outputs(push, utility_outputs(call_id, envelope)).await;
5841 + }
5842 + })
5843 + })
5844 + .await;
5845 +
5846 + let (status, body) =
5847 + post_json(&mut app, "/weight_checker", json!({"action": "checksum"})).await;
5848 + assert_eq!(status, StatusCode::OK);
5849 + assert_eq!(body, json!({"checksums": {"tp0:a": "1", "tp1:a": "2"}}));
5850 +
5851 + let baseline = json!({"tp0:a": "1", "tp1:a": "9", "tp2:a": "3"});
5852 + let (status, body) = post_json(
5853 + &mut app,
5854 + "/weight_checker",
5855 + json!({"action": "compare", "baseline": baseline}),
5856 + )
5857 + .await;
5858 + assert_eq!(status, StatusCode::OK);
5859 + assert_eq!(
5860 + body,
5861 + json!({"match": false, "mismatches": ["tp1:a", "tp2:a"]})
5862 + );
5863 +
5864 + let (status, body) = post_json(&mut app, "/weight_checker", json!({"action": "reset"})).await;
5865 + assert_eq!(status, StatusCode::OK);
5866 + assert_eq!(body, json!({"status": "reset"}));
5867 + engine_task.abort_and_join().await;
5868 +}
5869 +
5870 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5871 +#[serial]
5872 +async fn weight_checker_route_rejects_invalid_requests_before_engine_calls() {
5873 + let (mut app, engine_task) = test_admin_app_with_engine_script(|dealer, _push| {
5874 + boxed_test_future(async move {
5875 + let message = recv_engine_message(dealer).await;
5876 + panic!("invalid request reached engine: {message:?}");
5877 + })
5878 + })
5879 + .await;
5880 + for body in [
5881 + json!({}),
5882 + json!({"action": "frobnicate"}),
5883 + json!({"action": "compare"}),
5884 + json!({"action": "compare", "baseline": {"k": 1}}),
5885 + ] {
5886 + let (status, _) = post_json(&mut app, "/weight_checker", body.clone()).await;
5887 + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
5888 + }
5889 + engine_task.abort_and_join().await;
5890 +}
5891 +
5892 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5893 #[serial]
5894 async fn sleep_route_uses_python_compatible_default_query_values() {
rust/src/server/src/routes/weight_checker.rs new
+113
@@ -0,0 +1,113 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use std::collections::{BTreeMap, BTreeSet};
5 +use std::sync::Arc;
6 +
7 +use axum::Json;
8 +use axum::extract::State;
9 +use axum::extract::rejection::JsonRejection;
10 +use serde::{Deserialize, Serialize};
11 +use serde_json::Value as JsonValue;
12 +
13 +use crate::error::{ApiError, invalid_request};
14 +use crate::state::AppState;
15 +use crate::utils::utility_call_error;
16 +
17 +#[derive(Debug, Deserialize, PartialEq)]
18 +#[serde(rename_all = "snake_case")]
19 +pub(crate) enum WeightCheckerAction {
20 + Checksum,
21 + Reset,
22 + Compare,
23 +}
24 +
25 +#[derive(Debug, Deserialize)]
26 +pub(crate) struct WeightCheckerRequest {
27 + action: WeightCheckerAction,
28 + #[serde(default)]
29 + baseline: Option<BTreeMap<String, String>>,
30 +}
31 +
32 +#[derive(Serialize)]
33 +#[serde(untagged)]
34 +pub(crate) enum WeightCheckerResponse {
35 + Checksums {
36 + checksums: BTreeMap<String, String>,
37 + },
38 + Reset {
39 + status: &'static str,
40 + },
41 + Compare {
42 + #[serde(rename = "match")]
43 + matched: bool,
44 + mismatches: Vec<String>,
45 + },
46 +}
47 +
48 +/// Checksum, reset, or compare model weights against a baseline.
49 +pub async fn weight_checker(
50 + State(state): State<Arc<AppState>>,
51 + body: Result<Json<WeightCheckerRequest>, JsonRejection>,
52 +) -> Result<Json<WeightCheckerResponse>, ApiError> {
53 + let Json(body) = body?;
54 + let client = state.engine_core_client();
55 + let no_args = || {
56 + (
57 + Vec::<JsonValue>::new(),
58 + BTreeMap::<String, JsonValue>::new(),
59 + )
60 + };
61 +
62 + if body.action == WeightCheckerAction::Reset {
63 + let (args, kwargs) = no_args();
64 + client
65 + .collective_rpc("reset_weights", None, args, kwargs)
66 + .await
67 + .map_err(|error| utility_call_error("reset_weights", error))?;
68 + return Ok(Json(WeightCheckerResponse::Reset { status: "reset" }));
69 + }
70 + if body.action == WeightCheckerAction::Compare && body.baseline.is_none() {
71 + return Err(invalid_request!(
72 + param = Some("baseline"),
73 + "action='compare' requires a 'baseline' object"
74 + ));
75 + }
76 +
77 + let (args, kwargs) = no_args();
78 + let workers = client
79 + .collective_rpc("compute_weight_checksums", None, args, kwargs)
80 + .await
81 + .map_err(|error| utility_call_error("compute_weight_checksums", error))?;
82 + let mut checksums = BTreeMap::new();
83 + for worker in workers {
84 + let worker: BTreeMap<String, String> = rmpv::ext::from_value(worker).map_err(|error| {
85 + ApiError::server_error(format!("invalid weight checksums: {error}"))
86 + })?;
87 + for (key, digest) in worker {
88 + if checksums.contains_key(&key) {
89 + return Err(ApiError::server_error(format!(
90 + "Duplicate weight checksum keys: {key}"
91 + )));
92 + }
93 + checksums.insert(key, digest);
94 + }
95 + }
96 +
97 + let Some(baseline) = body.baseline.filter(|_| body.action == WeightCheckerAction::Compare)
98 + else {
99 + return Ok(Json(WeightCheckerResponse::Checksums { checksums }));
100 + };
101 + let mismatches: Vec<String> = checksums
102 + .keys()
103 + .chain(baseline.keys())
104 + .collect::<BTreeSet<_>>()
105 + .into_iter()
106 + .filter(|key| checksums.get(*key) != baseline.get(*key))
107 + .cloned()
108 + .collect();
109 + Ok(Json(WeightCheckerResponse::Compare {
110 + matched: mismatches.is_empty(),
111 + mismatches,
112 + }))
113 +}
tests/entrypoints/serve/dev/rlhf/state_transitions/test_weight_checker.py new
+133
@@ -0,0 +1,133 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +import os
4 +from unittest.mock import patch
5 +
6 +import pytest
7 +import regex as re
8 +import requests
9 +import torch
10 +from transformers import AutoModelForCausalLM
11 +
12 +from tests.entrypoints.serve.dev.rlhf.conftest import (
13 + MODEL_NAME,
14 + gen,
15 + health,
16 + ok,
17 + server,
18 +)
19 +from tests.utils import multi_gpu_test
20 +from vllm.distributed.weight_transfer import (
21 + HTTPVLLMWeightSyncClient,
22 + ModuleSource,
23 + WeightTransferTrainerFactory,
24 +)
25 +from vllm.distributed.weight_transfer.ipc_engine import IPCTrainerInitInfo
26 +
27 +
28 +def weight_checker(url: str, action: str, baseline=None) -> requests.Response:
29 + body = {"action": action}
30 + if baseline is not None:
31 + body["baseline"] = baseline
32 + return requests.post(f"{url}/weight_checker", json=body, timeout=300)
33 +
34 +
35 +def checksums(url: str) -> dict[str, str]:
36 + response = weight_checker(url, "checksum")
37 + response.raise_for_status()
38 + return response.json()["checksums"]
39 +
40 +
41 +def ranks(keys) -> set[str]:
42 + return {key.rsplit(":", 1)[0] for key in keys}
43 +
44 +
45 +def reset_reload_and_compare(url: str) -> None:
46 + baseline = checksums(url)
47 + try:
48 + weight_checker(url, "reset").raise_for_status()
49 + comparison = weight_checker(url, "compare", baseline).json()
50 + assert ranks(comparison["mismatches"]) == ranks(baseline)
51 + finally:
52 + requests.post(
53 + f"{url}/collective_rpc", json={"method": "reload_weights"}, timeout=300
54 + ).raise_for_status()
55 + assert weight_checker(url, "compare", baseline).json() == {
56 + "match": True,
57 + "mismatches": [],
58 + }
59 +
60 +
61 +@pytest.fixture(scope="class")
62 +def server_url():
63 + with server() as url:
64 + yield url
65 +
66 +
67 +class TestSingleGPU:
68 + @pytest.mark.parametrize(
69 + "payload",
70 + [{}, {"action": "frobnicate"}, {"action": "compare"}, [], None],
71 + )
72 + def test_invalid_request_returns_400(self, server_url, payload):
73 + response = requests.post(
74 + f"{server_url}/weight_checker", json=payload, timeout=10
75 + )
76 + assert response.status_code == 400, response.text
77 + assert health(server_url) == 200
78 +
79 + def test_checksum_is_stable_and_compare_reports_differences(self, server_url):
80 + baseline = checksums(server_url)
81 + assert all(
82 + re.fullmatch(r"[0-9a-f]{64}", digest) for digest in baseline.values()
83 + )
84 + assert checksums(server_url) == baseline
85 +
86 + changed, missing, *_ = baseline
87 + altered = {**baseline, changed: "0" * 64}
88 + del altered[missing]
89 + assert weight_checker(server_url, "compare", altered).json() == {
90 + "match": False,
91 + "mismatches": sorted([changed, missing]),
92 + }
93 +
94 + def test_reset_changes_weights_and_reload_restores_them(self, server_url):
95 + reset_reload_and_compare(server_url)
96 + assert ok(gen(server_url))
97 +
98 +
99 +def test_ipc_weight_transfer_restores_reset_weights():
100 + """The documented RL flow: reset, then transfer the checkpoint back."""
101 + args = ["--weight-transfer-config", '{"backend": "ipc"}']
102 + with (
103 + patch.dict(os.environ, {"VLLM_ALLOW_INSECURE_SERIALIZATION": "1"}),
104 + server(extra_args=args, port=8772) as url,
105 + ):
106 + baseline = checksums(url)
107 + weight_checker(url, "reset").raise_for_status()
108 + trainer_model = AutoModelForCausalLM.from_pretrained(
109 + MODEL_NAME, torch_dtype=torch.bfloat16
110 + ).cuda()
111 + WeightTransferTrainerFactory.trainer_init(
112 + IPCTrainerInitInfo(rank=0),
113 + client=HTTPVLLMWeightSyncClient(url),
114 + source=ModuleSource(trainer_model),
115 + ).send_weights()
116 + assert weight_checker(url, "compare", baseline).json() == {
117 + "match": True,
118 + "mismatches": [],
119 + }
120 +
121 +
122 +@multi_gpu_test(num_gpus=2)
123 +@pytest.mark.parametrize(
124 + "args, expected",
125 + [
126 + (["--data-parallel-size", "2"], {"dp0:pp0:pcp0:tp0", "dp1:pp0:pcp0:tp0"}),
127 + (["--tensor-parallel-size", "2"], {"dp0:pp0:pcp0:tp0", "dp0:pp0:pcp0:tp1"}),
128 + ],
129 +)
130 +def test_checksum_and_reset_cover_every_worker(args, expected):
131 + with server(extra_args=args, port=8771, timeout=600) as url:
132 + assert ranks(checksums(url)) == expected
133 + reset_reload_and_compare(url)
tests/model_executor/model_loader/test_weight_checksum.py new
+93
@@ -0,0 +1,93 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from types import SimpleNamespace
4 +
5 +import pytest
6 +import torch
7 +import torch.nn as nn
8 +from torch.testing._internal.two_tensor import TwoTensor
9 +
10 +from vllm.config import ParallelConfig
11 +from vllm.model_executor import parameter
12 +from vllm.model_executor.model_loader.weight_checksum import (
13 + compute_tensor_digests,
14 + zero_weights,
15 +)
16 +from vllm.v1.worker import gpu_worker
17 +from vllm.v1.worker.gpu_worker import Worker
18 +
19 +pytestmark = pytest.mark.cpu_test
20 +
21 +
22 +class _Model(nn.Module):
23 + def __init__(self):
24 + super().__init__()
25 + self.linear = nn.Linear(2, 2)
26 + nn.init.ones_(self.linear.weight)
27 + nn.init.ones_(self.linear.bias)
28 + # A strided view, which cannot be reinterpreted as bytes in place.
29 + self.strided = nn.Parameter(torch.arange(1.0, 7.0)[::2])
30 + self.scalar = nn.Parameter(torch.tensor(2.0))
31 + # Loading never restores buffers, so reset must leave them alone.
32 + self.register_buffer("k_scale", torch.tensor(2.0))
33 +
34 +
35 +def test_digests_cover_every_parameter():
36 + assert set(compute_tensor_digests(_Model())) == {
37 + "linear.weight",
38 + "linear.bias",
39 + "strided",
40 + "scalar",
41 + }
42 +
43 +
44 +def test_zero_weights_changes_every_parameter_and_no_buffer():
45 + model = _Model()
46 + before = compute_tensor_digests(model)
47 + zero_weights(model)
48 + after = compute_tensor_digests(model)
49 + assert all(before[name] != after[name] for name in before)
50 + assert model.k_scale.item() == 2.0
51 +
52 +
53 +def test_shared_weight_partitions_are_hashed_and_reset(monkeypatch):
54 + monkeypatch.setattr(parameter, "get_tensor_model_parallel_rank", lambda: 0)
55 + monkeypatch.setattr(parameter, "get_tensor_model_parallel_world_size", lambda: 1)
56 + model = nn.Module()
57 + model.transform = parameter.SharedWeightParameter(weight_loader=None)
58 + model.transform.add_partition(0, object(), 2, 2)
59 + model.transform.partitions[0].data.fill_(3.0)
60 +
61 + before = compute_tensor_digests(model)
62 + zero_weights(model)
63 + assert set(before) == {"transform.0"}
64 + assert compute_tensor_digests(model) != before
65 +
66 +
67 +def test_tensor_subclass_inner_tensors_are_hashed_and_reset():
68 + model = nn.Module()
69 + model.weight = nn.Parameter(
70 + TwoTensor(torch.ones(2, 2), torch.full((2, 2), 2.0)), requires_grad=False
71 + )
72 +
73 + before = compute_tensor_digests(model)
74 + zero_weights(model)
75 + assert set(before) == {"weight.a", "weight.b"}
76 + assert all(
77 + before[name] != digest for name, digest in compute_tensor_digests(model).items()
78 + )
79 +
80 +
81 +def test_dense_dp_replicas_get_distinct_key_prefixes(monkeypatch):
82 + for name in ("get_tp_group", "get_pp_group", "get_pcp_group"):
83 + monkeypatch.setattr(gpu_worker, name, lambda: SimpleNamespace(rank_in_group=0))
84 + prefixes = []
85 + for dp_rank in (0, 1):
86 + worker = object.__new__(Worker)
87 + worker.parallel_config = ParallelConfig(
88 + data_parallel_size=2, data_parallel_rank=dp_rank
89 + )
90 + # Dense DP resets data_parallel_rank to 0 in each engine.
91 + worker.parallel_config.reconfigure_for_independent_dp_rank()
92 + prefixes.append(worker._weight_checksum_prefix())
93 + assert prefixes == ["dp0:pp0:pcp0:tp0:", "dp1:pp0:pcp0:tp0:"]
tests/v1/engine/test_engine_core_client.py
+18
@@ -222,6 +222,24 @@ def _make_dplb_client(num_engines: int = 3, client_count: int = 1) -> DPLBAsyncM
222 return client
223
224
225 +def test_dplb_weight_checksums_gather_every_engine():
226 + """call_utility_async on a DPLB client would keep only the first engine."""
227 + client = _make_dplb_client(num_engines=2)
228 +
229 + async def call_utility(method, *args, engine):
230 + assert (method, args) == ("compute_weight_checksums", ())
231 + return [{f"dp{engine[0]}:tp{tp}:w": "x"} for tp in (0, 1)]
232 +
233 + client._call_utility_async = call_utility
234 + workers = asyncio.run(client.compute_weight_checksums_async())
235 + assert [key for worker in workers for key in worker] == [
236 + "dp0:tp0:w",
237 + "dp0:tp1:w",
238 + "dp1:tp0:w",
239 + "dp1:tp1:w",
240 + ]
241 +
242 +
243 def test_dplb_late_interaction_sticky_routing():
244 client = _make_dplb_client()
245
vllm/engine/protocol.py
+4
@@ -268,6 +268,10 @@ class EngineClient(ABC):
268 """Perform a collective RPC call to the given path."""
269 raise NotImplementedError
270
271 + async def compute_weight_checksums(self) -> list[dict[str, str]]:
272 + """Return rank-qualified weight digests from every worker of every engine."""
273 + raise NotImplementedError
274 +
275 async def handle_fault(
276 self, fault_tolerance_request: FaultToleranceRequest
277 ) -> FaultToleranceResult:
vllm/entrypoints/serve/dev/rlhf/api_router.py
+38 -1
@@ -3,7 +3,7 @@
3
4 import json
5 from http import HTTPStatus
6 -from typing import Annotated, Any
6 +from typing import Annotated, Any, Literal
7
8 from fastapi import APIRouter, Body, FastAPI, HTTPException, Query, Request
9 from fastapi.responses import JSONResponse
@@ -216,6 +216,43 @@ async def weight_info(raw_request: Request):
216 return JSONResponse(content={"weight_version": weight_version})
217
218
219 +@router.post("/weight_checker")
220 +async def weight_checker(
221 + raw_request: Request,
222 + action: Annotated[Literal["checksum", "reset", "compare"], Body(embed=True)],
223 + baseline: Annotated[dict[str, str] | None, Body(embed=True)] = None,
224 +) -> JSONResponse:
225 + """Checksum, reset, or compare model weights against a baseline."""
226 + client = engine_client(raw_request)
227 + if action == "reset":
228 + await client.collective_rpc("reset_weights")
229 + return JSONResponse(content={"status": "reset"})
230 + if action == "compare" and baseline is None:
231 + raise HTTPException(
232 + status_code=HTTPStatus.BAD_REQUEST.value,
233 + detail="action='compare' requires a 'baseline' object",
234 + )
235 +
236 + checksums: dict[str, str] = {}
237 + for worker_checksums in await client.compute_weight_checksums():
238 + if duplicates := checksums.keys() & worker_checksums.keys():
239 + raise HTTPException(
240 + status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
241 + detail=f"Duplicate weight checksum keys: {sorted(duplicates)}",
242 + )
243 + checksums.update(worker_checksums)
244 + if action == "checksum":
245 + return JSONResponse(content={"checksums": checksums})
246 +
247 + assert baseline is not None
248 + mismatches = sorted(
249 + key
250 + for key in checksums.keys() | baseline.keys()
251 + if checksums.get(key) != baseline.get(key)
252 + )
253 + return JSONResponse(content={"match": not mismatches, "mismatches": mismatches})
254 +
255 +
256 @router.get("/get_world_size")
257 async def get_world_size(
258 raw_request: Request,
vllm/model_executor/model_loader/weight_checksum.py new
+43
@@ -0,0 +1,43 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Per-parameter weight digests and reset, for verifying weight updates."""
4 +
5 +import hashlib
6 +from collections.abc import Iterator
7 +
8 +import torch
9 +import torch.nn as nn
10 +from torch.utils._python_dispatch import is_traceable_wrapper_subclass
11 +
12 +from vllm.model_executor.parameter import SharedWeightParameter
13 +
14 +
15 +def _tensors(name: str, tensor: torch.Tensor) -> Iterator[tuple[str, torch.Tensor]]:
16 + # Both keep their bytes in inner tensors (e.g. TorchAO's qdata and scale).
17 + if isinstance(tensor, SharedWeightParameter):
18 + for index, partition in tensor.partitions.items():
19 + yield from _tensors(f"{name}.{index}", partition)
20 + elif is_traceable_wrapper_subclass(tensor):
21 + for attr in tensor.__tensor_flatten__()[0]:
22 + yield from _tensors(f"{name}.{attr}", getattr(tensor, attr))
23 + else:
24 + yield name, tensor
25 +
26 +
27 +def _weights(model: nn.Module) -> Iterator[tuple[str, torch.Tensor]]:
28 + for name, param in model.named_parameters():
29 + yield from _tensors(name, param)
30 +
31 +
32 +def compute_tensor_digests(model: nn.Module) -> dict[str, str]:
33 + return {
34 + name: hashlib.sha256(
35 + weight.detach().cpu().contiguous().view(-1).view(torch.uint8).numpy()
36 + ).hexdigest()
37 + for name, weight in _weights(model)
38 + }
39 +
40 +
41 +def zero_weights(model: nn.Module) -> None:
42 + for _, weight in _weights(model):
43 + weight.data.zero_()
vllm/v1/engine/async_llm.py
+3
@@ -1125,6 +1125,9 @@ class AsyncLLM(EngineClient):
1125 async def is_sleeping(self) -> bool:
1126 return await self.engine_core.is_sleeping_async()
1127
1128 + async def compute_weight_checksums(self) -> list[dict[str, str]]:
1129 + return await self.engine_core.compute_weight_checksums_async()
1130 +
1131 async def add_lora(self, lora_request: LoRARequest) -> bool:
1132 """Load a new LoRA adapter into the engine for future requests."""
1133 return await self.engine_core.add_lora_async(lora_request)
vllm/v1/engine/core.py
+3
@@ -1005,6 +1005,9 @@ class EngineCore:
1005 def execute_dummy_batch(self):
1006 self.model_executor.execute_dummy_batch()
1007
1008 + def compute_weight_checksums(self) -> list[dict[str, str]]:
1009 + return self.collective_rpc("compute_weight_checksums")
1010 +
1011 def add_lora(self, lora_request: LoRARequest) -> bool:
1012 return self.model_executor.add_lora(lora_request)
1013
vllm/v1/engine/core_client.py
+4
@@ -1362,6 +1362,10 @@ class AsyncMPClient(MPClient):
1362 "collective_rpc", method, timeout, args, kwargs
1363 )
1364
1365 + async def compute_weight_checksums_async(self) -> list[dict[str, str]]:
1366 + per_engine = await self.call_utility_all_async("compute_weight_checksums")
1367 + return [worker for workers in per_engine for worker in workers]
1368 +
1369 async def handle_fault(
1370 self, ft_request: FaultToleranceRequest
1371 ) -> FaultToleranceResult:
vllm/v1/worker/gpu_worker.py
+20
@@ -58,6 +58,10 @@ from vllm.distributed.weight_transfer import (
58 from vllm.logger import init_logger
59 from vllm.lora.request import LoRARequest
60 from vllm.model_executor.model_loader import get_model_loader
61 +from vllm.model_executor.model_loader.weight_checksum import (
62 + compute_tensor_digests,
63 + zero_weights,
64 +)
65 from vllm.model_executor.warmup.kernel_warmup import kernel_warmup
66 from vllm.multimodal.gpu_ipc_memory import reserve_mm_ipc_gpu_memory
67 from vllm.platforms import current_platform
@@ -342,6 +346,22 @@ class Worker(WorkerBase):
346 def checkpoint_restore(self) -> None:
347 checkpoint_restore_distributed_state()
348
349 + def _weight_checksum_prefix(self) -> str:
350 + # data_parallel_rank is 0 in dense workers; only the index is unique.
351 + dp = self.parallel_config.data_parallel_index
352 + pp = get_pp_group().rank_in_group
353 + pcp = get_pcp_group().rank_in_group
354 + tp = get_tp_group().rank_in_group
355 + return f"dp{dp}:pp{pp}:pcp{pcp}:tp{tp}:"
356 +
357 + def compute_weight_checksums(self) -> dict[str, str]:
358 + prefix = self._weight_checksum_prefix()
359 + digests = compute_tensor_digests(self.model_runner.get_model())
360 + return {prefix + name: digest for name, digest in digests.items()}
361 +
362 + def reset_weights(self) -> None:
363 + zero_weights(self.model_runner.get_model())
364 +
365 def _maybe_get_memory_pool_context(self, tag: str) -> AbstractContextManager:
366 if (
367 current_platform.is_cuda_alike()