[Feat] Pass encoders' ec_transfer_params to the request that consumes them by theminjunchoi · Pull Request #3197 · vllm-project/aibrix · GitHub
Skip to content

[Feat] Pass encoders' ec_transfer_params to the request that consumes them - #3197

Open
theminjunchoi wants to merge 6 commits into
vllm-project:mainfrom
theminjunchoi:feat/epd-ec-transfer-params
Open

theminjunchoi wants to merge 6 commits into
vllm-project:mainfrom
theminjunchoi:feat/epd-ec-transfer-params

Conversation

@theminjunchoi

Copy link
Copy Markdown
Contributor

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 ECCPUConnector with NIXL. That one only pulls items listed in the request's ec_transfer_params, which the encoder returns, so today the consumer just encodes everything again.

This change merges the ec_transfer_params from 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, so ECExampleConnector setups don't change.

I also added a 1e1p1d-ec-nixl.yaml sample that doesn't need a shared volume, and updated pd-disaggregation.rst (with zh_CN) and pd_readme.md. One thing I ran into: ECCPUConnector in vLLM 0.32.0 won't start without VLLM_USE_V2_MODEL_RUNNER=1, and the encode pod needs VLLM_EC_SIDE_CHANNEL_HOST set 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 -race on pkg/plugins/gateway/algorithms/pd/... and pkg/plugins/gateway, plus make 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 with UCX_TLS=tcp,self). The fields follow vLLM's ec_cpu_connector.md and 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

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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pkg/plugins/gateway/algorithms/pd/encode/encode.go Outdated
Comment thread pkg/plugins/gateway/algorithms/pd/encode/encode.go
@cursor

cursor Bot commented Oct 22, 2026

Copy link
Copy Markdown

@cursor

cursor Bot commented Oct 22, 2026

Copy link
Copy Markdown

Design simplification review

Tip reviewed: d094880ff45a78f026ff6dad63edd65bcd3a2fe8

Verdict

Approve — the design matches the intent without unnecessary layers.

What works

  • Plumbing stays inside the existing encode stage (Execute → merge → setTransferParams → ctx.ReqBody). No new router hooks, CRD fields, or connector abstraction.
  • Gateway owns ec_transfer_params only when the encode stage actually runs; hash-based ECExampleConnector paths stay unchanged when encoders return nothing to inject.
  • Manual merge (first-wins by mm_hash) is justified: sjson path semantics are a poor fit for opaque hash keys, and byte-preserving edits match the PD JSONEditor rationale (avoid map round-trips that reshuffle nested JSON).
  • Delete-all-copies-then-set correctly handles vLLM’s last-wins duplicate-key behavior; that is the right contract for a gateway-owned field (contrast with controlled fields that reject duplicates).
  • Docs/sample clearly defer Mooncake (consumer-before-encode) to a follow-up — good scope cut.

Simpler alternatives considered

Idea Why not required now
Reuse pd.JSONEditor for set/delete Helps little until there is a DeleteAll; current loop is local and clear
Only set when params != nil (skip strip) Would leave a client duplicate that vLLM could prefer over encoder handles
Parse into map[string]json.RawMessage then marshal Slightly clearer, but re-encodes values and is not needed at this size

Optional clarifications (non-blocking)

  1. Contract wording: After encode runs, client ec_transfer_params are always stripped, even when encoders return none (covered by the unit test). RST/PR text leans toward “when responses carry params…” — a one-line “always cleared after encode” would remove ambiguity.
  2. Sample vs other EPD samples: 1e1p1d-ec-nixl.yaml omits --mm-encoder-only (other encode samples include it). Gateway max_tokens=1 covers generation; call out whether omitting the flag is intentional for ECCPUConnector + V2 runner.
  3. Gemini infinite-loop note: A bounded delete loop is a cheap defensive nit; with current gjson/sjson behavior on valid/duplicate JSON, delete progresses or Exists becomes false. Not a design flaw.

Design verdict

Approve — focused, contract-aligned, no over-engineering.

@varungup90 varungup90 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. setTransferParams is quadratic in the number of duplicate ec_transfer_params keys. The loop for 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_bytes in 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_params pair with ForEach and 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.

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 setTransferParams errors

    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.

Nits (non-blocking)

  1. The gateway owns the field only when the stage runs. EncodeMedia returns early for non-chat paths, for requests without media and for rolesets without encoders, and then any client ec_transfer_params goes through to the prefill or serving pod untouched. It also stays in place if setTransferParams fails (logged only). With ECCPUConnector and 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.
  2. 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 as success with the params silently dropped, so the consumer re-encodes without any signal. A distinct status for the invalid case would help.
  3. 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.
  4. 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 .po msgids match the RST.
  5. Sample. 1e1p1d-ec-nixl.yaml parses as a single StormService. Compared with 1e1p1d.yaml it drops --mm-encoder-only on the encode pod and moves to v0.32.0; confirm both are intended for ECCPUConnector with 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/: passes
    • gofmt: clean
    • go vet on the encode and router packages: clean
    • golangci-lint --new-from-rev on pkg/plugins/gateway/...: 0 issues
    • the mutants, the input and timing probes, and the sample and .po checks above (temporary, not committed)
  • Did not run: a real vLLM, NIXL or GPU setup, or repo-wide make test or 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>
@theminjunchoi

Copy link
Copy Markdown
Contributor Author

@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 ValidateRequest only runs on the PD path, so rejecting there wouldn't cover E+PD.

615e39e counts a 200 with a non-JSON body as invalid_response and logs it like a failed encode. It also turns the test into a table: a repeated mm_hash, a failed encoder, invalid and oversized responses, and a request body that can't be rewritten. I reran your five mutants against it and all of them fail now. 63e499e fixes the docs wording.

On the other points:

  • Stripping the field when the stage doesn't run: I'd rather do that in a follow-up, since it would also change requests in deployments that don't use encoders.
  • The 1 MiB cap: an encode response with max_tokens: 1 is around 1 KB, so I kept the cap as a guard.
  • Decode leg: in vLLM 0.32.0, an engine without an EC connector logs a warning and ignores the field (v1/engine/core.py). vLLM's own disagg_epd_proxy.py also sends it to decode.
  • Sample: both changes are intentional. Since 0.32.0 an ec_producer switches to encoder-only mode by itself (_resolve_mm_encoder_only), so --mm-encoder-only isn't needed, and ECCPUConnector needs 0.32.0.

@cursor

cursor Bot commented Oct 24, 2026

Copy link
Copy Markdown

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants