[KV Connector] Fix DecodeBench fp8 fill values and add a startup fill mode (#58472)

Signed-off-by: Zijing Liu <liuzijing2014@gmail.com> Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai> Co-authored-by: Yifan Qiao <yifanqiao@inferact.ai> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Zijing Liu committed Sep 24, 2026 at 16:55 UTC 7a9406690ebe2fc47236a81cf7ec33188c6565e2
3 files changed +549 -38
docs/serving/expert_parallel_deployment.md
+1 -1
@@ -315,6 +315,6 @@ except Exception as e:
315
316 ### Benchmarking
317
318 -- To simulate the decode deployment of disaggregated serving, pass `--kv-transfer-config '{"kv_connector":"DecodeBenchConnector","kv_role":"kv_both"}'` to the `vllm serve` invocation. The connector populates KV cache with random values so decode can be profiled in isolation.
318 +- To simulate the decode deployment of disaggregated serving, pass `--kv-transfer-config '{"kv_connector":"DecodeBenchConnector","kv_role":"kv_both"}'` to the `vllm serve` invocation. The connector populates KV cache with random values so decode can be profiled in isolation. Add `"kv_connector_extra_config":{"startup_fill":true}` to fill the whole KV cache once at startup instead of in the decode steps that admit new requests (see the connector docstring for its limitations).
319
320 - **CUDAGraph capture**: Use `--compilation_config '{"cudagraph_mode": "FULL_DECODE_ONLY"}'` to enable CUDA graph capture for decode only and save KV cache.
tests/v1/kv_connector/unit/test_decode_bench_connector.py
+325 -1
@@ -18,6 +18,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.decode_bench_connector import
18 DecodeBenchConnectorMetadata,
19 )
20 from vllm.forward_context import ForwardContext
21 +from vllm.platforms import current_platform
22 from vllm.utils.hashing import sha256
23 from vllm.v1.core.kv_cache_manager import KVCacheBlocks
24 from vllm.v1.core.kv_cache_utils import (
@@ -34,6 +35,8 @@ from vllm.v1.kv_cache_interface import (
35 FullAttentionSpec,
36 KVCacheConfig,
37 KVCacheGroupSpec,
38 + KVQuantMode,
39 + MambaSpec,
40 SlidingWindowSpec,
41 )
42 from vllm.v1.request import Request
@@ -50,7 +53,12 @@ from .utils import (
53 class DecodeBenchTestRunner:
54 """Test runner for DecodeBenchConnector."""
55
53 - def __init__(self, block_size: int, num_gpu_blocks: int):
56 + def __init__(
57 + self,
58 + block_size: int,
59 + num_gpu_blocks: int,
60 + kv_connector_extra_config: dict | None = None,
61 + ):
62 self.block_size = block_size
63 self.num_gpu_blocks = num_gpu_blocks
64
@@ -61,6 +69,7 @@ class DecodeBenchTestRunner:
69 block_size=block_size,
70 max_num_batched_tokens=1000,
71 kv_connector="DecodeBenchConnector",
72 + kv_connector_extra_config=kv_connector_extra_config,
73 )
74
75 self.vllm_config = vllm_config
@@ -780,5 +789,320 @@ def test_decode_bench_connector_concurrent_requests():
789 assert len(metadata2.reqs_to_fill) == 0
790
791
792 +def _fill_single_block(cache_dtype, spec_dtype, kv_quant_mode, cache, fill_std=0.0):
793 + """Fill block 1 of a one-layer cache through the worker connector."""
794 + vllm_config = create_vllm_config(
795 + block_size=16,
796 + kv_connector="DecodeBenchConnector",
797 + kv_connector_extra_config={"fill_std": fill_std},
798 + cache_dtype=cache_dtype,
799 + )
800 + kv_cache_spec = FullAttentionSpec(
801 + block_size=16,
802 + num_kv_heads=1,
803 + head_size=8,
804 + dtype=spec_dtype,
805 + kv_quant_mode=kv_quant_mode,
806 + )
807 + connector = DecodeBenchConnector(
808 + vllm_config,
809 + KVConnectorRole.WORKER,
810 + KVCacheConfig(
811 + num_blocks=cache.shape[0],
812 + kv_cache_tensors=[],
813 + kv_cache_groups=[KVCacheGroupSpec(["layer"], kv_cache_spec)],
814 + ),
815 + )
816 + connector.register_kv_caches({"layer": cache})
817 + connector.bind_connector_metadata(
818 + DecodeBenchConnectorMetadata(reqs_to_fill={"request": (([1],), 16)})
819 + )
820 + connector.start_load_kv(
821 + ForwardContext(no_compile_layers={}, attn_metadata={}, slot_mapping={})
822 + )
823 +
824 +
825 +@pytest.mark.parametrize(
826 + "cache_dtype,spec_dtype,kv_quant_mode,fp8_dtype",
827 + [
828 + (
829 + "fp8",
830 + torch.uint8,
831 + KVQuantMode.FP8_PER_TENSOR,
832 + current_platform.fp8_dtype(),
833 + ),
834 + ("fp8_e5m2", torch.uint8, KVQuantMode.FP8_PER_TENSOR, torch.float8_e5m2),
835 + ("auto", torch.float8_e4m3fn, KVQuantMode.NONE, torch.float8_e4m3fn),
836 + ],
837 +)
838 +@pytest.mark.parametrize("fill_std", [0.0, 0.1])
839 +def test_decode_bench_connector_fills_fp8_caches(
840 + cache_dtype, spec_dtype, kv_quant_mode, fp8_dtype, fill_std
841 +):
842 + """fp8 caches, including ones stored as uint8, get encoded fill values."""
843 + cache = torch.zeros(4, 16, 8, dtype=spec_dtype)
844 + _fill_single_block(cache_dtype, spec_dtype, kv_quant_mode, cache, fill_std)
845 +
846 + values = cache.view(fp8_dtype).float()
847 + assert torch.count_nonzero(values[[0, 2, 3]]) == 0
848 + filled = values[1]
849 + if fill_std == 0:
850 + expected = torch.tensor(0.015).to(fp8_dtype).float()
851 + torch.testing.assert_close(filled, expected.expand_as(filled))
852 + else:
853 + assert torch.isfinite(filled).all()
854 + assert filled.std() > 0
855 +
856 +
857 +def test_decode_bench_connector_zero_fills_packed_uint8_caches():
858 + """uint8 layouts that do not hold plain fp8 values are zeroed."""
859 + cache = torch.full((4, 16, 8), 7, dtype=torch.uint8)
860 + _fill_single_block("nvfp4", torch.uint8, KVQuantMode.NVFP4, cache)
861 +
862 + assert torch.count_nonzero(cache[1]) == 0
863 + assert torch.all(cache[[0, 2, 3]] == 7)
864 +
865 +
866 +@pytest.mark.parametrize("fill_std", [0.0, 0.1])
867 +def test_decode_bench_connector_startup_fill_fills_registered_caches(fill_std):
868 + """Startup fill writes every cache view in place, including strided views."""
869 + vllm_config = create_vllm_config(
870 + block_size=16,
871 + kv_connector="DecodeBenchConnector",
872 + kv_connector_extra_config={"startup_fill": True, "fill_std": fill_std},
873 + )
874 + kv_cache_config = KVCacheConfig(
875 + num_blocks=4,
876 + kv_cache_tensors=[],
877 + kv_cache_groups=[
878 + KVCacheGroupSpec(
879 + ["attention", "transposed"],
880 + FullAttentionSpec(
881 + block_size=16, num_kv_heads=1, head_size=1, dtype=torch.float32
882 + ),
883 + ),
884 + ],
885 + )
886 + connector = DecodeBenchConnector(
887 + vllm_config, KVConnectorRole.WORKER, kv_cache_config
888 + )
889 + attention = torch.full((4, 2, 16), -1.0)
890 + transposed = torch.full((2, 4, 16), -1.0).transpose(0, 1)
891 + connector.register_kv_caches({"attention": attention, "transposed": transposed})
892 +
893 + for filled in (attention, transposed):
894 + if fill_std == 0:
895 + torch.testing.assert_close(filled, torch.full_like(filled, 0.015))
896 + else:
897 + assert torch.isfinite(filled).all()
898 + assert filled.std() > 0
899 +
900 +
901 +@pytest.mark.parametrize("fill_std", [0.0, 0.1])
902 +def test_decode_bench_connector_startup_fill_encodes_fp8_caches(fill_std):
903 + """Startup fill encodes values in the fp8 dtype of a uint8 fp8 cache."""
904 + vllm_config = create_vllm_config(
905 + block_size=16,
906 + kv_connector="DecodeBenchConnector",
907 + kv_connector_extra_config={"startup_fill": True, "fill_std": fill_std},
908 + cache_dtype="fp8",
909 + )
910 + kv_cache_spec = FullAttentionSpec(
911 + block_size=16,
912 + num_kv_heads=1,
913 + head_size=8,
914 + dtype=torch.uint8,
915 + kv_quant_mode=KVQuantMode.FP8_PER_TENSOR,
916 + )
917 + connector = DecodeBenchConnector(
918 + vllm_config,
919 + KVConnectorRole.WORKER,
920 + KVCacheConfig(
921 + num_blocks=4,
922 + kv_cache_tensors=[],
923 + kv_cache_groups=[KVCacheGroupSpec(["layer"], kv_cache_spec)],
924 + ),
925 + )
926 + cache = torch.zeros(4, 16, 8, dtype=torch.uint8)
927 + connector.register_kv_caches({"layer": cache})
928 +
929 + values = cache.view(current_platform.fp8_dtype()).float()
930 + if fill_std == 0:
931 + expected = torch.tensor(0.015).to(current_platform.fp8_dtype()).float()
932 + torch.testing.assert_close(values, expected.expand_as(values))
933 + else:
934 + assert torch.isfinite(values).all()
935 + assert values.std() > 0
936 +
937 +
938 +def test_decode_bench_connector_startup_fill_keeps_zeroed_groups_per_request():
939 + """Attention blocks the engine zeroes on allocation are filled per request."""
940 + vllm_config = create_vllm_config(
941 + block_size=16,
942 + max_num_batched_tokens=1000,
943 + kv_connector="DecodeBenchConnector",
944 + kv_connector_extra_config={"startup_fill": True},
945 + )
946 + # Mamba layers make the engine zero newly allocated attention blocks.
947 + kv_cache_config = KVCacheConfig(
948 + num_blocks=4,
949 + kv_cache_tensors=[],
950 + kv_cache_groups=[
951 + KVCacheGroupSpec(
952 + ["attention"],
953 + FullAttentionSpec(
954 + block_size=16, num_kv_heads=1, head_size=1, dtype=torch.float32
955 + ),
956 + ),
957 + KVCacheGroupSpec(
958 + ["mamba"],
959 + MambaSpec(
960 + block_size=16,
961 + shapes=((4,), (4,)),
962 + dtypes=(torch.float32, torch.float32),
963 + ),
964 + ),
965 + ],
966 + )
967 + assert kv_cache_config.needs_kv_cache_zeroing
968 + scheduler_connector = DecodeBenchConnector(
969 + vllm_config, KVConnectorRole.SCHEDULER, kv_cache_config
970 + )
971 + num_external_tokens = 17
972 + request = create_request(
973 + request_id=1, num_tokens=num_external_tokens + 1, block_size=16
974 + )
975 + blocks = KVCacheBlocks(
976 + (
977 + [KVCacheBlock(block_id=1), KVCacheBlock(block_id=2)],
978 + [KVCacheBlock(block_id=3)],
979 + )
980 + )
981 + scheduler_connector.update_state_after_alloc(request, blocks, num_external_tokens)
982 + metadata = scheduler_connector.build_connector_meta(SchedulerOutput.make_empty())
983 + assert isinstance(metadata, DecodeBenchConnectorMetadata)
984 + assert metadata.reqs_to_fill == {
985 + request.request_id: (([1, 2], []), num_external_tokens)
986 + }
987 +
988 + worker_connector = DecodeBenchConnector(
989 + vllm_config, KVConnectorRole.WORKER, kv_cache_config
990 + )
991 + attention = torch.full((4, 2), -1.0)
992 + mamba_states = (torch.zeros(4, 4), torch.zeros(4, 4))
993 + worker_connector.register_kv_caches({"attention": attention, "mamba": mamba_states})
994 + assert torch.all(attention == -1.0)
995 +
996 + worker_connector.bind_connector_metadata(metadata)
997 + worker_connector.start_load_kv(
998 + ForwardContext(no_compile_layers={}, attn_metadata={}, slot_mapping={})
999 + )
1000 + torch.testing.assert_close(attention[1:3], torch.full_like(attention[1:3], 0.015))
1001 + assert torch.all(attention[[0, 3]] == -1.0)
1002 +
1003 +
1004 +def test_decode_bench_connector_startup_fill_skips_per_step_fill():
1005 + """Admitted requests need no per-step fill and leave existing KV intact."""
1006 + block_size = 16
1007 + runner = DecodeBenchTestRunner(
1008 + block_size=block_size,
1009 + num_gpu_blocks=100,
1010 + kv_connector_extra_config={"startup_fill": True},
1011 + )
1012 + for kv_cache in runner.kv_caches.values():
1013 + torch.testing.assert_close(kv_cache, torch.full_like(kv_cache, 0.015))
1014 + kv_cache.fill_(7.0)
1015 +
1016 + req1 = runner.new_request([1] * (block_size * 2))
1017 + scheduler_output, metadata = runner.run_single_step()
1018 + assert scheduler_output.num_scheduled_tokens == {req1.request_id: 1}
1019 + assert scheduler_output.scheduled_new_reqs[0].num_computed_tokens == (
1020 + block_size * 2 - 1
1021 + )
1022 + assert metadata.reqs_to_fill == {}
1023 +
1024 + req2 = runner.new_request([2] * (block_size * 3))
1025 + scheduler_output, metadata = runner.run_single_step()
1026 + assert scheduler_output.num_scheduled_tokens == {
1027 + req1.request_id: 1,
1028 + req2.request_id: 1,
1029 + }
1030 + assert metadata.reqs_to_fill == {}
1031 +
1032 + # A re-admitted (e.g. preempted) request is not reported again.
1033 + assert runner.scheduler_connector.get_num_new_matched_tokens(req1, 0) == (
1034 + 0,
1035 + False,
1036 + )
1037 + for kv_cache in runner.kv_caches.values():
1038 + assert torch.all(kv_cache == 7.0)
1039 +
1040 +
1041 +def test_decode_bench_connector_startup_fill_keeps_circular_buffer_zero_fill():
1042 + """Circular buffers are still zeroed per request with startup fill."""
1043 + vllm_config = create_vllm_config(
1044 + block_size=16,
1045 + max_num_batched_tokens=1000,
1046 + kv_connector="DecodeBenchConnector",
1047 + kv_connector_extra_config={"startup_fill": True},
1048 + )
1049 + kv_cache_config = KVCacheConfig(
1050 + num_blocks=8,
1051 + kv_cache_tensors=[],
1052 + kv_cache_groups=[
1053 + KVCacheGroupSpec(
1054 + ["full_attention"],
1055 + FullAttentionSpec(
1056 + block_size=16, num_kv_heads=1, head_size=1, dtype=torch.float32
1057 + ),
1058 + ),
1059 + KVCacheGroupSpec(
1060 + ["compressor_ring"],
1061 + CircularBufferSpec(
1062 + block_size=32, num_kv_heads=1, head_size=1, dtype=torch.float32
1063 + ),
1064 + ),
1065 + ],
1066 + )
1067 + scheduler_connector = DecodeBenchConnector(
1068 + vllm_config, KVConnectorRole.SCHEDULER, kv_cache_config
1069 + )
1070 + num_external_tokens = 17
1071 + request = create_request(
1072 + request_id=1, num_tokens=num_external_tokens + 1, block_size=16
1073 + )
1074 + blocks = KVCacheBlocks(
1075 + (
1076 + [KVCacheBlock(block_id=1), KVCacheBlock(block_id=2)],
1077 + [KVCacheBlock(block_id=4)],
1078 + )
1079 + )
1080 + scheduler_connector.update_state_after_alloc(request, blocks, num_external_tokens)
1081 + metadata = scheduler_connector.build_connector_meta(SchedulerOutput.make_empty())
1082 + assert isinstance(metadata, DecodeBenchConnectorMetadata)
1083 + assert metadata.reqs_to_fill == {
1084 + request.request_id: (([], [4]), num_external_tokens)
1085 + }
1086 +
1087 + worker_connector = DecodeBenchConnector(
1088 + vllm_config, KVConnectorRole.WORKER, kv_cache_config
1089 + )
1090 + attention_cache = torch.zeros(8, 2)
1091 + ring_cache = torch.ones(8, 2)
1092 + worker_connector.register_kv_caches(
1093 + {"full_attention": attention_cache, "compressor_ring": ring_cache}
1094 + )
1095 + assert torch.all(ring_cache == 1.0)
1096 + worker_connector.bind_connector_metadata(metadata)
1097 + worker_connector.start_load_kv(
1098 + ForwardContext(no_compile_layers={}, attn_metadata={}, slot_mapping={})
1099 + )
1100 +
1101 + torch.testing.assert_close(attention_cache, torch.full_like(attention_cache, 0.015))
1102 + expected_ring = torch.ones_like(ring_cache)
1103 + expected_ring[4] = 0
1104 + assert torch.equal(ring_cache, expected_ring)
1105 +
1106 +
1107 if __name__ == "__main__":
1108 pytest.main([__file__, "-v"])
vllm/distributed/kv_transfer/kv_connector/v1/decode_bench_connector.py
+223 -36
@@ -28,6 +28,16 @@ Example:
28 - fill_mean (float): Mean value for random normal fill (default: 0.015)
29 - fill_std (float): Standard deviation for random fill (default: 0.0)
30 Set to 0 for constant values, >0 for random sampling
31 + - startup_fill (bool): Fill the whole KV cache once at startup instead
32 + of filling each new request's blocks in the step that schedules it
33 + (default: False). Circular-buffer caches are still zeroed per
34 + request, and attention caches whose new blocks the engine zeroes
35 + before use (hybrid Mamba or mixed-precision KV caches) are still
36 + filled per request. The fill is not redone after sleep mode
37 + discards the KV cache.
38 +
39 + The dummy KV cache is only meant for performance measurement; outputs are
40 + not meaningful for accuracy evaluation.
41
42 """
43
@@ -45,13 +55,21 @@ from vllm.distributed.kv_transfer.kv_connector.v1.base import (
55 SupportsHMA,
56 )
57 from vllm.logger import init_logger
58 +from vllm.platforms import current_platform
59 from vllm.utils.math_utils import cdiv
60 from vllm.v1.attention.backend import AttentionMetadata
61 from vllm.v1.core.kv_cache_utils import (
62 dcp_world_size_for_kv_cache_spec,
63 resolve_dcp_kv_block_size,
64 )
54 -from vllm.v1.kv_cache_interface import CircularBufferSpec, iter_layer_specs
65 +from vllm.v1.kv_cache_interface import (
66 + AttentionSpec,
67 + CircularBufferSpec,
68 + KVCacheSpec,
69 + KVQuantMode,
70 + UniformTypeKVCacheSpecs,
71 + iter_layer_specs,
72 +)
73
74 if TYPE_CHECKING:
75 from vllm.config import VllmConfig
@@ -64,6 +82,45 @@ if TYPE_CHECKING:
82 logger = init_logger(__name__)
83
84
85 +def _get_fp8_dtype(spec: KVCacheSpec, cache_dtype: str) -> torch.dtype | None:
86 + """The fp8 dtype of a KV cache that stores plain fp8 values as uint8.
87 +
88 + Returns None for other layouts, including packed ones that embed scales
89 + or other metadata in the uint8 page.
90 + """
91 + if (
92 + not isinstance(spec, AttentionSpec)
93 + or spec.dtype != torch.uint8
94 + or spec.kv_quant_mode != KVQuantMode.FP8_PER_TENSOR
95 + or spec.state_content_bytes is not None
96 + ):
97 + return None
98 + cache_dtype = getattr(spec, "cache_dtype_str", None) or cache_dtype
99 + if cache_dtype in ("fp8", "fp8_e4m3"):
100 + return current_platform.fp8_dtype()
101 + if cache_dtype == "fp8_e5m2":
102 + return torch.float8_e5m2
103 + return None
104 +
105 +
106 +def _get_startup_fill_group_ids(kv_cache_config: "KVCacheConfig") -> set[int]:
107 + """KV cache groups that startup fill covers.
108 +
109 + Circular buffers are zero-filled per request. Attention groups whose new
110 + blocks the engine zeroes before use are filled per request, since the
111 + zeroing would wipe the startup fill.
112 + """
113 + group_ids = set()
114 + for group_idx, group in enumerate(kv_cache_config.kv_cache_groups):
115 + spec = group.kv_cache_spec
116 + if any(isinstance(s, CircularBufferSpec) for s in iter_layer_specs(spec)):
117 + continue
118 + if kv_cache_config.needs_kv_cache_zeroing and isinstance(spec, AttentionSpec):
119 + continue
120 + group_ids.add(group_idx)
121 + return group_ids
122 +
123 +
124 @dataclass
125 class DecodeBenchConnectorMetadata(KVConnectorMetadata):
126 """Metadata for DecodeBenchConnector.
@@ -191,6 +248,14 @@ class DecodeBenchConnectorScheduler:
248 """Scheduler-side implementation for DecodeBenchConnector."""
249
250 def __init__(self, vllm_config: "VllmConfig", kv_cache_config: "KVCacheConfig"):
251 + kv_transfer_config = vllm_config.kv_transfer_config
252 + assert kv_transfer_config is not None
253 + self.startup_fill = kv_transfer_config.get_from_extra_config(
254 + "startup_fill", False
255 + )
256 + self._startup_fill_group_ids = (
257 + _get_startup_fill_group_ids(kv_cache_config) if self.startup_fill else set()
258 + )
259 dcp_world_size = vllm_config.parallel_config.decode_context_parallel_size
260 self.kv_cache_groups = kv_cache_config.kv_cache_groups
261 self.group_block_sizes = tuple(
@@ -275,6 +340,9 @@ class DecodeBenchConnectorScheduler:
340 strict=True,
341 )
342 ):
343 + if group_idx in self._startup_fill_group_ids:
344 + block_ids_per_group_list.append([])
345 + continue
346 is_circular_buffer = all(
347 isinstance(spec, CircularBufferSpec)
348 for spec in iter_layer_specs(group.kv_cache_spec)
@@ -310,6 +378,9 @@ class DecodeBenchConnectorScheduler:
378 )
379 block_ids_per_group_list.append(block_ids)
380 block_ids_per_group = tuple(block_ids_per_group_list)
381 + self._filled_requests.add(req_id)
382 + if self.startup_fill and not any(block_ids_per_group):
383 + return
384
385 # Store the blocks to fill for all group. _pending_fills doesn't need cleanup
386 # as it's cleared after build_connector_meta
@@ -317,7 +388,6 @@ class DecodeBenchConnectorScheduler:
388 block_ids_per_group,
389 num_external_tokens,
390 )
320 - self._filled_requests.add(req_id)
391
392 block_counts = tuple(len(group) for group in block_ids_per_group)
393 logger.debug(
@@ -356,10 +426,27 @@ class DecodeBenchConnectorWorker:
426 assert kv_transfer_config is not None
427 self.fill_mean = kv_transfer_config.get_from_extra_config("fill_mean", 0.015)
428 self.fill_std = kv_transfer_config.get_from_extra_config("fill_std", 0.0)
429 + self.startup_fill = kv_transfer_config.get_from_extra_config(
430 + "startup_fill", False
431 + )
432 + self._startup_fill_group_ids = (
433 + _get_startup_fill_group_ids(kv_cache_config) if self.startup_fill else set()
434 + )
435
436 # Will be populated via register_kv_caches
437 self.kv_caches: dict[str, torch.Tensor] | None = None
438
439 + # Layers whose uint8 KV cache stores plain fp8 values
440 + self._fp8_dtypes: dict[str, torch.dtype] = {}
441 + for group in kv_cache_config.kv_cache_groups:
442 + for layer_name in group.layer_names:
443 + spec = group.kv_cache_spec
444 + if isinstance(spec, UniformTypeKVCacheSpecs):
445 + spec = spec.kv_cache_specs[layer_name]
446 + fp8_dtype = _get_fp8_dtype(spec, vllm_config.cache_config.cache_dtype)
447 + if fp8_dtype is not None:
448 + self._fp8_dtypes[layer_name] = fp8_dtype
449 +
450 # Mapping from KV cache group index to list of layer names in that group
451 self.group_to_layers = {
452 group_idx: list(group.layer_names)
@@ -373,6 +460,17 @@ class DecodeBenchConnectorWorker:
460 for spec in iter_layer_specs(group.kv_cache_spec)
461 )
462 }
463 + num_zeroed_groups = len(
464 + self.group_to_layers.keys()
465 + - self._startup_fill_group_ids
466 + - self._zero_fill_group_ids
467 + )
468 + if self.startup_fill and num_zeroed_groups:
469 + logger.info(
470 + "DecodeBenchConnector: the engine zeroes new blocks of %d KV "
471 + "cache groups before use, so they are still filled per request.",
472 + num_zeroed_groups,
473 + )
474
475 def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
476 """Store references to the KV cache tensors."""
@@ -383,6 +481,48 @@ class DecodeBenchConnectorWorker:
481 len(kv_caches),
482 )
483
484 + if self.startup_fill:
485 + self._fill_all_kv_caches()
486 +
487 + def _fill_all_kv_caches(self):
488 + """Fill the KV cache tensors of the startup-filled groups once, in
489 + place, with dummy values, so that no per-request fill is needed.
490 + """
491 + assert self.kv_caches is not None
492 +
493 + num_layers = 0
494 + for group_idx in sorted(self._startup_fill_group_ids):
495 + for layer_name in self.group_to_layers[group_idx]:
496 + kv_cache = self.kv_caches.get(layer_name)
497 + if isinstance(kv_cache, torch.Tensor):
498 + tensors = [kv_cache]
499 + elif isinstance(kv_cache, (list, tuple)) and all(
500 + isinstance(t, torch.Tensor) for t in kv_cache
501 + ):
502 + tensors = list(kv_cache)
503 + else:
504 + logger.warning_once(
505 + "DecodeBenchConnector: skipping startup fill for layer "
506 + "%s whose KV cache is %s, not a tensor or a list/tuple "
507 + "of tensors.",
508 + layer_name,
509 + type(kv_cache).__name__,
510 + )
511 + continue
512 + for tensor in tensors:
513 + fill_dtype = self._fp8_dtypes.get(layer_name, tensor.dtype)
514 + self._fill_tensor(tensor, self.fill_mean, self.fill_std, fill_dtype)
515 + num_layers += 1
516 +
517 + logger.info(
518 + "DecodeBenchConnector: Filled %d KV cache layers at startup with %s "
519 + "values (mean=%.3f, std=%.3f)",
520 + num_layers,
521 + "random" if self.fill_std > 0 else "constant",
522 + self.fill_mean,
523 + self.fill_std,
524 + )
525 +
526 def start_fill_kv(self, metadata: DecodeBenchConnectorMetadata):
527 """Fill the allocated KV cache blocks with dummy values.
528
@@ -455,12 +595,17 @@ class DecodeBenchConnectorWorker:
595 # dimension — so fill each tensor in its entirety with the same
596 # dummy values.
597 if isinstance(kv_cache, torch.Tensor):
458 - self._fill_block_tensor(kv_cache, block_ids, fill_mean, fill_std)
598 + fill_dtype = self._fp8_dtypes.get(layer_name, kv_cache.dtype)
599 + self._fill_block_tensor(
600 + kv_cache, block_ids, fill_mean, fill_std, fill_dtype
601 + )
602 elif isinstance(kv_cache, (list, tuple)) and all(
603 isinstance(t, torch.Tensor) for t in kv_cache
604 ):
605 for state_tensor in kv_cache:
463 - self._fill_state_tensor(state_tensor, fill_mean, fill_std)
606 + self._fill_tensor(
607 + state_tensor, fill_mean, fill_std, state_tensor.dtype
608 + )
609 else:
610 logger.warning_once(
611 "DecodeBenchConnector: skipping fill for layer %s whose KV "
@@ -486,6 +631,7 @@ class DecodeBenchConnectorWorker:
631 block_ids: list[int],
632 fill_mean: float,
633 fill_std: float,
634 + fill_dtype: torch.dtype,
635 ):
636 """Fill the requested block rows of a block-indexed KV cache tensor.
637
@@ -495,6 +641,8 @@ class DecodeBenchConnectorWorker:
641 tensor's first dim are ignored.
642 fill_mean: Mean value for the fill.
643 fill_std: Standard deviation for the fill.
644 + fill_dtype: Dtype the values are encoded as, e.g. the fp8 dtype
645 + of a uint8 fp8 cache.
646
647 """
648 # Convert block_ids to tensor on device
@@ -509,46 +657,85 @@ class DecodeBenchConnectorWorker:
657 if len(valid_block_ids) == 0:
658 return
659
512 - # Create fill values - either constant or random
513 - block_shape = kv_cache.shape[1:]
514 - if fill_std > 0:
515 - # Random normal sampling
516 - fill_values = torch.normal(
517 - mean=fill_mean,
518 - std=fill_std,
519 - size=(len(valid_block_ids),) + block_shape,
520 - dtype=kv_cache.dtype,
521 - device=kv_cache.device,
522 - )
523 - else:
524 - # Constant fill value
525 - fill_values = torch.full(
526 - (len(valid_block_ids),) + block_shape,
527 - fill_mean,
528 - dtype=kv_cache.dtype,
529 - device=kv_cache.device,
530 - )
660 + fill_values = self._make_fill_values(
661 + (len(valid_block_ids),) + kv_cache.shape[1:],
662 + kv_cache.device,
663 + fill_dtype,
664 + fill_mean,
665 + fill_std,
666 + )
667
668 # Batch fill operation
533 - kv_cache[valid_block_ids] = fill_values
669 + kv_cache[valid_block_ids] = fill_values.view(kv_cache.dtype)
670 +
671 + def _make_fill_values(
672 + self,
673 + size: tuple[int, ...],
674 + device: torch.device,
675 + dtype: torch.dtype,
676 + fill_mean: float,
677 + fill_std: float,
678 + ) -> torch.Tensor:
679 + """Create constant or random fill values of ``dtype``, clamped to its
680 + finite range. Non-floating dtypes (packed layouts) are filled with
681 + zeros.
682 + """
683 + if not dtype.is_floating_point:
684 + if fill_mean or fill_std:
685 + logger.warning_once(
686 + "DecodeBenchConnector: %s KV caches do not hold plain "
687 + "floating-point values; filling them with zeros.",
688 + dtype,
689 + )
690 + return torch.zeros(size, dtype=dtype, device=device)
691 +
692 + finfo = torch.finfo(dtype)
693 + if fill_std > 0:
694 + sample_dtype = torch.float32
695 + if dtype in (torch.float16, torch.bfloat16, torch.float32, torch.float64):
696 + sample_dtype = dtype
697 + values = torch.normal(
698 + fill_mean, fill_std, size=size, device=device, dtype=sample_dtype
699 + )
700 + return values.clamp_(finfo.min, finfo.max).to(dtype)
701 + fill_mean = min(max(fill_mean, finfo.min), finfo.max)
702 + return torch.full(size, fill_mean, dtype=dtype, device=device)
703
535 - def _fill_state_tensor(
536 - self, kv_cache: torch.Tensor, fill_mean: float, fill_std: float
704 + def _fill_tensor(
705 + self,
706 + kv_cache: torch.Tensor,
707 + fill_mean: float,
708 + fill_std: float,
709 + fill_dtype: torch.dtype,
710 ):
538 - """Fill an entire non-block-indexed state tensor with dummy values.
711 + """Fill an entire tensor in place with dummy values.
712
540 - Hybrid / linear-attention layers (e.g. Mamba, Kimi Delta Attention)
541 - store their per-layer state as tensors with no num_blocks dimension,
542 - so the whole tensor is filled with the same constant or random values
543 - used for block fills, rather than selected block rows.
713 + Used for startup fills, and for hybrid / linear-attention layers (e.g.
714 + Mamba, Kimi Delta Attention) whose per-layer state tensors are filled
715 + in their entirety with the same constant or random values used for
716 + block fills, rather than selected block rows.
717
718 Args:
546 - kv_cache: A state tensor to fill in its entirety.
719 + kv_cache: A tensor to fill in its entirety.
720 fill_mean: Mean value for the fill.
721 fill_std: Standard deviation for the fill.
722 + fill_dtype: Dtype the values are encoded as, e.g. the fp8 dtype
723 + of a uint8 fp8 cache.
724
725 """
551 - if fill_std > 0:
552 - kv_cache.normal_(mean=fill_mean, std=fill_std)
553 - else:
554 - kv_cache.fill_(fill_mean)
726 + view = kv_cache.view(fill_dtype)
727 + if fill_std == 0 or not fill_dtype.is_floating_point:
728 + view.fill_(
729 + self._make_fill_values(
730 + (), torch.device("cpu"), fill_dtype, fill_mean, fill_std
731 + )
732 + )
733 + return
734 + # Sample in chunks to bound the temporary memory.
735 + rows = max(1, (1 << 24) // max(1, view[0].numel()))
736 + for chunk in view.split(rows):
737 + chunk.copy_(
738 + self._make_fill_values(
739 + chunk.shape, chunk.device, fill_dtype, fill_mean, fill_std
740 + )
741 + )