[Feature][Frontend] Expose effective attention block size for DCP (#56538)
Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: OpenAI Codex <noreply@openai.com> Signed-off-by: Julien Darve <jdarve@NVIDIA.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>
JulienDarve committed
Sep 16, 2026 at 01:29 UTC
f37c550bf6353d7d2a7289cbf256943e8c282fad
18 files changed
+159
-11
docs/serving/context_parallel_deployment.md
+9
@@ -20,6 +20,15 @@ Both approaches are under active development.
20
21
Due to the auto-regressive nature of decoding, every decoding step needs to compute a small amount of query tokens w.r.t. a large number of key/value tokens stored in the paged KV cache. The core of decode context parallel is how to shard the KV cache across GPUs.
22
23
+`engine_client.vllm_config.cache_config.effective_attention_block_size` reports the
24
+initialized full-attention block size in tokens, including DCP: a physical block of
25
+16 tokens with DCP=4 represents 64 tokens. The same value is available through
26
+gRPC `Control.GetServerInfo.effective_attention_block_size`. Physical block-size
27
+fields retain their existing meaning. The new value is unavailable (`None` in
28
+Python, absent in gRPC) if there is no common full-attention size, the scheduler
29
+does not expose it, or an older engine omits it. gRPC also omits the value when
30
+engine replicas disagree. Partial cache events carry their own actual size.
31
+
32
For a model with `H` kv-heads, a request with `T` tokens in the context needs to store `H * T` key/value tensors in the KV cache.
33
34
1. If one GPU can hold them all, and the performance is good enough, then no parallelization is needed.
rust/Cargo.toml
+1
-1
@@ -147,7 +147,7 @@ vllm-llm = { path = "src/llm" }
147
vllm-managed-engine = { path = "src/managed-engine" }
148
vllm-metrics = { path = "src/metrics" }
149
vllm-parser = { path = "src/parser" }
150
-vllm-proto = { version = "0.2.0", path = "proto" }
150
+vllm-proto = { path = "proto" }
151
vllm-server = { path = "src/server" }
152
vllm-text = { path = "src/text" }
153
vllm-tokenizer = { path = "src/tokenizer" }
rust/proto/README.md
+4
-2
@@ -2,6 +2,9 @@
2
3
This directory is the canonical source for vLLM's gRPC schema.
4
5
+See [context parallel deployment](../../docs/serving/context_parallel_deployment.md)
6
+for effective attention block-size metadata in Python and `Control.GetServerInfo`.
7
+
8
Schema updates are no longer published to the Buf Schema Registry. Rust consumers
9
should use `vllm-proto` from crates.io; consumers in other languages can generate
10
bindings from the `.proto` files in this directory. Buf still builds and lints
@@ -34,8 +37,7 @@ On pull requests and releases, `cargo-semver-checks` compares the crate with its
37
latest published version. Include any required version bump in the protocol
38
change PR. This check becomes available after the first manual publication.
39
37
-1. Update the crate version and the `vllm-proto` workspace dependency together,
38
- and update `rust/Cargo.lock`.
40
+1. Update the crate version in `rust/proto/Cargo.toml` and update `rust/Cargo.lock`.
41
2. Run `cargo publish --manifest-path rust/proto/Cargo.toml --locked --dry-run`
42
and the frontend gRPC tests. Record the tested vLLM releases or revisions in
43
the release notes; matching crate versions alone do not establish runtime compatibility.
rust/proto/control.proto
+2
@@ -50,6 +50,8 @@ message ServerInfo {
50
uint64 max_batched_tokens = 9;
51
uint32 max_loras = 10;
52
RlCapabilities rl_capabilities = 11;
53
+ // Full-attention block size in tokens after initialization; absent if unavailable.
54
+ optional uint64 effective_attention_block_size = 14;
55
}
56
57
message RlCapabilities {
rust/src/engine-core-client/src/client.rs
+9
@@ -510,6 +510,15 @@ impl EngineCoreClient {
510
self.engines.iter().map(|engine| engine.ready_response.num_gpu_blocks).sum()
511
}
512
513
+ /// Return the effective attention block size if all engines report the same value.
514
+ pub fn effective_attention_block_size(&self) -> Option<u64> {
515
+ let size = self.ready_response().effective_attention_block_size?;
516
+ self.engines
517
+ .iter()
518
+ .all(|engine| engine.ready_response.effective_attention_block_size == Some(size))
519
+ .then_some(size)
520
+ }
521
+
522
/// Return the minimum engine-reported `max_model_len` across all engines.
523
///
524
/// This is the auto-fitted value after KV cache profiling and may differ
rust/src/engine-core-client/src/mock_engine.rs
+1
@@ -72,6 +72,7 @@ pub fn default_ready_response() -> EngineCoreReadyResponse {
72
weight_transfer_backend: None,
73
enable_sleep_mode: false,
74
supports_draft_weight_updates: false,
75
+ effective_attention_block_size: None,
76
}
77
}
78
rust/src/engine-core-client/src/protocol/handshake.rs
+3
@@ -104,6 +104,9 @@ pub struct EngineCoreReadyResponse {
104
/// Whether the engine has a speculative draft model that can be updated.
105
#[serde(default)]
106
pub supports_draft_weight_updates: bool,
107
+ /// Full-attention block size in tokens after initialization, or unavailable.
108
+ #[serde(default)]
109
+ pub effective_attention_block_size: Option<u64>,
110
}
111
112
/// Frontend-owned ZMQ addresses that are sent to the engine during startup
rust/src/engine-core-client/src/tests/client.rs
+6
@@ -2846,6 +2846,11 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2846
2847
let ready_response: EngineCoreReadyResponse =
2848
rmp_serde::from_slice(&hex::decode(ready_response_hex).unwrap()).unwrap();
2849
+ let mut legacy_ready = serde_json::to_value(&ready_response).unwrap();
2850
+ legacy_ready.as_object_mut().unwrap().remove("effective_attention_block_size");
2851
+ let legacy_ready: EngineCoreReadyResponse =
2852
+ rmp_serde::from_slice(&rmp_serde::to_vec_named(&legacy_ready).unwrap()).unwrap();
2853
+ assert!(legacy_ready.effective_attention_block_size.is_none());
2854
assert!(ready_response.supports_lora);
2855
assert_eq!(ready_response.max_loras, 8);
2856
assert_eq!(
@@ -2854,6 +2859,7 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2859
);
2860
assert!(ready_response.enable_sleep_mode);
2861
assert!(ready_response.supports_draft_weight_updates);
2862
+ assert_eq!(ready_response.effective_attention_block_size, Some(64));
2863
let kv_events_config = ready_response.kv_events_config.expect("KV events config should decode");
2864
assert!(kv_events_config.enable_kv_cache_events);
2865
assert_eq!(kv_events_config.publisher, "zmq");
rust/src/engine-core-client/src/tests/python_compat.py
+2
@@ -423,6 +423,7 @@ class EngineCoreReadyResponse:
423
weight_transfer_backend: str | None = None
424
enable_sleep_mode: bool = False
425
supports_draft_weight_updates: bool = False
426
+ effective_attention_block_size: int | None = None
427
428
429
ready_response = EngineCoreReadyResponse(
@@ -446,6 +447,7 @@ ready_response = EngineCoreReadyResponse(
447
weight_transfer_backend="nccl",
448
enable_sleep_mode=True,
449
supports_draft_weight_updates=True,
450
+ effective_attention_block_size=64,
451
kv_events_config=KVEventsConfig(
452
enable_kv_cache_events=True,
453
publisher="zmq",
rust/src/server/src/grpc/control.rs
+1
@@ -195,6 +195,7 @@ impl pb::control_server::Control for ControlServiceImpl {
195
max_batched_tokens: ready.max_num_batched_tokens,
196
max_loras: ready.max_loras,
197
rl_capabilities: Some(self.rl_capabilities()),
198
+ effective_attention_block_size: self.client().effective_attention_block_size(),
199
}))
200
}
201
rust/src/server/src/grpc/tests.rs
+12
-1
@@ -1911,8 +1911,16 @@ async fn control_abort_resolves_external_id_and_empty_is_noop() {
1911
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1912
#[serial]
1913
async fn control_reports_server_and_model_info() {
1914
+ let mut ready = default_ready_response();
1915
+ ready.effective_attention_block_size = Some(64);
1916
let (generate_service, control_service, engine_health, _engine_task) =
1915
- setup_grpc_service(b"engine-grpc-info", default_stream_output_specs()).await;
1917
+ setup_grpc_service_with_engine_script(
1918
+ b"engine-grpc-info".to_vec(),
1919
+ ready,
1920
+ Arc::new(FakeTextBackend),
1921
+ |_, _| boxed_test_future(async {}),
1922
+ )
1923
+ .await;
1924
let (channel, server_task) = start_grpc_test_server(
1925
generate_service,
1926
control_service,
@@ -1928,6 +1936,7 @@ async fn control_reports_server_and_model_info() {
1936
.expect("get server info")
1937
.into_inner();
1938
assert_eq!(server.engine_version, "test-vllm-version");
1939
+ assert_eq!(server.effective_attention_block_size, Some(64));
1940
assert_eq!(server.api_version, "vllm");
1941
assert_eq!(server.instance_id, "test-instance");
1942
assert_eq!(server.max_model_len, DEFAULT_MOCK_MAX_MODEL_LEN as u32);
@@ -2195,6 +2204,7 @@ async fn control_aggregates_multi_engine_capacity() {
2204
ready_0.weight_transfer_backend = Some("nccl".to_string());
2205
ready_0.enable_sleep_mode = true;
2206
ready_0.supports_draft_weight_updates = true;
2207
+ ready_0.effective_attention_block_size = Some(64);
2208
2209
let mut ready_1 = default_ready_response();
2210
ready_1.max_model_len = 4_096;
@@ -2248,6 +2258,7 @@ async fn control_aggregates_multi_engine_capacity() {
2258
.into_inner();
2259
assert_eq!(server.max_model_len, 4_096);
2260
assert_eq!(server.total_kv_blocks, 30);
2261
+ assert!(server.effective_attention_block_size.is_none());
2262
let rl = server.rl_capabilities.expect("RL capabilities");
2263
assert!(!rl.weight_transfer_enabled);
2264
assert!(rl.weight_transfer_backend.is_empty());
tests/config/test_config_utils.py
+3
@@ -215,6 +215,9 @@ def test_cache_config_hash_ignores_kv_cache_sizing_knobs():
215
base_hash = CacheConfig().compute_hash()
216
assert CacheConfig(kv_cache_memory_bytes=1 << 30).compute_hash() == base_hash
217
assert CacheConfig(gpu_memory_utilization=0.5).compute_hash() == base_hash
218
+ config = CacheConfig()
219
+ config.effective_attention_block_size = 64
220
+ assert config.compute_hash() == base_hash
221
222
223
def test_scheduler_config_hash_includes_max_num_seqs():
tests/v1/core/test_kv_cache_utils.py
+38
@@ -279,6 +279,44 @@ def make_request(
279
)
280
281
282
+@pytest.mark.parametrize("dcp", [1, 4])
283
+def test_effective_attention_block_size_matches_events(dcp):
284
+ from vllm.distributed.kv_events import BlockStored
285
+ from vllm.v1.engine.core import EngineCore
286
+
287
+ config = KVCacheConfig(
288
+ num_blocks=32,
289
+ kv_cache_tensors=[],
290
+ kv_cache_groups=[
291
+ KVCacheGroupSpec(["attention"], new_kv_cache_spec()),
292
+ ],
293
+ )
294
+ manager = KVCacheManager(
295
+ generate_scheduler_kv_cache_config([config]),
296
+ max_model_len=256,
297
+ scheduler_block_size=16 * dcp,
298
+ hash_block_size=16 * dcp,
299
+ dcp_world_size=dcp,
300
+ enable_kv_cache_events=True,
301
+ )
302
+ core = EngineCore.__new__(EngineCore)
303
+ core.vllm_config = SimpleNamespace(cache_config=CacheConfig(block_size=16))
304
+ core.scheduler = SimpleNamespace(kv_cache_manager=manager)
305
+ core._initialize_effective_attention_block_size()
306
+ block_size = core.vllm_config.cache_config.effective_attention_block_size
307
+ assert block_size == 16 * dcp
308
+
309
+ request = make_request(
310
+ "block-size", list(range(64)), block_size=16 * dcp, hash_fn=sha256
311
+ )
312
+ assert manager.allocate_slots(request, 64) is not None
313
+ assert [
314
+ event.block_size
315
+ for event in manager.take_events()
316
+ if isinstance(event, BlockStored)
317
+ ] == [block_size]
318
+
319
+
320
def new_kv_cache_spec(
321
block_size=16,
322
num_kv_heads=2,
tests/v1/engine/test_engine_core_client.py
+28
-3
@@ -158,7 +158,10 @@ def test_mp_client_uses_env_timeout(monkeypatch: pytest.MonkeyPatch):
158
local_engines_only=False,
159
enable_elastic_ep=False,
160
)
161
- vllm_config = SimpleNamespace(parallel_config=parallel_config)
161
+ vllm_config = SimpleNamespace(
162
+ parallel_config=parallel_config,
163
+ cache_config=SimpleNamespace(effective_attention_block_size=None),
164
+ )
165
166
client = core_client_mod.MPClient(
167
asyncio_mode=False,
@@ -368,10 +371,15 @@ def test_dplb_finished_requests_release_inflight():
371
assert req.request_id not in client.reqs_in_flight
372
373
371
-def test_apply_ready_response_syncs_block_size():
374
+@pytest.mark.parametrize(
375
+ ("effective_size", "other_size"),
376
+ [(None, None), (4224, 4224), (4224, 1056), (4224, None)],
377
+)
378
+def test_apply_ready_response_syncs_block_size(effective_size, other_size):
379
import msgspec
380
381
client = object.__new__(MPClient)
382
+ client._effective_attention_block_sizes = set()
383
client.vllm_config = SimpleNamespace(
384
cache_config=SimpleNamespace(block_size=16, num_gpu_blocks=0),
385
model_config=SimpleNamespace(max_model_len=8192),
@@ -399,14 +407,31 @@ def test_apply_ready_response_syncs_block_size():
407
max_loras=0,
408
)
409
)
402
- client._apply_ready_response(payload)
410
+ fields = msgspec.msgpack.decode(payload)
411
+ if effective_size is None:
412
+ del fields["effective_attention_block_size"]
413
+ else:
414
+ fields["effective_attention_block_size"] = effective_size
415
+ client._apply_ready_response(msgspec.msgpack.encode(fields))
416
assert client.vllm_config.cache_config.block_size == 1056
417
+ cache_config = client.vllm_config.cache_config
418
+ assert cache_config.effective_attention_block_size == effective_size
419
+
420
+ fields["effective_attention_block_size"] = other_size
421
+ client._apply_ready_response(msgspec.msgpack.encode(fields))
422
+ assert cache_config.effective_attention_block_size == (
423
+ effective_size if effective_size == other_size else None
424
+ )
425
+
426
+ client._apply_ready_response(b"")
427
+ assert cache_config.effective_attention_block_size is None
428
429
430
def test_apply_ready_response_syncs_mamba_block_size():
431
import msgspec
432
433
client = object.__new__(MPClient)
434
+ client._effective_attention_block_sizes = set()
435
client.vllm_config = SimpleNamespace(
436
cache_config=SimpleNamespace(block_size=16, num_gpu_blocks=0),
437
model_config=SimpleNamespace(max_model_len=8192),
vllm/config/cache.py
+3
@@ -211,6 +211,8 @@ class CacheConfig:
211
"""The number of blocks to allocate for CPU memory."""
212
213
# Set after KV cache initialization.
214
+ effective_attention_block_size: int | None = field(default=None, init=False)
215
+ """Full-attention block size in tokens, including DCP, or None if unavailable."""
216
kv_cache_size_tokens: int | None = field(default=None, init=False)
217
"""Per-DP-engine KV cache capacity in tokens (group-aware). Uses
218
group-aware capacity since num_gpu_blocks * block_size can be wrong
@@ -277,6 +279,7 @@ class CacheConfig:
279
# Post-init/derived counters
280
"num_gpu_blocks",
281
"num_cpu_blocks",
282
+ "effective_attention_block_size",
283
"kv_cache_size_tokens",
284
"kv_cache_max_concurrency",
285
# WIP feature toggle not impacting compiled graph shape
vllm/v1/engine/__init__.py
+2
@@ -102,6 +102,8 @@ class EngineCoreReadyResponse:
102
weight_transfer_backend: str | None = None
103
enable_sleep_mode: bool = False
104
supports_draft_weight_updates: bool = False
105
+ # Full-attention block size in tokens after initialization, or unavailable.
106
+ effective_attention_block_size: int | None = None
107
108
109
class EngineCoreRequest(
vllm/v1/engine/core.py
+20
-1
@@ -85,7 +85,7 @@ from vllm.v1.fault_tolerance.engine_core_sentinel import (
85
EngineCoreSentinel,
86
fault_tolerant_wrapper,
87
)
88
-from vllm.v1.kv_cache_interface import KVCacheConfig
88
+from vllm.v1.kv_cache_interface import KVCacheConfig, is_full_attention_spec
89
from vllm.v1.metrics.stats import SchedulerIterationDetails, SchedulerStats
90
from vllm.v1.outputs import ModelRunnerOutput
91
from vllm.v1.request import Request, RequestStatus
@@ -168,6 +168,7 @@ class EngineCore:
168
block_size=scheduler_block_size,
169
hash_block_size=hash_block_size,
170
)
171
+ self._initialize_effective_attention_block_size()
172
self.use_spec_decode = vllm_config.speculative_config is not None
173
self.check_for_draft_tokens = (
174
self.use_spec_decode or vllm_config.model_config.is_diffusion
@@ -382,6 +383,21 @@ class EngineCore:
383
)
384
return scheduler_kv_cache_config
385
386
+ def _initialize_effective_attention_block_size(self) -> None:
387
+ cache_config = self.vllm_config.cache_config
388
+ cache_config.effective_attention_block_size = None
389
+ cache_manager = getattr(self.scheduler, "kv_cache_manager", None)
390
+ if cache_manager is None:
391
+ return
392
+ block_sizes = {
393
+ manager.block_size
394
+ for manager in cache_manager.coordinator.single_type_managers
395
+ if is_full_attention_spec(manager.kv_cache_spec)
396
+ }
397
+ cache_config.effective_attention_block_size = (
398
+ block_sizes.pop() if len(block_sizes) == 1 else None
399
+ )
400
+
401
def get_supported_tasks(self) -> tuple[SupportedTask, ...]:
402
supported_tasks = self.model_executor.supported_tasks
403
self._log_pooler_config(supported_tasks)
@@ -1649,6 +1665,9 @@ class EngineCoreProc(EngineCore):
1665
num_gpu_blocks=self.vllm_config.cache_config.num_gpu_blocks or 0,
1666
block_size=self.vllm_config.cache_config.block_size,
1667
mamba_block_size=self.vllm_config.cache_config.mamba_block_size,
1668
+ effective_attention_block_size=(
1669
+ self.vllm_config.cache_config.effective_attention_block_size
1670
+ ),
1671
dp_stats_address=self.frontend_stats_publish_address,
1672
dtype=str(self.vllm_config.model_config.dtype).removeprefix("torch."),
1673
vllm_version=VLLM_VERSION,
vllm/v1/engine/core_client.py
+15
-3
@@ -566,6 +566,7 @@ class MPClient(EngineCoreClient):
566
):
567
self.vllm_config = vllm_config
568
self._renderer: BaseRenderer | None = renderer
569
+ self._effective_attention_block_sizes: set[int | None] = set()
570
571
# ZMQ setup.
572
sync_ctx = zmq.Context(io_threads=2)
@@ -803,10 +804,21 @@ class MPClient(EngineCoreClient):
804
def _apply_ready_response(self, payload: bytes) -> None:
805
"""Decode an EngineCoreReadyResponse and sync any post-initialization
806
config changes (e.g. auto-fitted max_model_len) back to the frontend."""
806
- if not payload:
807
- return
807
vllm_config = self.vllm_config
809
- response = msgspec.msgpack.decode(payload, type=EngineCoreReadyResponse)
808
+ response = (
809
+ msgspec.msgpack.decode(payload, type=EngineCoreReadyResponse)
810
+ if payload
811
+ else None
812
+ )
813
+ self._effective_attention_block_sizes.add(
814
+ response.effective_attention_block_size if response is not None else None
815
+ )
816
+ sizes = self._effective_attention_block_sizes
817
+ vllm_config.cache_config.effective_attention_block_size = (
818
+ next(iter(sizes)) if len(sizes) == 1 else None
819
+ )
820
+ if response is None:
821
+ return
822
vllm_config.model_config.max_model_len = min(
823
vllm_config.model_config.max_model_len, response.max_model_len
824
)