Repository navigation
[Feat] Pass encoders' ec_transfer_params to the request that consumes them - #3197
theminjunchoi wants to merge 6 commits into
Conversation
The encode stage drained the encode response, so EC connectors that pull the encoder output from the encoder (ECCPUConnector with NIXL) never got the handles they need and the consumer encoded every item again. Merge the ec_transfer_params of the encode responses and set them on the request, replacing any copy the client sent. Signed-off-by: MinJun Choi <mj04300017@gmail.com>
Two encoders return params for different items; the client repeats the field. Only the merged params remain, and nothing remains when the encoders return none. Signed-off-by: MinJun Choi <mj04300017@gmail.com>
Add a 1E-1P-1D sample that needs no shared storage, and note the vLLM version, VLLM_USE_V2_MODEL_RUNNER and the side-channel host it needs. Signed-off-by: MinJun Choi <mj04300017@gmail.com>
There was a problem hiding this comment.
Code Review
This pull request introduces support for passing ec_transfer_params from encode pods to prefill pods in the gateway's PD disaggregation routing, enabling the use of ECCPUConnector with NIXL to pull encoder outputs without requiring shared storage. The changes include updates to the encoding executor to merge and inject these transfer parameters into the prefill request body, updated documentation, and a new sample configuration. The review feedback highlights two key improvements: first, a security concern regarding a potential infinite loop in setTransferParams that could lead to CPU exhaustion, and second, an observability improvement to return an error instead of silently ignoring invalid JSON responses from the encoder.
Design simplification reviewTip reviewed: VerdictApprove — the design matches the intent without unnecessary layers. What works
Simpler alternatives considered
Optional clarifications (non-blocking)
Design verdictApprove — focused, contract-aligned, no over-engineering. |
varungup90
left a comment
There was a problem hiding this comment.
Review of #3197 at d094880f
Verdict: not as it stands; approve once item 1 is fixed. The design is right (the plumbing stays inside the encode stage, and the merge, strip and set logic is small and clear). But the new delete loop has quadratic cost on a client-controlled body, which I'd fix before merge. The rest are test and docs nits.
Blocker
-
setTransferParamsis quadratic in the number of duplicateec_transfer_paramskeys. The loopfor gjson.GetBytes(body, "ec_transfer_params").Exists() { body = sjson.DeleteBytes(...) }rescans and rewrites the whole body once per copy. I timed it on valid JSON with N duplicate keys:duplicate keys body size time 1,000 28 KB 11 ms 5,000 141 KB 119 ms 20,000 566 KB 1.7 s 50,000 1.4 MB 11.4 s A client who can send a multimodal chat request to a PD model with an encode pod can pin a core for seconds per request, and the cost grows with the square of the body size. The repo has no body-size cap that I could find to bound it (I only saw Envoy's 4 MiB
per_connection_buffer_limit_bytesin the sidecar chart). The Cursor bot called Gemini's DoS note overstated; the infinite loop isn't real (my odd inputs, including an escaped key, a truncated value and trailing garbage, all terminated), but the quadratic cost is. Two suggestions:- Remove all copies in a single pass (collect the byte ranges of every top-level
ec_transfer_paramspair withForEachand rebuild once). - Or treat it like the other gateway-owned fields and reject duplicates up front with a 400.
Gemini's suggested iteration bound would be wrong here: after 10 deletions the 11th and later copies would remain, and vLLM takes the last one, so a client could still control the field.
- Remove all copies in a single pass (collect the byte ranges of every top-level
Mutation check on the PR's tests
Eight mutants, each applied once and then reverted.
-
Killed: keeping the client's copy, unquoted keys in the merge, and not assigning the new body back to
ctx.ReqBody. -
Survived:
- first-wins on a repeated
mm_hash(flipping to last-wins) - dropping the successful encoders' params when another encoder fails (the "partial success still injects" behavior the Cursor review lists as optional)
- the invalid-JSON guard (removing it)
- the 1 MiB response cap (raising it)
- ignoring
setTransferParamserrors
The coverage is one test with two subtests. A table of cases (two encoders with an overlapping hash, one failing encoder, an invalid 200 body, an over-cap body) would pin these.
- first-wins on a repeated
Nits (non-blocking)
- The gateway owns the field only when the stage runs.
EncodeMediareturns early for non-chat paths, for requests without media and for rolesets without encoders, and then any clientec_transfer_paramsgoes through to the prefill or serving pod untouched. It also stays in place ifsetTransferParamsfails (logged only). WithECCPUConnectorand NIXL the field tells the consumer where to pull embeddings from. Stripping it unconditionally for vLLM PD and E+PD requests would close that. This is a pre-existing exposure; I read it from the code and did not exercise it. - Response handling. An encoder response over 1 MiB is now an error (
read_error) where it used to be drained and counted as a success. And a 200 with an invalid body is emitted assuccesswith the params silently dropped, so the consumer re-encodes without any signal. A distinct status for the invalid case would help. - The decode leg also carries the field (the body is shared). I couldn't check vLLM's behavior with and without an EC connector on the decode pod. The author or a vLLM reviewer should confirm it's ignored.
- Docs. The RST and PR text say the gateway replaces the client's params "when an encode response carries" them. In fact the client's copy is always removed once the stage runs (the "encoders return none" case). One sentence would remove the ambiguity. The
.pomsgids match the RST. - Sample.
1e1p1d-ec-nixl.yamlparses as a single StormService. Compared with1e1p1d.yamlit drops--mm-encoder-onlyon the encode pod and moves tov0.32.0; confirm both are intended forECCPUConnectorwith the V2 runner. As the author says, it's untested on a GPU, so I can't confirm the NIXL flow itself.
What I ran / did not run
- Ran:
- merge-tree against current
main(1c7788d5): clean go test -race -count=1 ./pkg/plugins/gateway/algorithms/pd/... ./pkg/plugins/gateway/: passesgofmt: cleango veton theencodeandrouterpackages: cleangolangci-lint --new-from-revonpkg/plugins/gateway/...: 0 issues- the mutants, the input and timing probes, and the sample and
.pochecks above (temporary, not committed)
- merge-tree against current
- Did not run: a real vLLM, NIXL or GPU setup, or repo-wide
make testor e2e. The GitHub checks on this head are green: Go Lint and Unit Tests, Go Integration Tests, Installation E2E, Installation Smoke / Helm, Sphinx HTML, DCO and triage.
Generated by Claude Code
The delete loop rescanned and rewrote the body once per copy of the field, so a body with many copies cost time quadratic in its size (about 3s for 50,000 copies). Rebuild the top level once without them instead. Signed-off-by: MinJun Choi <mj04300017@gmail.com>
A 200 with a body that is not JSON was counted as a success and its params silently dropped. Count it as invalid_response and log it like a failed encode. The table test now also covers a repeated mm_hash, a failed encoder, an invalid or oversized response, and a request body that can't be rewritten. Signed-off-by: MinJun Choi <mj04300017@gmail.com>
Once the encode stage runs, the client's copy is removed even when the encoders return no params. Signed-off-by: MinJun Choi <mj04300017@gmail.com>
|
@varungup90 Thanks for the review, and for timing the delete loop. That one was a real problem. I pushed three commits. 231bc0f removes the client's copies in a single pass now. On the same kind of input you used, 50,000 copies went from 2.86s to about 2ms, and a body without the field passes through untouched. I went with this instead of a 400 because 615e39e counts a 200 with a non-JSON body as On the other points:
|

Pull Request Description
Follow-up to #3069 and #3153. Right now the encode stage throws away the encode response, which is fine for connectors that look the output up by hash on shared storage, but not for
ECCPUConnectorwith NIXL. That one only pulls items listed in the request'sec_transfer_params, which the encoder returns, so today the consumer just encodes everything again.This change merges the
ec_transfer_paramsfrom the encode responses and sets them on the body sent to the prefill pod, or to the serving pod in E+PD. It only does this when the encode stage actually ran, and it drops any copy the client sent first, including a repeated key, since vLLM keeps the last one. If the encoders return nothing, the body is left as is, soECExampleConnectorsetups don't change.I also added a
1e1p1d-ec-nixl.yamlsample that doesn't need a shared volume, and updatedpd-disaggregation.rst(with zh_CN) andpd_readme.md. One thing I ran into:ECCPUConnectorin vLLM 0.32.0 won't start withoutVLLM_USE_V2_MODEL_RUNNER=1, and the encode pod needsVLLM_EC_SIDE_CHANNEL_HOSTset to its pod IP, so both are in the sample and docs. Mooncake EC, where the consumer has to be chosen before encoding, can come in a separate PR.For testing, I added a unit test and ran
go test -raceonpkg/plugins/gateway/algorithms/pd/...andpkg/plugins/gateway, plusmake html-all. I couldn't try it end to end on a GPU, since NIXL's UCX backend wouldn't start in the cloud sandbox I had (Destination is unreachable, even withUCX_TLS=tcp,self). The fields follow vLLM'sec_cpu_connector.mdand its chat completion protocol, but if anyone can run the sample on a real cluster, that would be a big help.Related Issues
Part of #2962