| 1 | # NIXL push-mode KV transfer |
| 2 | |
| 3 | The default NIXL connector is **pull-based**: the decode (D) instance |
| 4 | reads KV blocks from the prefill (P) instance via `NIXL READ` after |
| 5 | prefill completes. `NixlPushConnector` adds a **push-based** alternative |
| 6 | in which P writes the KV blocks directly into D's pre-allocated memory |
| 7 | via `NIXL WRITE`. |
| 8 | |
| 9 | This document describes the threading, queues, and scheduling |
| 10 | interactions specific to the push design. The pull-mode design is |
| 11 | unchanged; the push connector reuses the same handshake, NIXL agent |
| 12 | setup, and metadata path wherever possible. |
| 13 | |
| 14 | ## High-level flow |
| 15 | |
| 16 | ```mermaid |
| 17 | sequenceDiagram |
| 18 | autonumber |
| 19 | participant Client |
| 20 | participant Proxy |
| 21 | participant DSched as D Scheduler |
| 22 | participant DWorker as D Worker (main) |
| 23 | participant DWriter as D Writer |
| 24 | participant PWriter as P Writer |
| 25 | participant PWorker as P Worker (main) |
| 26 | participant PSched as P Scheduler |
| 27 | |
| 28 | Client->>Proxy: POST /v1/completions |
| 29 | Proxy->>PSched: prefill leg (do_remote_decode=True, max_tokens=1) |
| 30 | Proxy->>DSched: decode leg (do_remote_prefill=True, P coordinates) |
| 31 | |
| 32 | note over DSched,DWriter: D side - register blocks with P |
| 33 | DSched->>DSched: update_state_after_alloc, stash registration, arm watchdog |
| 34 | DSched->>DWorker: build_connector_meta -> meta.push_registrations |
| 35 | DWorker->>DWriter: enqueue (req_id, reg_data) on _reg_send_inbox |
| 36 | DWriter->>PWriter: NIXL send_notif PUSH_REG msgpack |
| 37 | |
| 38 | note over PSched,PWriter: P side - prefill, stage finished blocks |
| 39 | PSched->>PSched: request_finished, stash blocks |
| 40 | PSched->>PWorker: build_connector_meta -> meta.push_finished_blocks |
| 41 | PWorker->>PWriter: enqueue (req_id, blocks) on _finished_blocks_inbox |
| 42 | |
| 43 | note over PWriter: P writer matches and WRITEs |
| 44 | PWriter->>PWriter: get_new_notifs returns PUSH_REG, route via _handle_push_reg_notif |
| 45 | alt PUSH_REG and finished blocks both present |
| 46 | PWriter->>PWriter: pop matching pair, fire WRITE |
| 47 | else only one side present |
| 48 | PWriter->>PWriter: stash and wait, self-poll only when blocks unmatched |
| 49 | end |
| 50 | PWriter->>PWriter: _ensure_handshake to D (async; defer WRITE) |
| 51 | PWriter->>PWriter: handshake callback re-queues on _deferred_push_inbox, wake |
| 52 | PWriter->>DWriter: NIXL WRITE direct to D GPU + completion notif |
| 53 | |
| 54 | note over DWorker,DWriter: D side - completion accounting |
| 55 | DWriter-->>DWorker: forward HB and completion notifs via _pending_completion_notifs |
| 56 | DWorker->>DWorker: _get_new_notifs drains, HB extends lease, completion marks recv done |
| 57 | DWorker->>DSched: update_connector_output(finished_recving) |
| 58 | DSched->>DSched: clear watchdog deadline |
| 59 | |
| 60 | note over PWorker,PWriter: P side - reclaim |
| 61 | PWorker->>PWorker: get_finished, drain _sending_transfers, queue eviction |
| 62 | PWriter->>PWriter: drain _evict_finished_inbox, drop stale state |
| 63 | PWorker->>PSched: update_connector_output(finished_sending) |
| 64 | PSched->>PSched: free lease |
| 65 | |
| 66 | DWorker-->>Proxy: stream decode tokens |
| 67 | Proxy-->>Client: response |
| 68 | ``` |
| 69 | |
| 70 | ## Threads |
| 71 | |
| 72 | ``NixlPushConnectorWorker`` introduces a single dedicated background |
| 73 | thread per worker (i.e. per TP rank), named ``nixl-push-writer``. |
| 74 | Each owns the new push-specific NIXL operations on its rank: |
| 75 | |
| 76 | * ``nixl_wrapper.get_new_notifs()`` — receive notifications. |
| 77 | * ``nixl_wrapper.send_notif(...)`` for the ``PUSH_REG:<msgpack>`` (D |
| 78 | side) and for the per-WRITE completion notif (P side). |
| 79 | * ``nixl_wrapper.make_prepped_xfer(...) / transfer(...)`` — submit the |
| 80 | WRITE itself. |
| 81 | |
| 82 | Heartbeats continue to go out from the engine main thread via the |
| 83 | existing base-worker ``_send_heartbeats`` plumbing inside |
| 84 | ``start_load_kv``. |
| 85 | |
| 86 | ### Wake model |
| 87 | |
| 88 | The writer thread blocks on ``_push_writer_wake`` (a |
| 89 | ``threading.Event``) when it has no work. Three callers set the |
| 90 | event: |
| 91 | |
| 92 | 1. **``start_load_kv``** (worker main thread, called once per engine |
| 93 | step with the scheduler's metadata) — sets the wake only when the |
| 94 | step actually hands the writer new work, i.e. when |
| 95 | ``meta.push_registrations`` or ``meta.push_finished_blocks`` is |
| 96 | non-empty. This is the wake for new transfers. |
| 97 | 2. **``get_finished``** (worker main thread, called once per engine |
| 98 | step to report completions) — always sets the wake. The writer is |
| 99 | the sole consumer of ``nixl_wrapper.get_new_notifs()`` for push, |
| 100 | so this gives it a chance to drain inbound notifs (heartbeats from |
| 101 | D, completion notifs after a WRITE, late-arriving ``PUSH_REG``) |
| 102 | even when there is no new metadata to act on. |
| 103 | 3. **Handshake-completion callback** (background handshake executor |
| 104 | thread) — both handshakes run on the executor and never block the |
| 105 | writer; their done-callbacks re-enqueue the deferred op and set the |
| 106 | wake, since neither ``send_notif`` nor the NIXL WRITE may run off the |
| 107 | writer thread: |
| 108 | * the **D→P** handshake (before sending ``PUSH_REG``) re-enqueues the |
| 109 | registration onto ``_reg_send_inbox``; |
| 110 | * the **P→D** handshake (before a WRITE) re-enqueues the matched |
| 111 | ``(req_id, blocks, reg_data)`` onto ``_deferred_push_inbox``. |
| 112 | |
| 113 | On this second pass ``_ensure_handshake`` returns ``None`` (the agent |
| 114 | is now connected), so the writer sends the ``PUSH_REG`` / issues the |
| 115 | WRITE directly. If a handshake *failed*, the callback fails or drops |
| 116 | the request instead of re-enqueuing, so there is no retry loop (see |
| 117 | Failure handling). |
| 118 | |
| 119 | In addition to event-driven wakes, the writer self-polls at |
| 120 | ``_PUSH_WRITER_POLL_INTERVAL_MS = 1.0`` ms while there are P-side |
| 121 | finished blocks waiting for an unmatched ``PUSH_REG``. |
| 122 | |
| 123 | When a request completes on P (lease expires or the WRITE finishes), |
| 124 | ``get_finished`` enqueues the request id onto ``_evict_finished_inbox``, |
| 125 | which the writer drains to drop stale ``_push_finished_blocks`` / |
| 126 | ``_pending_d_registrations`` and stop self-polling. |
| 127 | |
| 128 | ## Writer-local matching tables |
| 129 | |
| 130 | | Table | Owner | Holds | |
| 131 | |--------------------------------|------------------|------------------------------------------------------------------------| |
| 132 | | `_pending_d_registrations` | writer | D registrations received from a remote D, waiting for P's blocks | |
| 133 | | `_push_finished_blocks` | writer | P blocks staged by the scheduler, waiting for a remote D registration | |
| 134 | |
| 135 | Either side can arrive first. The writer matches in both directions: |
| 136 | when a ``PUSH_REG`` arrives we look up ``_push_finished_blocks``, and |
| 137 | when finished blocks arrive we look up ``_pending_d_registrations``. |
| 138 | Both lookups try an exact ``request_id`` match first, then fall back |
| 139 | to comparing the ids after stripping the trailing per-engine random |
| 140 | suffix (via ``get_base_request_id``). The fallback exists because the |
| 141 | proxy hands the same ``X-Request-Id`` to both legs, so P and D wrap it |
| 142 | into the same ``cmpl-<uuid>-<index>`` form and differ only by the |
| 143 | 8-hex randomization suffix that ``input_processor.assign_request_id`` |
| 144 | appends per engine. Stripping just that suffix normalizes both sides |
| 145 | to the same id while preserving the completion index (so multi-prompt |
| 146 | sub-requests stay distinct). It also works whether or not |
| 147 | ``VLLM_DISABLE_REQUEST_ID_RANDOMIZATION`` is set, which matters since |
| 148 | that env var is slated for removal upstream. |
| 149 | |
| 150 | ## Wire format |
| 151 | |
| 152 | A push registration is sent as a NIXL notification: |
| 153 | |
| 154 | ```text |
| 155 | PUSH_REG:<msgpack-encoded dict> |
| 156 | ``` |
| 157 | |
| 158 | Fields in the dict: |
| 159 | |
| 160 | | Field | Set by | Meaning | |
| 161 | |----------------------|--------|------------------------------------------------------------------------| |
| 162 | | ``request_id`` | D | D's own vLLM request id; P's match key, echoed in the completion notif | |
| 163 | | ``decode_engine_id`` | D | D's engine id (P uses this for the reverse handshake) | |
| 164 | | ``decode_host`` | D | D's NIXL side-channel host | |
| 165 | | ``decode_port`` | D | D's NIXL side-channel port | |
| 166 | | ``decode_tp_size`` | D | D's tensor-parallel size | |
| 167 | | ``local_block_ids`` | D | per-group lists of D's *logical* block ids (preallocated) | |
| 168 | | ``remote_engine_id`` | D | P's engine id (for the existing P-side handshake) | |
| 169 | | ``remote_host`` | D | P's NIXL side-channel host | |
| 170 | | ``remote_port`` | D | P's NIXL side-channel port | |
| 171 | | ``remote_tp_size`` | D | P's tensor-parallel size | |
| 172 | |
| 173 | D ships **logical** block ids; P expands them to physical block ids at |
| 174 | WRITE-submission time using the ratio learned during the NIXL |
| 175 | handshake (`remote_physical_blocks_per_logical`). This matches the |
| 176 | pull-mode contract — schedulers ship logical ids, workers expand to |
| 177 | physical at submission. |
| 178 | |
| 179 | The completion notif sent from P to D after a WRITE is the existing |
| 180 | `<request_id>:<tp_size>` format used in pull mode (here ``request_id`` |
| 181 | is D's own request id, taken from the registration), so the D-side |
| 182 | accounting code is unchanged. |
| 183 | |
| 184 | ## Pipeline parallelism and hybrid KV caches |
| 185 | |
| 186 | The pull connector requires the local and remote workers to expose a |
| 187 | congruent list of KV regions — region *i* here corresponds to region |
| 188 | *i* there. That assumption breaks under pipeline parallelism (PP) |
| 189 | combined with a hybrid (HMA) KV layout: |
| 190 | |
| 191 | * a PP-sharded prefiller (**P**) holds only a slice of the model's |
| 192 | layers while the `PP=1` decoder (**D**) holds them all, so region |
| 193 | counts differ; |
| 194 | * with HMA, several layer names are pooled into one region and the layer |
| 195 | that represents a pooled region can differ between P and D. |
| 196 | |
| 197 | `NixlPushConnector` handles this for PP-sharded producers by routing |
| 198 | **by layer name** instead of by region index. Each |
| 199 | worker advertises which layer names back each of its NIXL regions in the |
| 200 | handshake metadata (`NixlAgentMetadata.region_members`). A producer that |
| 201 | needs layer routing derives its transfer layer order once, when it |
| 202 | registers its KV caches, and every transfer it issues uses that order. |
| 203 | `add_remote_agent` then selects exactly the remote regions this stage |
| 204 | owns and reorders them to match, so both sides stay paired regardless of |
| 205 | how each remote rank happens to order its metadata. |
| 206 | |
| 207 | Addresses, block lengths, strides, and per-region capacities follow the same |
| 208 | layer order. Descriptor offsets use each layer's region capacity, so P and |
| 209 | D need not allocate the same number of blocks. Physical allocations are still |
| 210 | registered once, even when multiple layers share them. |
| 211 | |
| 212 | Packed MLA caches (block-outermost layouts such as DeepSeek-V4's) store the |
| 213 | pages of every layer for one block in a single row. A PP stage holds only its |
| 214 | own layers, so its rows are shorter and each layer sits at a different offset: |
| 215 | |
| 216 | ```text |
| 217 | D (PP=1), block b: | L0 | L1 | L2 | L3 | L4 | L5 | |
| 218 | P (stage 2), block b: | L3 | L4 | L5 | |
| 219 | ``` |
| 220 | |
| 221 | `PP=1` peers transfer whole rows. They also advertise where each layer's page |
| 222 | sits in the row (`NixlAgentMetadata.packed_member_layouts`), so a PP producer |
| 223 | writes each of its pages to the matching slot in D's row. Page sizes must |
| 224 | match; offsets, row strides, and block counts may differ. Because PP stages can |
| 225 | list the same attention backends in a different order, packed push compares the |
| 226 | sorted set of backend names during the handshake. Pull mode is unchanged. |
| 227 | |
| 228 | Invariants enforced when the remote regions are aligned: |
| 229 | |
| 230 | * every locally owned layer must be advertised exactly once by the |
| 231 | remote; a missing layer fails the handshake rather than silently |
| 232 | leaving that layer's KV stale, and remote-only layers (owned by other |
| 233 | PP stages) are ignored; |
| 234 | * a remote that omits layer metadata while the local layout requires |
| 235 | layer routing fails the handshake instead of falling back to |
| 236 | region-index routing; |
| 237 | * layer order is a property of the local layout alone, so the same local |
| 238 | source descriptors serve every remote engine and TP rank; only the |
| 239 | remote descriptor list is rebuilt per rank. |
| 240 | |
| 241 | Decode-side PP is unsupported because completions are counted per |
| 242 | consumer rank. Mamba/SSM hybrids are unsupported under PP. Packed PP push |
| 243 | requires MLA caches and equal P/D block sizes. MLA pages are replicated |
| 244 | across TP ranks, so producer TP1 to consumer TP2 is supported. |
| 245 | |
| 246 | ## Scheduler-side responsibilities |
| 247 | |
| 248 | `NixlPushConnectorScheduler` extends the base scheduler with: |
| 249 | |
| 250 | * **D side** — `update_state_after_alloc` stashes registration data in |
| 251 | `_push_pending_registrations` and arms a soft watchdog |
| 252 | (`_push_registration_deadlines`). `build_connector_meta` drains the |
| 253 | stash into `meta.push_registrations` and any expired entries are |
| 254 | dropped with a warning. |
| 255 | * **P side** — `request_finished` stashes block IDs in |
| 256 | `_finished_request_blocks` (for the lease and for |
| 257 | `has_pending_push_work`) and `_newly_finished_push_blocks` (for the |
| 258 | next worker step via `meta.push_finished_blocks`). |
| 259 | * **Both sides** — `has_pending_push_work` keeps the engine main loop |
| 260 | stepping while there is in-flight push state, so the writer always |
| 261 | gets at least one wake per step. |
| 262 | |
| 263 | `update_connector_output`: |
| 264 | |
| 265 | * `finished_sending` (P side) clears the lease entry. |
| 266 | * `finished_recving` (D side) clears the watchdog deadline. |
| 267 | |
| 268 | ## Timeouts and watchdogs |
| 269 | |
| 270 | Two per-request timers are armed on the scheduler: |
| 271 | |
| 272 | * **D-side registration watchdog** — ``_push_registration_deadlines``. |
| 273 | If a registered request does not see a push completion within |
| 274 | ``push_registration_timeout`` seconds (defaults to |
| 275 | ``decoder_kv_blocks_ttl``), ``build_connector_meta`` drops the stale |
| 276 | registration and the pending entry, logs a warning, and stops trying |
| 277 | to resend the registration. The corresponding request remains tracked |
| 278 | in ``_reqs_need_recv``; it is the engine's request-level abort path |
| 279 | (or the user / proxy timing out the HTTP call) that ultimately fails |
| 280 | the request. |
| 281 | * **P-side block lease** — same ``_kv_lease_duration`` used by pull |
| 282 | mode. ``request_finished`` sets the expiration in ``_reqs_need_send`` |
| 283 | and ``update_connector_output(finished_sending=...)`` clears it on |
| 284 | successful WRITE. Stale leases are reaped by ``get_finished`` in the |
| 285 | base worker, which then enqueues the eviction onto |
| 286 | ``_evict_finished_inbox`` so the writer also stops self-polling. |
| 287 | |
| 288 | ## Failure handling |
| 289 | |
| 290 | * **D-side handshake failure (P→D handshake before sending PUSH_REG)** — |
| 291 | the future's done-callback calls ``_handle_failed_transfer(rid, None)``, |
| 292 | which marks D's pre-allocated blocks invalid and enqueues onto |
| 293 | ``_failed_recv_reqs`` so the next ``get_finished`` reports the |
| 294 | request as a failed recv. Same recv-side accounting as pull mode. |
| 295 | * **D-side ``send_notif`` failure when shipping the PUSH_REG to P** — |
| 296 | identical handling: ``_handle_failed_transfer`` marks the recv as |
| 297 | failed. |
| 298 | * **P-side handshake failure (P→D handshake before a WRITE)** — the |
| 299 | future's done-callback logs ``push_handshake_failed`` and drops the |
| 300 | request without re-queuing. It deliberately does *not* call |
| 301 | ``_handle_failed_transfer`` (there is no ``_recving_metadata`` entry to |
| 302 | invalidate on the producer side, same reasoning as the WRITE-submission |
| 303 | failure below). P's blocks are reclaimed by the ``_kv_lease_duration`` |
| 304 | lease and D's stale registration by its watchdog. |
| 305 | * **P-side WRITE submission failure** — the WRITE handle (if any) is |
| 306 | released and ``xfer_stats.record_failed_transfer()`` bumps the |
| 307 | failure counter. We deliberately do not call |
| 308 | ``_handle_failed_transfer`` here: ``req_id`` on the P side has no |
| 309 | entry in ``_recving_metadata`` (P is not the receiver), so the |
| 310 | helper would put a P-local request id into ``_failed_recv_reqs`` |
| 311 | and trip the assertion in the base worker's ``get_finished``. The |
| 312 | outbound WRITE is dropped on the floor; D's lease watchdog handles |
| 313 | the missing completion. |
| 314 | |
| 315 | ## Summary |
| 316 | |
| 317 | The push design is a small, well-contained extension on top of the |
| 318 | existing NIXL connector: |
| 319 | |
| 320 | * one new connector class, one new scheduler class, one new worker |
| 321 | class — all subclasses of the existing base classes; |
| 322 | * one dedicated background thread per worker; |
| 323 | * a few cross-thread queues, each with a single consumer (the writer); |
| 324 | most have one producer, except the two replay queues fed by both the |
| 325 | engine main thread and a handshake-completion callback: |
| 326 | ``_reg_send_inbox`` (registrations replayed after their D→P handshake) |
| 327 | and ``_deferred_push_inbox`` (matched pushes replayed after their P→D |
| 328 | handshake); |
| 329 | * one new notification type (`PUSH_REG:<msgpack>`). |
| 330 | |
| 331 | Behavior on the engine main thread is otherwise unchanged. The writer |
| 332 | thread is event-driven and idle when there is no push work. |