support hybrid attention for vllm connector && add integration test - #1
support hybrid attention for vllm connector && add integration test#1lpdink wants to merge 7 commits into
Conversation
lpdink
commented
Jul 28, 2026
- support hybrid attention for vllm connector
- add integration test
…ybrid attention
Every kv_cache_group (FullAttentionSpec / MambaSpec) becomes an independent
transfer unit with its own location spec (tp{rank}_g{group}), block table and
data access strategy: token-granular gather/scatter for attention groups,
per-block opaque byte copy for mamba/linear state groups. A full-attention
model is simply the one-group case.
- GroupMeta/TransferGroup abstractions; ReqState tracks per-group block tables
- SupportsHMA (request_finished_all_groups) for hybrid memory allocator models
- Adapt to vLLM 0.26.0 packed KV layout (num_blocks, heads, block, 2*head_size)
- Strided gather/scatter kernel path for padded/strided page layouts
- Null-block detection for unmaterialized mamba boundary state
…ention Two-phase save/load verification driven through a real KVCM manager and a real vLLM OpenAI server: phase 1 saves KV to KVCM and captures references straight from vLLM's paged cache; phase 2 loads via the connector and captures again. Captures use only vLLM's own block-table mapping, breaking the save/load symmetry so per-group translation bugs cannot cancel out. Bit-exact compare with cosine > 99.99% fallback. Scenarios: test_basic (TP=1), test_concurrent (4 reqs), test_tp (TP=2 with cross-block manager/vllm block size mapping for full-attention models). The same targets run against full-attention (Qwen2.5) and hybrid (Qwen3.5) models via KVCM_E2E_MODEL; hybrid runs enable prefix caching (mamba align mode) and restart vLLM between phases so loads come from KVCM, not the local cache. Adds a matrixed GitHub workflow (full-attention + hybrid) for self-hosted GPU runners.
…Error Two fixes in the vLLM connector scheduler path: 1. get_num_new_matched_tokens returned the raw external match count without capping it below the prompt length. This connector loads synchronously (load_kv_async=False), so vLLM schedules num_tokens - num_computed_tokens new tokens and asserts that count is > 0 (vllm 0.26.0 v1/core/sched/scheduler.py waiting-queue loop). A prompt whose token count is an exact multiple of the manager block size with all blocks externally cached made the count 0 and crashed the engine. Drop trailing matched blocks until at least one token remains to recompute, mirroring the fallback in vLLM's own SharedStorage/NIXL connectors. 2. handle_canceled_save_req indexed _alive_requests[req_id] directly, but cancellations arrive from http_executor threads and can race request teardown; use .get() with a warning and skip. Covered by kv_cache_manager/py_connector/test/test_scheduler_state.py and the integration_test/vllm_e2e test_full_hit scenario.
…cheduler state Previously zero unit coverage on the connector's pure logic. New Bazel py_tests under py_connector/test (vllm_stubs.py registers lightweight vLLM / pybind stand-ins in sys.modules so v1_connector imports without a GPU or the compiled client): * test_block_translation: _attn_token_indices / _state_block_ids against an independent brute-force reference, parameterized over ratio=1, ratio>1 and manager_bs != group_bs, plus hand-computed examples. * test_data_transfer_results: MultiResult ordering (in-order, out-of-order, concurrent) and the save/load done callbacks' stride-AND merge, which pins the implicit group-major submission-order contract of _submit_group_tasks; includes the hybrid report_failures=False branch. * test_scheduler_state: get_num_new_matched_tokens (incl. the full-hit cap), parse_block_mask_to_save_indices (offset and bool_masks), _parse_groups (full-attn, hybrid, eagle skip, unsupported spec), and the build_connector_meta state machine (new request, cached deltas with new_block_ids None/non-None, preemption resume via both resumed_req_ids and legacy resumed_from_preemption, save-threshold growth, both request_finished paths, canceled-save races). * test/kernel/test_strided_gather_scatter (GPU): the strided kernel path (block_stride/local_block_size incl. padded pages) added in fc89691 had no coverage; checked element-wise against naive torch indexing, plus a roundtrip and a padding-untouched sentinel check. vllm/BUILD: expose vllm_connector to py_connector subpackages for the tests.
Two production bugs surfaced by the new e2e scenarios: 1. Fail-reschedule loop after a KV load failure. With kv_load_failure_policy=recompute, vLLM reschedules the request and calls get_num_new_matched_tokens again; the manager still advertises the blocks whose storage is gone, so the connector re-matched them and the engine looped load-fail-reschedule forever (request hung). A request that already went through an external load attempt (blocks were allocated) now skips external matching on re-query and recomputes locally. 2. Multi-block hybrid saves always failed. vLLM's mamba 'align' mode only materializes the state block ending the matched region (single_type_kv_cache_manager.MambaManager assigns the null block to interior positions), so every interior manager block has a null (id 0) state target by design. save_task treated that as a failure, the stride-AND merge then dropped those manager blocks from the manager's prefix chain, and hybrid caching silently degraded to the final block only. Null state targets are now transferred vacuously (reported success, nothing copied) on both save and load; load_task previously failed the whole task on any null target for the same reason. Covered by test_scheduler_state (retry guard), test_data_transfer_results (vacuous null-state save/load), and e2e: hybrid basic now verifies 4/4 manager blocks bit-exact; test_load_failure exercises the recompute path end to end.
…rage Harness hardening (task B) plus the P2 coverage fix: * Hybrid prompts were ~578 tokens = 1 x 528 manager block, so multi-block state mapping and incremental save never ran under hybrid. make_base_prompts now emits 140 sentences (~2100 tokens > 3 x 528) for hybrid models. * wait_for_captures timeout raises AssertionError instead of warning. * Expected ref/loaded capture counts are computed per prompt from the actual tokenization (len // mbs, shared-prefix for loads) and asserted as exact lower bounds via assert_report_ok(min_matched=...). * bit-exact comparison is the default; cosine fallback only with KVCM_E2E_ALLOW_COSINE=1. * compare_captures iterates the loaded capture's layers (a loaded layer without a reference is a hard error); mamba align-mode null-state layers may legitimately be absent on either side and test_connector skips capturing null (id 0) state blocks, mirroring the connector's vacuous transfer. * VllmServer/ScenarioEnv: connector_name selection (mutation meta-test), log_level, kv_transfer extra-config overrides, kv_load_failure_policy, key_count_per_file, and a ScenarioEnv helper owning manager/vLLM lifecycle for the new custom scenarios; send_completions accepts token-id prompts and extra payload fields; file-backend root_path gets a trailing slash so block files land inside the dir. * test_connector: drop a duplicated capture line; add MutatedConnector (slot -1 off-by-one, test-side injection only) for the mutation meta-test.
* test_mutation (B4): runs the basic scenario with MutatedConnector (slot -1 in _attn_token_indices) and asserts KV verification FAILS -- proof the capture-based harness catches symmetric translation bugs and is not vacuous. * test_full_hit (C2): prompt trimmed to an exact multiple of the manager block size, resent after being fully saved. Regression for the synchronous full-hit crash (vllm 0.26.0 scheduler.py 'assert num_new_tokens > 0'); asserts the engine survives and 0 < matched < prompt tokens. * test_partial_hit (C1): staged A / A+B / A+B+C prompts with prefix caching on; exercises the non-zero-offset incremental manager query and the incremental save extension, asserts a logged query with offset > 0 and verifies the blocks saved through the incremental path. * test_load_failure (C3): deletes the tail half of the per-block storage files between save and load (key_count_per_file=1, block_per_load_task=1, kv_load_failure_policy=recompute). Full-attn: failures reported to vLLM, surviving head blocks verify bit-exact, mismatches confined to deleted blocks. Hybrid: failure swallowed by design (vLLM invalid-block recovery is single-group only), asserts no hang/crash and the failure log. * test_multi_turn (C4): turn 1 decodes past a manager block boundary (ignore_eos + return_token_ids), asserts the manager committed more blocks than the prompt covers; turn 2 embeds turn 1 prompt+output as token ids and must externally match beyond prompt-only coverage with verified KV. All scenarios pass for both Qwen2.5-7B-Instruct (full-attn) and Qwen3.5-4B (hybrid) alongside the original basic/concurrent/tp regressions.
| contents: read | ||
| on: | ||
| pull_request: | ||
| branches: ["main"] |
There was a problem hiding this comment.
mmm,这个CI没跑过,我也不了解内部CI机器的setting,要看一下。说不定要适配,我们内部CI机器真的有两张A10么?
| # Hybrid model: MambaSpec groups + FullAttentionSpec group | ||
| # (mamba_cache_mode="align"). | ||
| - kind: hybrid-attention | ||
| model_var: VLLM_E2E_MODEL_HYBRID |
There was a problem hiding this comment.
这个应该要相应的模型在测试机器上存在,应该在向主仓库的PR中说明要怎么配置仓库环境变量。
或者暂时删除本CI,在下一个里面处理。我总觉得这里不太好搞。要看一下主仓库已有的用内部机器的setting。然后要给对应的机器把模型下好,感觉很麻烦啊。
| bazelisk build //kv_cache_manager:kv_cache_manager_bin \ | ||
| //kv_cache_manager/client/pybind:kvcm_py_client_lib_wheel \ | ||
| //kv_cache_manager/py_connector/vllm:kvcm_vllm_connector_wheel \ | ||
| --per_file_copt='external/jsoncpp_git/.*@-Wno-error' |
There was a problem hiding this comment.
这里抑制了warning,但是我们的CI机器的gcc版本不一定很高。也许可以过,不过fine,也可以接受吧。
| cp "$whl" "/tmp/kvcm_whl/${pkg}-${ver}-cp312-cp312-manylinux_2_32_x86_64.whl" | ||
| done | ||
| "$KVCM_E2E_PYTHON" -m pip install --no-deps --force-reinstall /tmp/kvcm_whl/*.whl || \ | ||
| uv pip install --python "$KVCM_E2E_PYTHON" --no-deps --force-reinstall /tmp/kvcm_whl/*.whl |
There was a problem hiding this comment.
机器不一定有uv。总的来说,整个workflow,都是以我的开发机为环境编写的,我在考虑删除这个CI文件。还不成熟;相关CI建设要的外部资源有点多。
| which is what makes the test able to detect symmetric save/load translation | ||
| bugs. See ``test_connector.py`` for the capture-side details. | ||
|
|
||
| Full-attention vs hybrid models |
There was a problem hiding this comment.
总的来说,在我的本地测试中,我们使用了qwen3.5和qwen2.5,这分别测试了hybrid attention和full attention;现在的实现也可以支持MLA,例如glm-4.7-flash,但是没有做这个集成测试。
| level=logging.INFO, | ||
| format="%(asctime)s %(name)s %(levelname)s %(message)s") | ||
|
|
||
| MODEL_PATH = os.environ.get("KVCM_E2E_MODEL", "/root/ws/resources/models/Qwen2.5-7B-Instruct") |
There was a problem hiding this comment.
默认的本地路径,这个要避免的。
这个环境变量应该从CI仓库中来,在这里添加注释说明一般需要一个什么模型;
也要在PR的描述里说清楚。
| format="%(asctime)s %(name)s %(levelname)s %(message)s") | ||
|
|
||
| MODEL_PATH = os.environ.get("KVCM_E2E_MODEL", "/root/ws/resources/models/Qwen2.5-7B-Instruct") | ||
| COSINE_THRESHOLD = 0.9999 |
There was a problem hiding this comment.
我多少有点怀疑这个变量真的用到了吗?我们实际上已经实现了bit级一致的。
| COSINE_THRESHOLD = 0.9999 | ||
| # Bit-exact comparison is the default (empirically all scenarios achieve it). | ||
| # Cosine fallback must be explicitly requested. | ||
| ALLOW_COSINE = os.environ.get("KVCM_E2E_ALLOW_COSINE", "0") == "1" |
There was a problem hiding this comment.
如果没用到,他们都可以被删除。cossim的逻辑也可以删除。
| def is_hybrid_model(model_path: str) -> bool: | ||
| """Detect a hybrid (mamba/linear + full attention) model from its config.""" | ||
| try: | ||
| with open(os.path.join(model_path, "config.json")) as f: |
There was a problem hiding this comment.
这在读取对应的模型的config.json,fine,我不确定vllm的模型启动是否也需要,还是有聪明的detect机制;不过对我们测试程序来说,这是可接受的。
| return ( | ||
| "full_attention_interval" in text_cfg | ||
| or "linear_conv_kernel_dim" in text_cfg | ||
| or cfg.get("model_type", "").startswith("qwen3_5") |
There was a problem hiding this comment.
emmm,感觉这种写法不怎么鲁棒;还不如我们直接写死我们用的测试模型呢?
| # Paths / binaries | ||
| # --------------------------------------------------------------------------- # | ||
| def _runfiles_root() -> Optional[str]: | ||
| return os.environ.get("RUNFILES_DIR") or os.environ.get("TEST_SRCDIR") |
There was a problem hiding this comment.
我们用了太多环境变量,但是缺少一个地方说明,应该添加到PR的描述里面。
|
|
||
|
|
||
| def find_python() -> str: | ||
| return os.environ.get("KVCM_E2E_PYTHON", "/root/ws/env/global_vllm/.venv/bin/python") |
| cfg = { | ||
| "storage_config": { | ||
| "type": "file", | ||
| "global_unique_name": "nfs_01", |
There was a problem hiding this comment.
对于connector的测试来说,its fine,不过,实际上我们这个测试是可以测试到更广泛的情况的,而不仅仅停留在只是一个connector的测试,也就是可以考虑配置其他存储集群的endpoint来做。
不过可以放到后续。
| cmd = [ | ||
| find_python(), "-m", "vllm.entrypoints.openai.api_server", | ||
| "--model", MODEL_PATH, | ||
| "--served-model-name", "qwen", |
There was a problem hiding this comment.
也许可以换个通用的名字,回头我们要支持SWA的时候怎么说?
| env["KVCM_E2E_CAPTURE_DIR"] = self.capture_dir | ||
| # Keep the connector's KV cache layout matching its expected | ||
| # [2, num_blocks, block_size, num_kv_heads, head_size] shape. | ||
| env.setdefault("VLLM_KV_CACHE_LAYOUT", "NHD") |
There was a problem hiding this comment.
vllm 0.23.0的layout发生了变化,也解放了对hybrid attention的支持...
而此前,这也是为什么这个工作非常难做,耗费了大量的时间,最后发现还是重写是最好的。因为此前的实现面向v0.22.1和full attention做了假设,其数据模型对hybrid attention的实现很不友好。
本次支持是基于刚刚发布的v0.26的,测试也是在v0.26上做的,但是考虑到0.23+的协议稳定,预期目前的实现可以支持v0.23+。
There was a problem hiding this comment.
这个行为要验证一下,降级到0.23.0,断言我们添加的集成测试仍然通过。
| env.setdefault("VLLM_ATTENTION_BACKEND", "FLASH_ATTN") | ||
| # Use the PyTorch-native sampler; the flashinfer sampler JIT-compiles | ||
| # with ninja, which is not available in the test environment. | ||
| env.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0") |
There was a problem hiding this comment.
上次支持我们安装了ninja...不过也行吧。我估计都能过,还是有过不了的风险?
| assert ref_t.shape == got_t.shape, ( | ||
| f"shape mismatch {layer_name}[{si}]: {ref_t.shape} vs {got_t.shape}" | ||
| ) | ||
| if not torch.equal(ref_t, got_t): |
There was a problem hiding this comment.
这里的思路就是收集不同tp, token_count的tensor,然后断言完全一致...额,为什么没有把不同tp的保存到同一个tensor文件,奇怪。难道有多个不同的保存时间点,哦,有道理,还真是。因为这里是异步到达的么?
| # prefix cache; restart vLLM so phase 2 loads from KVCM, not locally. | ||
| if hybrid: | ||
| logger.info("restarting vLLM before phase 2 (clear local prefix cache)") | ||
| vllm = env.restart_vllm(log_suffix="_p2") |
| through the OpenAI API and verifies that the KV cache data saved to / loaded | ||
| from KVCM is correct. | ||
|
|
||
| Requires 1-2 GPUs and vLLM >= 0.26.0. |
| | Kind | Example | Groups | Orchestration | | ||
| |---|---|---|---| | ||
| | Full attention | Qwen2.5-7B-Instruct | 1 `FullAttentionSpec` | prefix caching off, one server for both phases | | ||
| | Hybrid | Qwen3.5-4B | 3 `MambaSpec` + 1 `FullAttentionSpec` | prefix caching on (`mamba_cache_mode="align"`), server restarted between phases so phase 2 loads from KVCM instead of the local prefix cache | |
|
额,竟然不能提前导入torch,过不了CI,要解一下。 |
|
Superseded by alibaba#257, opened against the |