-
Notifications
You must be signed in to change notification settings - Fork 54
support hybrid attention for vllm connector && add integration tests #257
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
lpdink
wants to merge
56
commits into
main
Choose a base branch
from
feature/vllm-hybrid-attention-rewrite
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
56 commits
Select commit
Hold shift + click to select a range
3bd6ea0
[py_connector] rewrite vLLM connector around per-group transfer for h…
lpdink 991d0b5
[integration_test] add vLLM e2e KV verification for full + hybrid att…
lpdink f702cd6
[py_connector] cap full-prompt external hit and fix canceled-save Key…
lpdink e0718d9
[py_connector] add unit tests for translation, transfer results and s…
lpdink d462dcb
[py_connector] fix load-failure retry loop and null mamba state transfer
lpdink 645e58a
[integration_test] harden vllm e2e harness and fix hybrid prompt cove…
lpdink f909e82
[integration_test] add mutation meta-test and four vllm e2e scenarios
lpdink 4052b4c
[py_connector] make connector unit tests runnable without torch
lpdink 729e95f
[integration_test] drop dev-machine assumptions from the vllm e2e har…
lpdink 9dc5961
[py_connector] restore pre-0.26 vLLM KV layouts via shape-based detec…
lpdink 031a784
[integration_test] add missing requests dep to vllm e2e py_library
lpdink 9af6081
[integration_test] bind vllm e2e harness sockets to loopback only
lpdink 1b8c148
[py_connector] index block tables by transferred group, not raw position
lpdink 09582a9
[py_connector] size attention transfers from the compact page size
lpdink a3fa71d
[py_connector] reject windowed FullAttentionSpec instead of mispublis…
lpdink d2998c1
[py_connector] fail closed when the hybrid capability probe is inconc…
lpdink 9925b52
[py_connector] bound pinned staging memory with a byte budget
lpdink ec86f36
[py_connector] document hybrid load-failure and none-mode state limits
lpdink 0d70939
[integration_test] add cross-request prefix e2e scenario for null mam…
lpdink 3979907
[py_connector] publish per-block spec coverage instead of faking stat…
lpdink 6238d3b
[py_connector] replace vacuous-success unit tests with abstention sem…
lpdink c0a1a48
[manager] count blocks, not block_keys, when validating spec group names
lpdink 1b9b792
[integration_test] pick the always-present spec when resolving block …
lpdink 3c2bf77
[py_connector] re-truncate external match after the full-hit cap
lpdink 4039096
[py_connector] report all-failed when a transfer task crashes
lpdink 4eec9b3
[py_connector] make get_num_new_matched_tokens self-explanatory
lpdink ab89a1b
[py_connector] document why hybrid load failures stay unreported
lpdink 97b6265
[py_connector] fast-fail re-queries on explicit load failure, not att…
lpdink b336061
[py_connector] split the vLLM connector into per-role cores
lpdink 060dac9
[py_connector] split build_connector_meta into single-purpose stages
lpdink 7bd0413
[py_connector] revert the connector-level pinned staging budget
lpdink cb7fe68
[py_connector] split transfer types by group kind with explicit KV la…
lpdink 4dcb734
[py_connector] encapsulate TransferPlan and make task slicing read-only
lpdink 95fc44f
[py_connector] drop the worker-side request mirror
lpdink cbe1bc4
[py_connector] replace the request mirror with a ledger and side tables
lpdink f0b9c25
[py_connector] rename cores to ConnectorScheduler/ConnectorWorker wit…
lpdink 9418a10
[integration_test] split the vllm e2e harness and adapt it to the cor…
lpdink 93fcb63
[py_connector] document the gather/scatter kernel's two addressing modes
lpdink 5e7f6de
[py_connector] fix the source-checkout run path and residual split re…
lpdink c91fab9
[py_connector] count save blocks by key material, not scheduled tokens
lpdink b8ddf63
[integration_test] OR the hybrid detection signals and guard the muta…
lpdink 05714af
[py_connector] dedupe in-flight getCacheLocation re-asks
lpdink 67e1a98
[py_connector] key location queries by full identity, not req_id alone
lpdink a0d6289
[py_connector] stage transfers from a bounded pool, not per-task allocs
lpdink 715a655
[py_connector] drop the GPU side of the staging pool, zero-VRAM data …
lpdink 591699e
[py_connector] gate load-failure reporting on block-table shape, not …
lpdink a973a01
[py_connector] restore the 1024-block staging pool and release save s…
lpdink 23d1cc0
[py_connector] fail closed on a broken match/alloc contract; guard No…
lpdink 4692e77
[py_connector] one query slot per request: supersede by the newest as…
lpdink bbe8643
[py_connector] scheduler hygiene: log unledgered reqs, drop dead toke…
lpdink 14c2f2c
[py_connector] refuse pure-mamba models before init; rename spec-grou…
lpdink 75bc18a
[deps] close the pydantic/orjson bazel dependency gap; document the e…
lpdink 5f033ef
[py_connector] restore the per-request hit accounting in kv_transfer_…
lpdink d265c8c
Merge branch 'main' into feature/vllm-hybrid-attention-rewrite
lpdink 0db89c9
[py_connector] cap the staging pool by pinned bytes per group, not bl…
lpdink 10bf0e8
[py_connector] make the test stubs survive requests subclassing (CI r…
lpdink File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| package(default_visibility = ["//integration_test:__subpackages__"]) | ||
|
|
||
| # Shared library: orchestration (manager + vLLM + driver + comparison) and the | ||
| # verifying connector injected into vLLM via kv_connector_module_path. | ||
| # | ||
| # NOTE: vLLM / torch / triton are NOT bazel deps: the e2e harness runs the | ||
| # tests inside a dedicated venv (one per supported vLLM era) and puts the | ||
| # worktree root first on PYTHONPATH -- the connector under test runs from | ||
| # source, and torch/vLLM resolve from the venv. These targets are manual + | ||
| # GPU-tagged for the same reason; bazel only stages the driver and the | ||
| # manager binary. | ||
| py_library( | ||
| name = "e2e_lib", | ||
| srcs = [ | ||
| "e2e_lib.py", | ||
| "lib_utils.py", | ||
| "servers.py", | ||
| "test_connector.py", | ||
| ], | ||
| imports = ["."], | ||
| tags = ["no-remote-exec"], | ||
| deps = ["@pip_cpu//requests"], | ||
| ) | ||
|
|
||
| py_test( | ||
| name = "test_basic", | ||
| timeout = "eternal", | ||
| srcs = ["test_basic.py"], | ||
| data = [ | ||
| "//kv_cache_manager:kv_cache_manager_bin", | ||
| ], | ||
| imports = ["."], | ||
| tags = [ | ||
| "exclusive", # GPU tests must run serially to avoid CUDA OOM contention | ||
| "gpu", # requires 1+ GPU | ||
| "manual", # needs a GPU machine + vLLM venv + model; see README.md | ||
| "no-remote-exec", | ||
| ], | ||
| deps = [":e2e_lib"], | ||
| ) | ||
|
|
||
| py_test( | ||
| name = "test_concurrent", | ||
| timeout = "eternal", | ||
| srcs = ["test_concurrent.py"], | ||
| data = [ | ||
| "//kv_cache_manager:kv_cache_manager_bin", | ||
| ], | ||
| imports = ["."], | ||
| tags = [ | ||
| "exclusive", # GPU tests must run serially to avoid CUDA OOM contention | ||
| "gpu", # requires 1+ GPU | ||
| "manual", # needs a GPU machine + vLLM venv + model; see README.md | ||
| "no-remote-exec", | ||
| ], | ||
| deps = [":e2e_lib"], | ||
| ) | ||
|
|
||
| py_test( | ||
| name = "test_tp", | ||
| timeout = "eternal", | ||
| srcs = ["test_tp.py"], | ||
| data = [ | ||
| "//kv_cache_manager:kv_cache_manager_bin", | ||
| ], | ||
| imports = ["."], | ||
| tags = [ | ||
| "exclusive", # GPU tests must run serially to avoid CUDA OOM contention | ||
| "gpu", # requires 1+ GPU | ||
| "manual", # needs a GPU machine + vLLM venv + model; see README.md | ||
| "no-remote-exec", | ||
| ], | ||
| deps = [":e2e_lib"], | ||
| ) | ||
|
|
||
| py_test( | ||
| name = "test_full_hit", | ||
| timeout = "eternal", | ||
| srcs = ["test_full_hit.py"], | ||
| data = [ | ||
| "//kv_cache_manager:kv_cache_manager_bin", | ||
| ], | ||
| imports = ["."], | ||
| tags = [ | ||
| "exclusive", # GPU tests must run serially to avoid CUDA OOM contention | ||
| "gpu", # requires 1+ GPU | ||
| "manual", # needs a GPU machine + vLLM venv + model; see README.md | ||
| "no-remote-exec", | ||
| ], | ||
| deps = [":e2e_lib"], | ||
| ) | ||
|
|
||
| py_test( | ||
| name = "test_partial_hit", | ||
| timeout = "eternal", | ||
| srcs = ["test_partial_hit.py"], | ||
| data = [ | ||
| "//kv_cache_manager:kv_cache_manager_bin", | ||
| ], | ||
| imports = ["."], | ||
| tags = [ | ||
| "exclusive", # GPU tests must run serially to avoid CUDA OOM contention | ||
| "gpu", # requires 1+ GPU | ||
| "manual", # needs a GPU machine + vLLM venv + model; see README.md | ||
| "no-remote-exec", | ||
| ], | ||
| deps = [":e2e_lib"], | ||
| ) | ||
|
|
||
| py_test( | ||
| name = "test_load_failure", | ||
| timeout = "eternal", | ||
| srcs = ["test_load_failure.py"], | ||
| data = [ | ||
| "//kv_cache_manager:kv_cache_manager_bin", | ||
| ], | ||
| imports = ["."], | ||
| tags = [ | ||
| "exclusive", # GPU tests must run serially to avoid CUDA OOM contention | ||
| "gpu", # requires 1+ GPU | ||
| "manual", # needs a GPU machine + vLLM venv + model; see README.md | ||
| "no-remote-exec", | ||
| ], | ||
| deps = [":e2e_lib"], | ||
| ) | ||
|
|
||
| py_test( | ||
| name = "test_multi_turn", | ||
| timeout = "eternal", | ||
| srcs = ["test_multi_turn.py"], | ||
| data = [ | ||
| "//kv_cache_manager:kv_cache_manager_bin", | ||
| ], | ||
| imports = ["."], | ||
| tags = [ | ||
| "exclusive", # GPU tests must run serially to avoid CUDA OOM contention | ||
| "gpu", # requires 1+ GPU | ||
| "manual", # needs a GPU machine + vLLM venv + model; see README.md | ||
| "no-remote-exec", | ||
| ], | ||
| deps = [":e2e_lib"], | ||
| ) | ||
|
|
||
| py_test( | ||
| name = "test_cross_request_prefix", | ||
| timeout = "eternal", | ||
| srcs = ["test_cross_request_prefix.py"], | ||
| data = [ | ||
| "//kv_cache_manager:kv_cache_manager_bin", | ||
| ], | ||
| imports = ["."], | ||
| tags = [ | ||
| "exclusive", # GPU tests must run serially to avoid CUDA OOM contention | ||
| "gpu", # requires 1+ GPU | ||
| "manual", # needs a GPU machine + vLLM venv + model; see README.md | ||
| "no-remote-exec", | ||
| ], | ||
| deps = [":e2e_lib"], | ||
| ) | ||
|
|
||
| # Meta-test: injects an off-by-one into the connector's token translation and | ||
| # asserts the KV verification FAILS -- proof the harness is not vacuous. | ||
| py_test( | ||
| name = "test_mutation", | ||
| timeout = "eternal", | ||
| srcs = ["test_mutation.py"], | ||
| data = [ | ||
| "//kv_cache_manager:kv_cache_manager_bin", | ||
| ], | ||
| imports = ["."], | ||
| tags = [ | ||
| "exclusive", # GPU tests must run serially to avoid CUDA OOM contention | ||
| "gpu", # requires 1+ GPU | ||
| "manual", # needs a GPU machine + vLLM venv + model; see README.md | ||
| "no-remote-exec", | ||
| ], | ||
| deps = [":e2e_lib"], | ||
| ) | ||
|
|
||
| # Wildcards skip "manual" targets; run the whole suite explicitly with | ||
| # bazelisk test //integration_test/vllm_e2e:e2e_tests | ||
| test_suite( | ||
| name = "e2e_tests", | ||
| tags = ["manual"], # keep wildcard builds from expanding into the GPU tests | ||
| tests = [ | ||
| ":test_basic", | ||
| ":test_concurrent", | ||
| ":test_cross_request_prefix", | ||
| ":test_full_hit", | ||
| ":test_load_failure", | ||
| ":test_multi_turn", | ||
| ":test_mutation", | ||
| ":test_partial_hit", | ||
| ":test_tp", | ||
| ], | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| # vLLM <-> KVCM End-to-End KV Cache Verification | ||
|
|
||
| End-to-end integration tests for the KVCM vLLM connector | ||
| (`kv_cache_manager/py_connector/vllm`). Each test starts a real KVCM manager | ||
| (local-file storage backend) and a real vLLM OpenAI server, drives prompts | ||
| 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.22.1, 0.23.0 and 0.26.0 are e2e-verified; the | ||
| connector detects the KV cache layout of each era from the tensor shape). | ||
|
|
||
| ## What is verified | ||
|
|
||
| The connector translates between three block spaces per `kv_cache_group`: | ||
|
|
||
| ``` | ||
| KVCM manager block idx -> global token idx -> group logical block | ||
| (step 1, connector-only) (step 2/3, shared with vLLM) | ||
| ``` | ||
|
|
||
| A bug in step 1 is *symmetric*: save gathers from the wrong slots and load | ||
| scatters back to the same wrong slots, so a transport round trip alone cannot | ||
| detect it. The test breaks the symmetry with `VerifyingConnector` | ||
| (`test_connector.py`), a subclass of the production connector that | ||
| independently captures KV data from vLLM's paged cache using only vLLM's own | ||
| block-table mapping: | ||
|
|
||
| 1. **Phase 1** — fresh prompts: prefill -> connector saves to KVCM. The saved | ||
| token ranges are captured from the paged cache (**reference** captures). | ||
| 2. **Phase 2** — same prompts + suffix: connector reports an external match and | ||
| loads from KVCM. The loaded blocks are captured (**loaded** captures). | ||
| 3. The driver (`e2e_lib.py`) matches loaded captures against references by | ||
| token content and compares per layer, requiring bit-exact equality (the | ||
| transfer is a verbatim byte round trip; all scenarios achieve it). | ||
|
|
||
| ## Model coverage | ||
|
|
||
| The same test targets run against either model kind, selected by | ||
| `KVCM_E2E_MODEL`: | ||
|
|
||
| | 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 | | ||
|
|
||
| Hybrid specifics verified: | ||
|
|
||
| * Per-group location specs (`tp{rank}_g{group}`) and per-group block tables. | ||
| * Attention groups: token-granular gather/scatter through the Triton kernel. | ||
| * Mamba/linear groups: per-block opaque state copy, where a manager block's | ||
| *last* token selects the state block (`_state_block_ids`). | ||
|
|
||
| ## Scenarios | ||
|
|
||
| | Test | TP | Prompts | Notes | | ||
| |---|---|---|---| | ||
| | `test_basic` | 1 | 1 | Minimal save -> load round trip | | ||
| | `test_concurrent` | 1 | 4 | Concurrent requests: ReqState tracking, per-request block attribution | | ||
| | `test_tp` | 2 | 2 | TP coordination; for full-attention models also `preferred_block_size=32` != vLLM block size (16), forcing real cross-block translation | | ||
| | `test_partial_hit` | 1 | 1 | Phase 2 extends the prompt mid-block: partial external hit | | ||
| | `test_full_hit` | 1 | 1 | Phase 2 resends the identical prompt: full-prompt hit is capped so >= 1 token is recomputed | | ||
| | `test_multi_turn` | 1 | 1 | Growing conversation: each turn loads the previous turns' blocks and saves new ones | | ||
| | `test_cross_request_prefix` | 1 | 2 | Request B is a strict token prefix of saved request A, ending inside one of A's blocks. Hybrid: B's match must be truncated to the last block whose recurrent state was really materialized, and B's output must be token-identical to a no-cache reference | | ||
| | `test_load_failure` | 1 | 1 | Storage files deleted between phases: load fails, retry loop must not spin, request still completes | | ||
| | `test_mutation` | 1 | 1 | Meta-test: injected off-by-one in the slot translation must make verification FAIL (proves the harness is not vacuous) | | ||
|
|
||
| ## Running | ||
|
|
||
| These targets are tagged `manual`: they need a GPU machine with a prepared | ||
| vLLM venv and a local model, so `bazelisk test //integration_test/...` skips | ||
| them and they must be requested explicitly (see below). | ||
|
|
||
| Build prerequisites (from the repo root): | ||
|
|
||
| ```bash | ||
| 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' | ||
| ``` | ||
|
|
||
| Install both wheels into the vLLM venv (rename them first: the Bazel output | ||
| name contains unstamped `{STABLE_*}` template variables; read the real version | ||
| from the wheel's `METADATA`). | ||
|
|
||
| Run (tagged `exclusive`, so they execute serially): | ||
|
|
||
| ```bash | ||
| bazelisk test //integration_test/vllm_e2e:e2e_tests \ | ||
| --cache_test_results=no --test_output=errors \ | ||
| --test_env=KVCM_E2E_PYTHON=/path/to/vllm-venv/bin/python \ | ||
| --test_env=KVCM_E2E_MODEL=/path/to/model \ | ||
| --per_file_copt='external/jsoncpp_git/.*@-Wno-error' | ||
| ``` | ||
|
|
||
| ## Environment variables | ||
|
|
||
| All environment variables used by the e2e harness: | ||
|
|
||
| | Variable | Required | Meaning | | ||
| |---|---|---| | ||
| | `KVCM_E2E_MODEL` | yes | Path to a local HF model directory (`config.json` + weights). Full-attention coverage needs a plain attention model (e.g. Qwen2.5-7B-Instruct); hybrid coverage needs a mamba/linear + attention model (e.g. Qwen3.5-4B). Hybrid models are auto-detected from `config.json`. | | ||
| | `KVCM_E2E_PYTHON` | yes | Python interpreter of a venv with vLLM (any supported version, see above) and both KVCM wheels (`kvcm_py_client`, `kvcm_vllm_connector`) installed. | | ||
| | `KVCM_E2E_CAPTURE_DIR` | internal | Set by the driver for the vLLM subprocess; tells `VerifyingConnector` where to write `.pt` captures. Do not set manually. | | ||
|
|
||
| The driver also sets vLLM knobs for the spawned server (`VLLM_KV_CACHE_LAYOUT=NHD`, | ||
| `VLLM_ATTENTION_BACKEND=FLASH_ATTN`, `VLLM_USE_FLASHINFER_SAMPLER=0`, | ||
| `FLASHINFER_DISABLE_VERSION_CHECK=1`) via `env.setdefault`, so a value you | ||
| export yourself wins. | ||
|
|
||
| ## Debugging | ||
|
|
||
| Bazel's `test.log` only shows the driver's view (e.g. HTTP 500). The real | ||
| tracebacks live in the scenario workdir under `$TEST_TMPDIR`: | ||
|
|
||
| ``` | ||
| <TEST_TMPDIR>/kvcm_vllm_e2e/<scenario>/ | ||
| manager/manager.stdout|stderr # KVCM manager | ||
| vllm/vllm*.stdout|stderr # vLLM (EngineCore tracebacks are here) | ||
| captures/{ref|loaded}_tp{rank}_{token_hash}.pt | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| """Compatibility facade over the split harness modules. | ||
|
|
||
| Pure helpers live in lib_utils.py, process lifecycle (manager binary, | ||
| vLLM server, ScenarioEnv) in servers.py; this module re-exports the | ||
| original surface so existing scenario files keep their imports. | ||
| """ | ||
|
|
||
| from lib_utils import ( # noqa: F401 | ||
| is_hybrid_model, _runfiles_root, find_repo_root, find_manager_binary, | ||
| find_python, free_port, wait_http, tokenize, get_manager_block_size, | ||
| block_token_hash, full_block_hashes, wait_for_prefix_cached, | ||
| send_completions, count_captures, wait_for_captures, compare_captures, | ||
| assert_report_ok, make_base_prompts, shared_token_prefix_len, | ||
| ) | ||
| from servers import ( # noqa: F401 | ||
| ManagerProcess, VllmServer, ScenarioEnv, run_e2e, | ||
| ) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
在后续CI ready时移除
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Kept for now: the manual tags are what keeps these targets out of OSS CI (no GPU runner there). They come off together with the CI-wiring PR that actually provides the runner.