Skip to content

[KV Connector][3/N] KV checksums for NIXL push - #61580

Closed
sawsa307 wants to merge 7 commits into
vllm-project:mainfrom
sawsa307:kv-checksum-nixl-push
Closed

sawsa307 wants to merge 7 commits into
vllm-project:mainfrom
sawsa307:kv-checksum-nixl-push

Conversation

@sawsa307

Copy link
Copy Markdown
Contributor

Overview

Part 3 of the KV checksum series: KV checksums for NIXL push. In push mode, the consumer's kv_transfer_params are built before the producer finishes, so the checksums cannot travel in them. This PR adds a second, connector-owned carrier and uses it to send the checksums on the completion notif of the WRITE that delivers the KV.

Depends on #61579 (2/N), which depends on #61578 (1/N). Only the last two commits are new here, starting at "KV checksums: connector-owned carrier". The full series and its validation are in #61488.

Not a duplicate: this extends the series; no other open PR checksums P/D transfers.

Claims

  • Connector-owned carrier. A new generic carrier, KVChecksumCarrier.CONNECTOR, lets a connector move the checksums in its own messages.
    • The producer's scheduler hands them to KVConnectorBase_V1.send_kv_checksums.
    • The consumer's worker connector returns them in KVConnectorTransferResults.kv_checksums by the time it reports the load finished.
    • MultiConnector delegates both.
    • Mooncake P2P (4/N) uses it too.
  • NIXL push verifies loads end to end.
    • The checksums ride on the WRITE's completion notif, which NIXL delivers after the WRITE's data, so the consumer never waits for them.
    • NIXL_CONNECTOR_VERSION goes from 14 to 15 for the new notif form. Without checksums, the notif keeps its old form.
  • Cleanup: removes the unreachable producer-side branch of the push worker's notif counting.

Validation

Unit tests (CPU):

pytest tests/v1/kv_connector/unit/test_kv_checksum.py \
  tests/v1/kv_connector/unit/test_kv_checksum_scheduler.py \
  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_heartbeat.py \
  tests/v1/kv_connector/unit/test_multi_connector.py \
  tests/v1/kv_connector/unit/test_output_aggregator.py \
  tests/v1/core/test_scheduler.py tests/v1/core/test_async_scheduler.py

673 passed, 1 skipped, 3 failed. Three tests fail for reasons unrelated to this change, and fail the same way on upstream main a2fb482732: test_abort_timeout_on_prefiller[ray] and [None] need Ray and the compiled CPU ops, and test_multi_example_connector_consistency needs a gated model. pre-commit (ruff, mypy) passes on every commit.

  • Scheduler-level end-to-end cases run for a test connector that uses the connector-owned carrier and for NIXL push: layout independence, correct loads across TP/PP pairs and block sizes, and corrupted, missing or malformed checksums.
  • test_nixl_push_connector.py builds a completion notif on a producer worker and parses it on a consumer worker, with and without checksums.
  • Mutation checks: each of these edits makes tests fail:
    • the scheduler or the worker drops received checksums;
    • send_kv_checksums is never called;
    • the producer worker sends no checksums;
    • the consumer worker drops notif checksums.

End to end on GPU (the full series in #61488 at ef3d386f31):

  • Setup: Qwen3-0.6B, NIXL push, with a test-only fault-injecting connector that is not part of this PR. Three layouts: TP1 -> TP1, producer TP2 -> consumer TP1, and producer TP1 -> consumer TP2.
  • Results, in every layout:
    • no false mismatch, and every injected corruption detected;
    • fail-closed rejects the load, and recompute completes the request;
    • no host sync under VLLM_GPU_SYNC_CHECK=error.
  • Cleanup check: with a tripwire in the removed branch, push-mode tests and GPU runs never reached it.

Before marking this ready, I will add a GPU run of this PR's own tree.

Details

Two commits:

  1. Connector-owned carrier (generic). Received checksums reach the scheduler in each worker's checksum record, on the existing per-worker output channel. The scheduler keeps the first copy for a pending load.
  2. NIXL push.
    • Producer scheduler: ships the checksums with the finished blocks in the next step's connector metadata.
    • Producer worker: hands them to the writer thread in the same queue item as the blocks, so they add no latency to the WRITE. Every producer rank that writes sends the same bytes, and the consumer keeps the first.
    • Cleanup: _get_new_notifs now counts only consumer-side completion notifs. A request that both loads and saves on a kv_both instance now has its completion notif counted as a load.

Limitations:

  • Expired lease during a deferred handshake: if a producer's lease expires while its WRITE waits for the handshake, the late WRITE goes out without checksums. The consumer then reports the load unverified, not mismatched.
  • Notif size: the notif grows by 8 bytes per block and group, about 64 KB for a 128k-token prompt with 16-token blocks.
  • Prefix hit inside a producer block: push allows larger producer blocks, and a consumer prefix hit that ends inside a producer block is unverified (see 2/N). The open upstream [Bugfix][NIXL] Reject push handshakes between different P/D block sizes #59747 proposes rejecting different block sizes in push at handshake.
  • No MLA run: no MLA model was run in push mode.

Pull Request Checklist
  • I used vLLM's /pr-checklist skill. (Mandatory for agents, optional for humans).

  • AI assistance was used during the creation of this PR.

  • Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.

  • Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI. (GPU run of this PR's own tree pending; see Validation.)

  • Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.

  • Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.

AI assistance (Claude) was used to write and review this change. I reviewed every changed line and ran the validation above.

🤖 Generated with Claude Code

First of a series adding connector-agnostic checksums of transferred KV
for P/D disaggregation. Some transfer failures are not reported by the
transport: corrupted bytes, a read after the producer has already
reused its blocks, or blocks placed at the wrong position. With
kv_transfer_config.enable_kv_checksum on both sides, the producer
checksums a request's KV in the step that finishes its prefill, the
checksums travel with the request in kv_transfer_params, and the
consumer verifies every load against them before the request runs.
kv_checksum_fail_closed turns a failed check into a KV load failure,
handled by kv_load_failure_policy.

No connector declares a carrier in this commit, so enabling KV
checksums alone has no effect yet; the next commit makes NIXL pull the
first connector that carries them. Later in this series: KV heads
replicated across TP, different producer and consumer block sizes,
NIXL push and its connector-owned carrier, Mooncake P2P, Mamba state,
and a Triton kernel.

Design
------
- Connector-agnostic. Workers compute the checksums from the KV cache
  itself, the scheduler sums and compares them, and a connector only
  declares how it carries the bytes (kv_checksum_carrier). Computing
  them inside each connector was rejected: every connector would
  reimplement it, and the values would depend on the connector's view
  of TP and cache layout.
- One value per (KV cache group, block position), independent of TP,
  PP and memory layout. The scheduler, the only place that sees every
  worker's record, sums the TP and PP parts on both sides, so the
  consumer needs no knowledge of the producer's layout.
- A failed check is a KV load failure. It takes the path
  connector-reported failures take (_handle_failed_recving), so
  kv_load_failure_policy decides what happens next and no new failure
  handling is added.
- No host sync on the engine step: results reach host memory on a side
  copy stream and an event.
- The producer checksums the whole prompt once, in the step that
  finishes it, with or without chunked prefill. Per-chunk partial sums
  were rejected: they would need per-rank state in the scheduler,
  cleanup on preemption, and handling of the block spanning two chunks.
  The cost is that all of the prompt's checksum work lands in its last
  prefill step.

What a checksum is
------------------
A checksum covers one block position of one KV cache group, over the
unsharded model: all layers of the group and all KV heads.

1. A slice is the KV of one layer and one KV head in the block: the K
   and V of the block's tokens for that head. Laid out token by token
   and split into little-endian 8-byte pieces, its checksum is

       s = sum_j x_j * (2 * j + 1)  mod 2**64

   where x_j is piece p of the token at position t in the request,
   j = t * P + p with P pieces per token.
2. The block's checksum adds up the slices of every layer and head,
   each times its own odd factor:

       c = sum_{layer, head} m * s  mod 2**64
       m = 2 * (layer * H + head) + 1

   H = 2**16 (MAX_KV_HEADS_PER_LAYER), a fixed bound on any layer's KV
   heads. Layers are numbered by the layer number in their names and
   heads globally across TP, so m does not depend on the model's head
   counts or its TP or PP split.

Why this definition:
- Factors and weights are odd, hence invertible mod 2**64: a change to
  any single piece always changes c.
- Distinct weights catch tokens in the wrong place; distinct factors
  catch swapped heads or layers. Random corruption of several pieces
  goes undetected with probability about 2**-64. It is not a
  cryptographic hash and does not defend against deliberate tampering.
- Numbering pieces from the request's first token makes the checksums
  of consecutive blocks add up to that of the tokens they hold, so
  different block sizes can later be compared without changing the
  definition.
- TP splits KV heads and PP splits layers, so every slice lies whole on
  one worker, and the workers' parts sum to c whatever the memory
  layout (head-major, token-major, kernel-block splits).

The last block of a request only covers its first num_tokens slots,
which excludes slots that later decode or draft tokens may write.

Life of a request
-----------------
Producer:
1. In the step whose scheduled tokens cross num_prompt_tokens,
   Scheduler._update_after_schedule calls KVChecksumScheduler.add_send
   for requests the connector carries checksums for. build_checksum_meta
   puts their blocks, per transfer group, into
   SchedulerOutput.kv_checksum_scheduled.reqs_to_send.
2. KVChecksumWorker.post_forward checksums them after the forward has
   written their KV. Rows for all groups are built with numpy and copied
   to the device in one non-blocking H2D copy from pinned memory. The
   worker's own copy stream then copies the sums to pinned host memory
   and records a blocking event; KVChecksumOutput.finalize, called when
   the output is pickled for the scheduler, only waits for that event.
   The results ride KVConnectorOutput.kv_checksums, which
   KVOutputAggregator merges across TP and PP workers.
3. Scheduler._free_request calls request_finished after the connector's
   request_finished. For a request that finished normally it sums the
   workers' parts and adds them to the connector's kv_transfer_params as
   "kv_checksums". The proxy forwards them to the consumer with the
   request.

Consumer:
1. When a request starts an async load, Scheduler.schedule calls
   add_recv. It decodes the expected checksums from the request's
   kv_transfer_params and puts the blocks the load writes, tokens
   [num_local_computed_tokens, num_computed_tokens), into reqs_to_recv
   in the same step, so workers know them before the load starts.
2. Workers checksum a load in the step whose transfer results report it
   finished, before any later forward can write its blocks; a request
   whose load failed or that ended is dropped. update_from_output keeps
   each worker's record until the aggregated finished_recving reports
   the request.
3. It then compares, after the connector's own failed_recving and
   invalid_block_ids are handled (loads already known to be bad are not
   compared), and before the request can be scheduled again.

On the v1 runner with a drafter that writes KV after the connector
step (defer_finalize), post_forward leaves the sends to finalize_sends,
which finalize_kv_connector calls after the drafter, on the last PP
rank only.

Which blocks
------------
- Producer: per transfer group, the blocks holding tokens
  [0, num_computed_tokens). Consumer: the blocks the load writes.
- Both: leading null blocks (out of a sliding window) are left out, a
  group with a null block after a non-null one is left out entirely,
  and sliding-window groups keep their last cdiv(window, block_size) + 1
  blocks, the clip NIXL and Mooncake apply to what they transfer. A
  connector that carries checksums must load exactly these blocks.
- Only positions both sides checksummed are compared, and the last
  block only if both sides hold the same number of tokens in it.

Verdicts
--------
Each group is compared on its own:
- Mismatch: some compared position differs. Logged as an error with
  the groups and block positions, even if other groups are unverified.
- Unverified: the group cannot be compared: it is checksummed by one
  side only, its fingerprint differs (other layers or dtype), its block
  sizes differ, a worker checksummed other blocks than scheduled, or no
  position is in common.
A load is also unverified as a whole when not every worker (TP * PP)
reported it, or the producer sent no (valid) checksums. Unverified
loads log a warning once per fixed reason, and the details per request
at debug level.

Each worker record carries a fingerprint per group, the sum of a hash
of each of its layers in it (name, spec type, dtype). Summed over PP
stages it identifies the group's layers whatever the PP layout; the
scheduler's own KVCacheConfig only lists the first worker's layers.

Groups a worker cannot checksum are logged at startup and reported in
the record's skipped_groups: layers without per-token KV blocks (Mamba
state), compressed caches (tokens_per_state != 1), KV heads packed into
head slots, host-resident groups, caches that are not whole 8-byte
pieces or whose kernel blocks do not tile the block size, KV heads
replicated across TP, two caches with one layer number, and layer names
without one. A group skipped on both sides is left out of the verdict,
so hybrid models verify their attention groups under fail-closed; a
group skipped on one side only is unverified.

Failure semantics
-----------------
- Fail-open (default): mismatched and unverified loads are only logged;
  the loaded blocks are then used and prefix-cached as usual. This lets
  checksums be rolled out, and observed, without failing requests.
- Fail-closed: mismatched and unverified loads fail through
  _handle_failed_recving: kv_load_failure_policy "fail" ends the request
  with an error, "recompute" sets num_computed_tokens to 0 and
  recomputes the whole prompt locally on the consumer.
- The KV is never transferred again: connectors clear do_remote_prefill
  on the first load. Recovery is per request, not per block.
  Recomputing only from the first bad block is future work, building on
  group-aware load-failure recovery (RFC vllm-project#47812, PR vllm-project#48216).

Carrier
-------
KVConnectorBase_V1.kv_checksum_carrier(request, receiving) returns how
the connector carries a request's checksums, or None (the default) if
it does not, e.g. offloading connectors. This commit defines
KVChecksumCarrier.KV_TRANSFER_PARAMS: generic code adds and reads the
"kv_checksums" key, and the connector needs no other change.
- MultiConnector: for a load, the sub-connector chosen to load it; for
  a producer request, the first sub-connector with a carrier.
- A connector without a carrier can verify no load: enabling KV
  checksums with it fails at startup when fail-closed and disables them
  with a warning when fail-open.

Wire format: base64 of msgpack (version, tp_size, num_tokens,
[(group_id, fingerprint, block_size, start, little-endian uint64
values)]). A producer whose workers did not all report a request (or
reported other blocks) sends no checksums for it, with a warning. For a
128k-token prompt with one group and 16-token blocks this is about
87 KB of base64. Proxies must forward unknown kv_transfer_params keys.

kv_checksum_enabled disables KV checksums, with a warning, for context
parallelism (DCP/PCP > 1), and for the Ray executor with PP > 1 unless
VLLM_USE_RAY_V2_EXECUTOR_BACKEND=1: its non-last stages' connector
outputs never reach the scheduler.

Rollout and limits
------------------
- Enable on producers before consumers; otherwise consumers see loads
  without checksums, unverified (failed when fail-closed).
- A producer request preempted in its final prefill step and
  re-prefilled before that step's output arrives can mix the two steps'
  values; the consumer then reports a mismatch or unverified, never a
  false match.
- In this commit, replicated KV heads, several caches per layer and
  Mamba state are skipped, different block sizes are unverified, and
  the PyTorch path reads the gathered KV several times per layer; later
  commits in the series address each.

Tests
-----
- test_kv_checksum.py checks every worker result against an independent
  NumPy implementation of the definition: head-major and token-major
  layouts and kernel-block splits; TP2 shards and PP2 stages summing to
  the whole model's value; swapped tokens and layers changing it; slots
  past num_tokens ignored; 16-token blocks adding up to 32-token blocks;
  KV-sharing counted once; chunked gathers; every skipped group kind
  next to a checksummed group; the consumer lifecycle (finish, failure,
  abort); deferred sends covering KV written before finalize_sends;
  fingerprints independent of the PP split; serialization and
  aggregation across workers.
- test_kv_checksum_scheduler.py runs a producer's and a consumer's real
  schedulers with real checksum workers, copying the producer's blocks
  into the consumer's, with a minimal test connector that only declares
  the kv_transfer_params carrier:
  - producers of TP2, PP2 and TP2xPP2 send what a single worker sends;
    requests the connector does not carry get no checksums; nothing is
    sent unless every worker reported;
  - correct loads verify for (TP, PP) pairs (1, 1)/(1, 1), (2, 1)/(1, 1),
    (1, 1)/(2, 1), (1, 2)/(1, 1) and (1, 1)/(2, 2);
  - with a local prefix hit only the loaded blocks are compared;
  - a corrupted byte (logged as a mismatch), missing and malformed
    checksums: only logged when fail-open, an error with policy "fail",
    a local recompute with "recompute";
  - different block sizes are unverified;
  - a group skipped on both sides is ignored, on one side only is
    unverified;
  - a connector without a carrier is refused at startup when
    fail-closed and disables checksums when fail-open.
  Disabling the comparison,
  ignoring the received checksums, summing fingerprints per worker
  instead of per PP stage, numbering heads per rank, or checksumming
  loads from the first block each make tests fail.
- test_scheduler.py and test_async_scheduler.py set the new scheduler
  attribute on their hand-built schedulers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: David Cheung <d7cheung@gmail.com>
Make NIXL pull the first connector that carries KV checksums, in the
kv_transfer_params the previous commit added them to: a producer's
request_finished already returns kv_transfer_params that the proxy
forwards to the consumer, so NixlPullConnector only declares the
carrier. With kv_transfer_config.enable_kv_checksum on both sides, NIXL
pull loads are now verified end to end.

NixlPullConnector.kv_checksum_carrier returns
KVChecksumCarrier.KV_TRANSFER_PARAMS for producer requests
(do_remote_decode) and for regular loads. A bidirectional load (a load
with do_remote_decode set, a prefiller reloading a decoder's KV from an
earlier turn) reads blocks whose source sent no checksums, so it
returns None: such loads are neither verified nor counted as
unverified.

Tests
-----
test_kv_checksum_scheduler.py runs the end-to-end scheduler tests that
do not depend on the connector for NIXL pull as well as for the test
connector: correct loads verify for every (TP, PP) pair, and a
corrupted, missing or malformed load follows kv_checksum_fail_closed
and kv_load_failure_policy. Making NixlPullConnector declare no carrier
makes the NIXL pull cases fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: David Cheung <d7cheung@gmail.com>
Checksum groups whose KV heads several TP ranks hold, which the
previous commit skipped: MLA-type caches, GQA with more TP ranks than
KV heads, and layers with several caches, such as an attention's KV
and its indexer's.

Design
------
When several TP ranks hold the same KV heads, those copies must not be
summed twice, and every copy should still be checked. A partial
checksum is therefore keyed by the (replicas, replica_index) of its
layers (ReplicaKey):
- A producer only checksums replica 0: any replica describes the same
  KV. Its other replicas report the request with no checksums, so the
  scheduler still counts them.
- A consumer checksums every replica. KVChecksumGroup.values gets one
  column per replica key, and _sum_replicas builds one check per
  replica: check k sums copy k % R of each layer replicated on R ranks,
  so every check covers each layer once and every copy is in some
  check. Each check must equal the producer's value, so a corrupted
  copy is a mismatch even if another copy is intact.
- Checking every copy was preferred over checking one replica because
  each rank's copy is written by its own transfer.

How many ranks replicate a layer (_tp_replicas):
- Specs that declare max_tp_shards give it through layer_tp_replicas.
- MLA-type caches (MLAAttentionSpec, SlidingWindowMLASpec) that do not
  declare it are held whole by every rank, as NIXL transfers them.
- Other attention shards KV heads across TP and replicates one head on
  consecutive ranks once TP exceeds them. More than one head per rank
  means no replicas; one head per rank is resolved with the model's KV
  head count when every layer shares it (no per-layer override, no
  separate global head count, and a draft model with the same count).
  Otherwise the group is still skipped.

Several caches per layer
------------------------
Distinct caches with one layer number of the same layer module (e.g.
model.layers.0.self_attn.attn and model.layers.0.self_attn.indexer
.k_cache) now take consecutive heads of that layer, in layer name
order, so both are covered. Caches with one layer number from different
modules (e.g. a main layer and an MTP layer, which PP may place on
different stages) are still skipped.

MiniMax-M3
----------
MiniMaxM3IndexerCache declares max_tp_shards=1: its index_k is one head
replicated to every TP rank. Its spec is an MLAAttentionSpec, so it is
treated as replicated either way; the declaration states it for any
other code that reads max_tp_shards.

Tests
-----
- test_kv_checksum.py: GQA replicas (TP4, 2 heads) and MLA replicas,
  declared or by spec type, are summed once by the producer and
  reported per replica by the consumer; an attention cache and an
  indexer cache of one layer take consecutive heads; the MiniMax-M3
  layout (2 attention heads sharded at TP2, replicated at TP4, plus a
  replicated indexer) sums to the definition on the producer, and every
  consumer replica check equals it; layers of different modules sharing
  a layer number are skipped.
- test_kv_checksum_scheduler.py: a TP1 producer with a TP4 consumer and
  a TP4 producer with a TP1 consumer (2 KV heads, so replicated) verify
  end to end, for NIXL pull and the kv_transfer_params test connector.
Summing every replica on the producer, checking only the first replica
on the consumer, not offsetting the second cache of a layer, or not
treating MLA-type caches as replicated each make tests fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: David Cheung <d7cheung@gmail.com>
NIXL transfers KV between producers and consumers with different block
sizes (block_size_ratio), but so far the consumer reported such loads
unverified, which fails them all under kv_checksum_fail_closed. The
checksum definition already allows comparing them: pieces are numbered
from the request's first token, so the checksums of consecutive blocks
add up to that of the tokens they hold, whatever the block size. This
commit only changes the comparison; checksums and their wire format are
unchanged (the payload already carries each group's block size).

Comparison
----------
The consumer's scheduler does the regrouping. With different block
sizes, it sums the smaller blocks' checksums into chunks of the larger
block size on each side (_chunk_sums) and compares chunks; a chunk is
compared only where both sides cover it whole and hold the same number
of tokens in it, blocks past the last token counting as zero. With
equal block sizes a chunk is a block, as before. Mismatches are
reported as the consumer's block positions, so with different block
sizes a mismatch is localized to one larger block.

Unverified:
- block sizes that are not multiples of each other;
- a consumer with smaller blocks whose first loaded block starts inside
  a producer block (a local prefix hit or window clip not aligned to
  the producer's blocks). That block's chunk also holds tokens the
  consumer did not load, so it cannot be compared. Skipping the chunk
  instead would leave the first loaded block unchecked while reporting
  the load verified. Adding the consumer's locally cached tokens to the
  chunk would report false mismatches: KV a consumer computed while
  decoding is in general not bit-identical to the producer's prefill of
  the same tokens.

Known limitations
-----------------
- The unaligned case above is unverified, so it fails under
  fail-closed and is only logged under fail-open. NIXL pull requires
  the consumer's block size to be a multiple of the producer's, so it
  arises only with larger producer blocks, as NIXL push and Mooncake
  P2P (later in this series) allow. The open upstream vllm-project#59747 proposes
  rejecting different block sizes in NIXL push at handshake.
  Checksumming producer blocks
  at a finer granularity would make the case comparable; it is not
  done here.

Tests
-----
- test_kv_checksum_scheduler.py: producer and consumer block sizes 16/32
  and 32/16, for NIXL pull and the test connector: a correct load
  verifies and a corrupted byte fails it under fail-closed. A consumer
  with 16-token blocks after a 16-token local prefix hit, loading from a
  producer with 32-token blocks, is unverified. The previous test that
  different block sizes are unverified is replaced.
- Comparing unsummed blocks makes the 16/32 cases fail; without the
  alignment check the unaligned load verifies without its first loaded
  block. test_kv_checksum.py (from the first commit of the series)
  already checks that two 16-token blocks add up to the 32-token block
  with the same tokens.

Validation
----------
On GPU, NIXL pull with producer block size 16 and consumer block size
32, on an earlier base, reported a mismatch for 4 of 4 requests in a
run without injected faults: NIXL on that base corrupted this transfer
(fixed upstream by vllm-project#58860). With recompute, the consumer's outputs
matched the reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: David Cheung <d7cheung@gmail.com>
Add the second way a connector can carry KV checksums, for connectors
whose consumer gets nothing back through kv_transfer_params once the
producer has finished, such as NIXL push and Mooncake P2P (next
commits). KVChecksumCarrier.CONNECTOR: the connector moves the
checksums in its own messages. The generic code and the connector
exchange only bytes; the comparison is unchanged.

Carrier API
-----------
- Producer: after request_finished, KVChecksumScheduler hands the
  summed checksums (encoded KVChecksumPayload bytes) to the new
  KVConnectorBase_V1.send_kv_checksums(request, checksums), instead of
  adding them to kv_transfer_params.
- Consumer: the worker connector returns them in the new
  KVConnectorTransferResults.kv_checksums, keyed by request, by the
  time it reports the load in finished_recving. Both model runners pass
  them to KVChecksumWorker.post_forward, which adds them to its record
  (KVChecksumRecord.received), so they reach the scheduler on the
  existing per-worker channel. The scheduler takes the first copy for a
  pending load, before it compares the step's finished loads: every
  worker of the consumer may receive the same bytes.
- MultiConnector forwards send_kv_checksums to the first sub-connector
  that carries the request's checksums, and merges kv_checksums of its
  sub-connectors' transfer results.
- A load whose checksums never arrive is unverified. For both carriers,
  checksums are only sent for requests that finished normally (stop or
  length), not aborted ones.

The consumer's worker connector must return the checksums by the time
it reports the load finished, so the consumer compares them in the
step that reports the load.

Tests
-----
test_kv_checksum_scheduler.py adds ConnectorCarrierConnector, a test
connector that declares the connector-owned carrier and ships the
checksums in its next step's metadata, and runs the end-to-end cases
(layout independence, correct loads across TP/PP pairs and block sizes,
and corrupted, missing or malformed checksums under each failure
policy) for it as well as for the kv_transfer_params test connector
and NIXL pull. Ignoring received checksums in the scheduler or the
worker, or not calling send_kv_checksums, makes the
connector-carrier cases fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: David Cheung <d7cheung@gmail.com>
In NIXL push mode the producer WRITEs the KV into the consumer's blocks.
The consumer's kv_transfer_params are built before the producer
finishes, and nothing returns to the consumer through them afterwards,
so KV checksums travel with the transfer itself: on the notif of the
WRITE that delivers the KV, through the connector-owned carrier of the
previous commit. Also drops an unreachable producer-side branch of the
push worker's notif handling.

NIXL push
---------
- NixlBaseConnector.kv_checksum_carrier is now shared by pull and push;
  each sets its carrier (_KV_CHECKSUM_CARRIER): kv_transfer_params for
  pull, the connector for push.
- P scheduler: send_kv_checksums keeps the checksums of a request whose
  finished blocks are about to be pushed, and build_connector_meta
  ships them with the blocks (NixlConnectorMetadata
  .push_finished_kv_checksums). Checksums of requests with nothing to
  push (e.g. aborted) are dropped, so nothing is left to clean up.
- P worker: start_load_kv hands them to the writer thread with the
  finished blocks, in the same queue item; the writer keeps them until
  the request is evicted. _completion_notif builds the WRITE's notif:
  KV_CHECKSUM_NOTIF_PREFIX + msgpack((remote_request_id, tp_size,
  checksums)), instead of "remote_request_id:tp_size" when there are
  checksums.
- D worker: _get_new_notifs parses both forms with the same completion
  accounting, keeps the first checksums received for a request it is
  loading, and get_transfer_results reports them with the request's
  finished_recving.
- NIXL_CONNECTOR_VERSION 14 -> 15 (new notif form), so mixed versions
  fail the handshake instead of misreading the notif. Without checksums
  (disabled, or not summed on the producer) the notif keeps the plain
  form, so the default path is unchanged.

Design choices
--------------
- Why the next step's metadata: only the P worker's writer thread can
  send NIXL messages, and the scheduler reaches workers only through
  the next SchedulerOutput. The finished blocks to push already take
  that path (request_finished records them, the next
  build_connector_meta ships them), and the checksums ride along in the
  same metadata and queue item, so they add no latency to the WRITE.
- Why the completion notif: it is already the consumer's signal that
  the load is done, and NIXL delivers it after the WRITE's data. The
  checksums therefore cannot arrive after the consumer reports the load
  finished, so the consumer never waits for them. A separate notif
  would have to be ordered against, and survive the loss of, the
  completion notif.
- Why every writing producer rank carries them: which producer ranks
  write to a given consumer rank depends on the TP ratio, PP and MLA.
  A single designated carrier would need a proof that it always writes,
  otherwise the checksums would not arrive and the load would be
  unverified. Every rank that writes sends the same bytes (summed in the
  producer's scheduler), and the consumer keeps the first copy, without
  merging. The cost is duplicated payloads when several producer ranks
  write to one consumer rank.
- The checksum payload inside the notif is opaque to NIXL and versioned
  on its own (_PAYLOAD_VERSION).

Unreachable producer-side notif counting
----------------------------------------
In push mode, the only notifs D sends to P are PUSH_REG, which the
writer thread consumes, and HB, which _get_new_notifs handles before
the counting. Only pull_worker.py fills _pending_recv_notifs, so no
code path sends P a plain "<req_id>:<n>" notif. The producer-side
branch of NixlPushConnectorWorker._get_new_notifs was carried over from
pull mode and never runs: P reports finished sends from its WRITE
handles in get_transfer_results. This commit removes the branch, its
guard, the transfer_topo assert it needed and the counter cleanup for
finished pushes. _get_new_notifs now counts only consumer-side
completion notifs and always returns an empty set. A completion notif
for a request that is also being sent (one request both loading and
saving on a kv_both instance) is now counted as a load instead of as a
send. With a tripwire in the removed branch, test_nixl_push_connector,
test_nixl_heartbeat and test_nixl_connector pass without reaching it,
as did the GPU push runs below.

Known limitations
-----------------
- If a producer's lease expires while its WRITE waits for a deferred
  handshake, the writer drops the checksums with the request and the
  late WRITE (a pre-existing hazard: its blocks may have been freed)
  goes out without them. The consumer reports the load unverified, which
  fails it under fail-closed, rather than a mismatch.
- A consumer load that never completes (some notifs lost) keeps its
  received checksums, as it already keeps its recv metadata.
- The notif grows by 8 bytes per block and group (about 64 KB for a
  128k-token prompt with 16-token blocks), sent once per WRITE. PUSH_REG
  carries block-id lists of similar size.
- A checksum notif that fails to decode is logged and dropped: like a
  lost notif, the consumer's load then never completes and the request
  waits until aborted.
- Push allows larger producer than consumer blocks; a consumer prefix
  hit that ends inside a producer block leaves the load unverified (see
  the block-size commit). The open upstream vllm-project#59747 proposes rejecting
  different block sizes in push at handshake.

Tests
-----
- test_kv_checksum_scheduler.py runs the end-to-end cases for
  NixlPushConnector as well: the producer's push metadata carries the
  checksums, and the consumer's workers report them with the finished
  load.
- test_nixl_push_connector.py: the P scheduler ships checksums with the
  finished blocks and drops those of an aborted request; start_load_kv
  queues them for the writer; a completion notif built by a producer
  worker, with and without checksums, completes the consumer's load and
  reports its checksums. It no longer sets transfer_topo or checks the
  return value of _get_new_notifs. Not reporting the received
  checksums, dropping them from the notif, or not sending them makes
  these tests fail.

Validation
----------
On GPU, end-to-end runs with a fault-injecting connector (not part of
this series) met their expected results for push with TP1 -> TP1,
producer TP2 -> consumer TP1 and producer TP1 -> consumer TP2: no false
mismatch, every injected corruption detected, no host sync. These runs
used Qwen/Qwen3-0.6B; no MLA model was run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: David Cheung <d7cheung@gmail.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.

@sawsa307
sawsa307 requested a review from youkaichao as a code owner October 14, 2026 20:16
@mergify

mergify Bot commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @sawsa307.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Oct 16, 2026
@njhill

njhill commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

@sawsa307 it would be good to split out this commit's changes into a separate PR we could get merged first, since they are an independent simplification: a691cf0

(commit is from the original PR, it's merged with other stuff in this one)

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