ProprietaryLegal's picture
2026-10-23 update: measured on GB10 (out-of-process proxy: -0.9 to -3.9 %/round one card, -3.9 to -6.5 % two cards, TTFT parity, 0 watchdog fires); in-process cost finding; hardening (late-proxy corruption, start fallback, stall detection, respawn, shutdown) with independent-review evidence; full proxy code
da83667 verified
|
Raw History Blame Contribute Delete
41.3 kB

Remote DSpark drafter — wire and state contract (DSV41-OFFLOAD)

Contract version 1.8 (2026-10-22). Owner: lane O3 (Spark side). Lane O2 implements the x86host worker against it. Every change is listed in the change log (§13) with a version bump; the wire version field changes only on an incompatible change. Propose changes in O2.progress.md (the coordinator relays them).

This contract comes from the proposer that runs in the live serve. File references point to the read-only copy of the image at /data/dsv41-offload/src-image/ (vLLM 0.31.1.dev0+g6e517b15.dsv41gb10, image sha256:435a5236). It does not come from the upstream remote-dspark mod (vLLM 0.29, GLM/Kimi), which uses a different state model.

1. Plain summary

  • Each engine step, Spark rank 0 sends the worker the target tap rows for the positions the target has just accepted. Rows are only sent for those positions, and never more than the last 128 per request. Rank 0 also sends one anchor token per request.
  • The worker keeps a 128-position window of those rows per request slot. It drafts a block of 5 tokens, with a confidence for each, by greedy argmax.
  • Rank 0 broadcasts the drafts and confidences to rank 1 over the existing TP group. The verifier, rejection sampler, depth policy and k schedule stay unchanged on the Sparks.
  • Nothing a draft produces is ever committed to the window. Only context rows that the target has verified are stored.
  • Prefix-cache hits, preemption, resume and reconnection need no special worker logic. The engine already recomputes the last 128 tokens of a prefix hit (SWA bounded replay), and rank 0 keeps a copy of every live window, so it can re-send one at any time.

2. Facts from the running proposer (verified in source)

fact value source
speculator DSparkSpeculator(DFlashSpeculator); method="dspark" → init_speculator v1/worker/gpu/spec_decode/__init__.py:35-40
block / k num_speculative_tokens = 5 = dspark_block_size; anchor-as-first: N = 5 query tokens per request ([anchor, noise×4]), every query position predicts the next token dspark/speculator.py:47-56, dflash/speculator.py:624-671 (SAMPLE_FROM_ANCHOR)
noise token dspark_noise_token_id = 128799 (parallel_drafting_token_id) config.json; spec_decode/utils.py:85
conditioning main_x = main_norm(main_proj(cat(aux_hidden_states, -1))), main_proj 15,360 → 5,120 (ReplicatedLinear, EXL3 K5 in this checkpoint), main_norm RMSNorm eps 1e-20 nvidia/dspark.py:104-112, 163-168; dflash/speculator.py:376-382; config quantization_config.exl3_moe.dense.drafter.main_proj = 5
taps aux_hidden_states = 3 tensors [T, 5120] bf16, in the order of dspark_target_layer_ids = [37, 38, 39]. Tap L = mean over the 4 hc copies of the residual stream entering decoder layer L (0-based), i.e. the output of layer L−1 eagle3_utils.py:113-120 ("v4.1 reads the attention inputs"), nvidia/model.py:540-541, 1119-1138
tap validity in eager prefill steps, layers ≥ 20 (max(kv_source_layer_ids)) run only on each request's last min(q, 128) rows of the step (decoder SWA bounded replay, patch 0034). The trimmed rows' taps are zeros. CUDA-graph (decode) steps compute every row nvidia/model.py:793-848, decoder_replay_layers.py:65-85, nvidia/model_state.py:407-433 (kept_lens = min(query_lens, window))
tap placement over TP replicated on both ranks. The live config has no sequence parallel (no --enable-expert-parallel); with SP the engine all-gathers aux before returning. Rank 0 alone holds complete taps nvidia/model.py:245-255, 1134-1138
context KV each draft layer: kv = kv_norm(wkv part of fused_wqa_wkv(main_x)), RoPE + quant + insert at the context positions nvidia/dspark.py:170-201, 256-279
context rows per step the target's rows minus the rejected suffix: num_valid_ctx = num_scheduled − num_rejected, positions = the target's positions of those rows dflash/speculator.py:570-622
anchor last_sampled[req] if num_sampled > 0, else next_prefill_tokens[0, req] (partial prefill). Anchor position = last valid context position + 1 dflash/speculator.py:578-585, 625
draft window the draft layers are pure sliding-window attention (compress_ratios[40:43] = 0), window W = 128, but the DSpark draft block does not use the causal per-token window ([max(P−127, 0), P], sparse_swa.py:1060-1066, the target's SWA layers). Its non-causal kernel anchors one window to the whole block: with the context ending at L = anchor_pos − 1, every query token of the block attends to the 128 context positions [max(anchor_pos − 128, 0), anchor_pos − 1] plus all N query tokens of the block (the earlier and the later ones) config sliding_window = 128; v1/attention/backends/mla/sparse_swa.py:1253-1255 (ComputeDSparkNoncausalSWAIndicesKernel: start_pos = max(prefix_len − window_size, 0), end_pos = seq_len), :550-562 + compressor_utils.py:21-27 (index width W + N, padded); nvidia/dspark.py:9-10
query clamp query positions min(pos, max_model_len − 1) with max_model_len = 327,680 dflash/speculator.py:656-657, 679-681
sampling (live) greedy drafts, draft_logits is None. VLLM_DSPARK_LOCAL_ARGMAX=1 gives the argmax of (base logits + Markov bias) and resolves ties to the lower vocabulary index (= torch.argmax). The sequential Markov step uses the previous sampled token (step 0: the anchor). Markov bias rows come from the cache VLLM_DSPARK_MARKOV_CACHE=/root/.cache/markov_top_tokens.json dspark/speculator.py:173-230, 120-130; nvidia/dspark.py:369-450; live container env
confidence sigmoid(confidence_head(cat(head_hidden, markov_embed))) per draft position. Consumed by the depth policy VLLM_DSPARK_DEPTH_POLICY=conf (live) dspark/speculator.py:223-230; nvidia/dspark.py:466-471; depth_policy.py
k actually verified decided on the Spark only: 0040 schedule [[1,2,5],[3,4,4]] (k = 5 at 1–2 requests, 4 at 3–4), 0047 adaptive window in the scheduler (VLLM_DSV41_ADAPTIVE_K=1, from accepted-draft EMAs), then per-request depth from the confidences (rows past the depth are dead and never accepted) v1/core/sched/scheduler.py:1491-1500, 2027-2036; model_runner.py:1528-1540, 1693-1698; depth_policy.py
prefix-cache hit CacheConfig.swa_bounded_replay = True (default; V2 runner, no PCP, not ROCm): sliding-window groups (the draft layers included) are not prefix-cacheable. On a hit of h tokens the scheduler rewinds to replay_start = h − 128 and the target recomputes those 128 tokens, so their taps reach the drafter like any prefill rows. Hits ≤ 128 tokens are discarded and fully recomputed config/cache.py:240; models/deepseek_v41/attention.py:529-570; v1/kv_cache_interface.py:950-955; v1/core/sched/scheduler.py:1002-1012, 1055-1065, 3126-3135
preemption the V2 runner removes preempted requests (finish_requests unions preempted_req_ids) and re-adds them as new requests (prefix hit + replay as above) model_runner.py:1187-1199, 1216-1258
request slot idx_mapping[i] = request-state index 0..max_num_seqs−1 (4). Slots are reused model_runner.py:1341-1342; states.py

3. Conditioning layout: send the raw taps (decided with numbers)

Two candidates: main_x (5,120 bf16 = 10,240 B/row, computed on rank 0) or the raw taps (3 × 5,120 bf16 = 30,720 B/row, main_proj + main_norm on the worker).

item main_x on Spark taps to worker
Spark work per round main_proj GEMM on rank 0. EXL3 K5 weights are 46.9 MiB; at the 273 GB/s LPDDR5x floor that is ≥ 0.18 ms per round, ~0.2–0.3 ms in practice none (row gather only)
Spark memory (capacity mode) main_proj + main_norm must stay loaded on rank 0 (≈ 47 MiB) no drafter tensor on either Spark
wire per decode round (at most 1+k rows/request) 1 req ≤ 61 KiB; 4 req ≤ 200 KiB 1 req ≤ 180 KiB; 4 req ≤ 600 KiB
extra wire time for taps at 4.6 GB/s effective (40 Gb/s) — +26 µs (1 req, 6 rows) … +89 µs (4 req, 20 rows)
worker main_proj on the 3090 — ≈ 0.05–0.08 ms (47 MiB at ~900 GB/s)
prefill (≤ 128 rows/request/step, §4.3) 1.25 MiB per request-chunk 3.75 MiB per request-chunk (+0.5 ms against a ~1 s chunk)

Decision: payload kind 1 = TAPS_BF16. The taps cost 25–90 µs more on the wire but save ≥ 0.18 ms on rank 0, and they keep both Sparks free of drafter weights. The result: about 0.05–0.15 ms better per round at 1–4 requests, plus a clean capacity mode. Kind 2 = MAINX_BF16 is reserved in HELLO. Revisit it only if the measured link (lane S1) or the worker's main_proj time (lane O2) reverses the sign.

Row layout: row = cat(tap37, tap38, tap39) as little-endian bf16. That is exactly row r of torch.cat(aux_hidden_states, dim=-1), the tensor combine_hidden_states consumes, so the worker computes main_x = main_norm(main_proj(row)) with the same weights.

4. Per-round data flow

4.1 Who talks to whom

  • Rank 0 (spark-a) is the only client. It uses one connection to the worker over its direct link (10.202.28.2 → 10.202.28.1) through lane O1's transport (TCP or RDMA RC). It sends one request and waits for the reply.
  • Rank 1 (spark-b) never talks to x86host. It receives drafts, confidences and a status word in one NCCL broadcast from rank 0 on the TP group. Both ranks must feed identical draft tokens to the target, because TP ranks diverge otherwise. The broadcast also makes every fallback decision identical on both ranks.
  • Why not have both ranks receive from x86host? It would save one ~20 µs NCCL hop. But the two links can fail independently, so the ranks would still need an agreement collective, and then nothing is saved. Rejected for v1. Revisit only if the coordinator measures that the TP broadcast dominates.

4.2 Sequence (sync mode, all policies)

  1. The target forward and the rejection sampler finish on both ranks, as today.
  2. Rank 0, on its GPU stream: gathers the candidate rows of the taps, drops rejected rows (device-side compaction using num_rejected, no host sync), and updates its device ring (§7.3). It then copies rows and metadata into a pinned staging buffer and waits for that copy.
  3. Rank 0 sends STEP and waits for the reply, with a deadline (§10).
  4. Rank 0 uploads [status, drafts, confidences] and broadcasts them on the TP group. Rank 1 receives the same tensor. Both ranks write draft_tokens and draft_token_confidence_probs.
  5. The engine continues unchanged: the depth policy records confidences, the scheduler takes the drafts, the next verify runs.

An optional async variant exists for capacity mode (VLLM_DSPARK_REMOTE_ASYNC=1). The engine stream waits on a device flag (cuStreamWaitValue32) that the proxy thread's side stream writes after the reply is in device memory, so rank 0's CPU never blocks. It is transparent to the worker: the same messages, the same order.

4.3 Which rows are sent (the trailing-window rule)

For request i in the step, with q_i scheduled rows and num_rejected_i rejected:

  • the candidate rows are the last n_i = min(q_i, W) rows of the request's span, with W = 128;
  • the valid rows are the first n_i − num_rejected_i of those. num_rejected_i > 0 only for decode rows, where q_i ≤ 1 + k ≤ 6 < W, so every candidate row is a decode row;
  • valid rows are sent in position order: first_pos … first_pos + n_rows − 1, and anchor_pos = first_pos + n_rows.

Why this is complete:

  • Within a step, only the last min(q, 128) rows have real taps (decoder bounded replay).
  • The draft block reads exactly the W = 128 context positions before the anchor, [anchor_pos − 128, anchor_pos − 1] (fewer near position 0), for every query token of the block (§2, block-anchored window). It never reads further back, and it needs all 128: the window is 128 rows, not the 127 of a causal per-token window.
  • By induction over steps, the worker always holds every position in [max(lo, anchor_pos − W), anchor_pos − 1], where lo is where the request's rows begin. That is position 0 for a fresh prompt, or replay_start after a prefix hit. Bounded replay recomputes the 128 tokens before the hit (replay_start = h − 128), so once the first chunk is done every one of the 128 rows the anchor reads exists.

5. Transport framing

The contract is transport-independent. One request message gets one reply message. Requests on a connection are processed in order, with one outstanding request per connection in v1.

  • TCP stub: each message is a little-endian u32 length followed by length bytes (AppHeader + body). TCP_NODELAY, explicit local bind to the direct-link address.

  • O1 transport ofl (lane O1, offload/transport/API.md, commit 41cf2f7 or later): one contract message is one ofl request.

    • ofl op = AppHeader.op (1–5; ofl's op 0 is its built-in echo); ofl generation = AppHeader.seq (ofl echoes it).
    • Payload = the whole contract message (AppHeader + body). The reply is the whole contract reply frame, RDMA-written into one registered 64 KiB range.
    • service dspark-v1, max_inflight 1, max_req_bytes 16 MiB (worst case STEP: 32 + 192 + 512 × 30,720 B = 15.7 MB), max_resp_bytes 64 KiB.
    • Ports: 18620 = drafter on the Spark A link (server 10.202.28.1, client binds 10.202.28.2); 18621 is reserved for the Spark B link. O1 uses 18610 (Engram) and 18600–18601 (self-tests).
    • An ofl timeout breaks the connection before returning (the QP goes to error), so a late reply never lands. The proxy then reconnects (same session) and resyncs (§7.5).

    Endpoints, as the Spark proxy writes them: o1rdma://10.202.28.1:18620?bind=10.202.28.2 (RDMA RC) and o1tcp://… (ofl's TCP backend). The contract's own TCP stub is tcp://… (§5 first bullet).

6. Messages (little-endian; all offsets in bytes)

6.1 AppHeader (32 B, every message)

off type field value
0 u32 magic 0x31505344 (bytes D S P 1)
4 u16 version 1
6 u16 op 1 HELLO, 2 STEP, 3 RESET, 4 STATS, 5 PING
8 u64 seq client sequence number, strictly increasing within a session (gaps allowed); the reply echoes it
16 u32 session nonzero random id chosen when the Spark proxy starts; the reply echoes it
20 u32 body_len bytes after the header
24 i32 status 0 in requests; frame status in replies (§9)
28 u32 flags 0 (reserved)

6.2 HELLO (op 1): request body 192 B

off type field live value
0 u32 proto_min 1
4 u32 proto_max 1
8 u32 num_spec (N) 5
12 u32 window (W) 128
16 u32 hidden 5120
20 u32 n_taps 3
24 u32[4] tap_layer_ids 37, 38, 39, 0
40 u32 payload_kind 1 = TAPS_BF16 (2 = MAINX_BF16 reserved)
44 u32 row_bytes 30720
48 u32 noise_token_id 128799
52 u32 vocab_size 129280
56 u32 max_model_len 327680
60 u32 max_slots 4 (max_num_seqs)
64 u32 max_rows_per_step 512 (= max_slots × W)
68 u32 sample_mode 1 = GREEDY (argmax of base + Markov bias, lowest index on ties, confidence head on)
72 u32 tp_size 2 (informative)
76 u32 reserved 0
80 u8[32] drafter_identity sha256 (§6.7)
112 u8[32] markov_identity sha256 of the bytes of markov_top_tokens.json (zeros if no Markov cache)
144 u8[48] client_name ASCII, NUL-padded

HELLO reply body (128 B), status 0 or an error from §9:

off type field
0 u32 proto (chosen)
4 u32 max_slots
8 u32 max_rows_per_step
12 u32 capabilities (bit0 TAPS_BF16, bit1 MAINX_BF16, bit2 HAS_MARGIN)
16 u32 window
20 u32 num_spec
24 u8[32] drafter_identity (the worker's)
56 u8[32] markov_identity (the worker's)
88 u8[40] worker_name (ASCII)

The worker rejects the HELLO in these cases:

  • IDENTITY_MISMATCH if either identity differs;
  • CONFIG_MISMATCH if N, W, hidden, n_taps, tap ids, row_bytes, noise id, vocab or sample_mode differ, or the payload kind is unsupported.

A HELLO with a new session id makes the worker drop all state of any previous session. The worker serves one client. A HELLO with the current session id (a reconnect) keeps the windows, but the client resyncs every live slot anyway (§7.4).

6.3 STEP (op 2): the per-round message

Request body:

StepHeader (32 B)
  0  u64 step_id      rank-0 engine step counter (monotonic; echoed; informative)
  8  u16 n_req        1..max_slots
 10  u16 step_flags   bit0 WANT_MARGIN (return top1-top2 margins, §6.3 reply), others 0
 12  u32 n_rows       total rows in the row block
 16  u32 row_bytes    = HELLO row_bytes
 20  u32 rows_offset  offset of the row block from the start of the body, multiple of 64
 24  u32 reserved[2]
ReqEntry[n_req] (32 B each), batch order
  0  u8  slot         request-state index 0..max_slots-1 (unique within a STEP)
  1  u8  req_flags    bit0 NEW_GEN  discard the slot's window before applying rows
                      bit1 NEED_DRAFT  return drafts/confidences (0 = partial prefill chunk: append only)
                      bit2 RESYNC  the rows are a re-send of the window from the Spark's ring (informative)
  2  u8  k_window     drafts the target verified for this request in the step that produced these rows
                      (0 for prefill; informative, for worker acceptance stats)
  3  u8  n_out        draft positions the Spark will consume, 0 = all N (v1.3; sent as 0 before). The worker
                      still runs the full N-token block, so positions < n_out are bit-identical to a full-block
                      draft; it may skip head / Markov / confidence for positions >= n_out, which it returns
                      as draft 0, confidence 0.0, margin 0.0
  4  u32 generation   per-slot generation (§7.1)
  8  i64 first_pos    position of this request's first row (ignored when n_rows == 0)
 16  u32 n_rows       rows of this request in the row block: valid context rows only, 0 <= n_rows <= W
 20  i32 anchor_token token at anchor_pos
 24  i64 anchor_pos   last valid context position + 1 (= first_pos + n_rows when n_rows > 0)
zero padding up to rows_offset
Row block: n_rows x row_bytes; entries in ReqEntry order, positions ascending within an entry

Reply body:

ReplyHeader (32 B)
  0  u64 step_id        echo
  8  u16 n_req          echo
 10  u16 num_spec       N
 12  u32 reply_flags    bit0 HAS_MARGIN
 16  u32 worker_us      apply + draft compute time, µs
 20  u32 recv_to_send_us  µs from request fully received to reply handed to the transport
 24  u32 reserved[2]
ReqStatus[n_req] (8 B): u16 status (§9.2), u16 window_rows, u32 generation (echo)
draft_ids  : i32 [n_req][N]   target vocabulary ids (full-vocab drafter; no remap)
confidence : f32 [n_req][N]   sigmoid outputs in [0, 1]
margin     : f32 [n_req][N]   only if HAS_MARGIN: top1 − top2 of (base + Markov) logits at each step

For entries without NEED_DRAFT, and for entries with a nonzero per-request error, the worker writes draft ids 0 and confidences 0.0. The same applies to positions ≥ n_out (§6.3 ReqEntry).

Reply values the Spark refuses (v1.4). The drafts become the target's input ids on both ranks and the confidences feed the depth policy, so rank 0 checks every reply before anything is broadcast. Besides the shape and the echoes (step_id, n_req, num_spec, per-entry generation), a reply is refused when, for any entry:

  • status is not a §9.2 code;
  • window_rows > min(W, anchor_pos); or, for a NEED_DRAFT entry, status OK with window_rows != min(W, anchor_pos) or PARTIAL_WINDOW with window_rows ≥ min(W, anchor_pos) (§7.2 step 5);
  • a consumed position (NEED_DRAFT, status OK or PARTIAL_WINDOW, position < n_out) has a draft id outside [0, vocab_size), a confidence that is not a finite value in [0, 1], or (HAS_MARGIN) a margin that is not finite and ≥ 0;
  • any other position has a nonzero draft id, confidence or margin.

A refused reply is a failed round (counter fallback_bad_reply, last_bad_reply in the stats): no draft of that reply is used, rank 0 drops the connection, and after the reconnect every live slot resyncs (§7.4).

n_out (v1.3). The Spark sends n_out = cap[n] when a dynamic speculation schedule is set and the depth policy runs a mode that masks past the cap (conf, confb or confr, with no VLLM_DSPARK_DEPTH_FILE, which could switch the mode at runtime). All of these are live, with schedule [[1,2,5],[3,4,4]], so n_out is 4 at 3–4 requests. Otherwise it sends 0 (all N): fixed:<d> and off verify by the scheduler window alone, which can exceed cap[n] when the next step has fewer requests. The shadow policy always sends 0, so parity covers every position. VLLM_DSPARK_REMOTE_N_OUT=0 turns n_out off. In the cases where n_out is sent, nothing reads the positions ≥ cap:

  • DSparkDepthPolicy.record zeroes the confidences at positions ≥ cap[num_reqs] of the current step before choosing depths (depth_policy.py:432-434).
  • Rows past a request's depth are dead and never accepted (model_runner.py:1528-1540, 1693-1698), even when the next step has fewer requests.
  • Calibration only uses positions below the verified depth.

Measured by lane O2: at 4 requests × n_out 4 the head runs 16 instead of 20 rows (a 16-row instead of a 32-row EXL3 tile), saving ≈ 0.55 ms per round.

6.4 RESET (op 3)

Body u32 slot_mask (bit i = slot i; 0xFFFFFFFF = all). The worker discards those windows. Reply: empty body.

6.5 STATS (op 4)

Empty body. Reply body: UTF-8 JSON of worker counters. Suggested keys: steps, rows, drafted_requests, partial_windows, stale_generation, bad_entries, worker_us_p50/p99, accepted_hist (from n_rows−1 against k_window), gpu_mem_bytes, session, uptime_s.

6.6 PING (op 5)

Empty body. Reply: empty body, status 0. Used for health checks and RTT probes. A PING never changes state.

6.7 drafter_identity

sha256 over the lines "<basename> <sha256-hex>\n", sorted by basename, for every source file the worker reads tensors or configuration from. For the live build these are:

  • config.json, exl3_moe_plan.json;
  • model-00002, model-00043 … model-00046-of-00046.safetensors;
  • model-exl3-head.safetensors, model-exl3-mtp.safetensors, model-exl3-mtp-experts.safetensors.

Each file's sha256 is the Spark-side source hash that lane S2 recorded in DSPARK_COPY_DONE (files[].sha256; x86host /data/dsv41-offload/dspark/DSPARK_COPY_DONE). For the eight tensor files this equals the deployment's pinned manifest.json. config.json and exl3_moe_plan.json are legal-v2a's own and are pinned by the receipt. offload/dspark/spark/tools/identity.py computes the digest from the receipt; the coordinator passes it to the Spark as VLLM_DSPARK_REMOTE_IDENTITY.

The worker computes the same digest from the same receipt. It must also check that the extracted files it actually loads match the receipt's extracted_files[].sha256, which is how its tensors are tied to these sources. A worker built from the baseline checkpoint, or any other one, is refused at HELLO.

Live values (legal-v2a, 2026-10-22):

  • drafter_identity = be2c1436fa9f9e66b398e1b1da2e140e6fddc82a11939e2ac4240a0ca79543bd
  • markov_identity = 0b6e71e638abd5abce2db433965bb5ff523cbbe92ed7ae54324a50ccaeffa19a, the sha256 of the live markov_top_tokens.json. It is byte-identical on both Sparks and on x86host.

7. State and lifecycle

7.1 Slot, generation and the Spark's bookkeeping (rank 0)

  • The worker keeps per slot: generation, the contiguous position range [lo, hi] of the rows it holds (or empty), and the row data keyed by pos mod W.
  • Rank 0 keeps per slot: owner (request id), generation, the same [lo, hi], the device ring rows (§7.3) and a dirty bit.
  • Each round, for each request in the batch:
    • If its request id differs from the slot's owner, the slot was reused (finish, abort, preemption or a new request). Rank 0 sets owner, increments generation (u32, wraps; compared by serial-number arithmetic), clears the ring range and sends NEW_GEN.
    • Nothing else changes a slot's generation on rank 0 (failures, resyncs and reconnects keep it), so within a session rank 0 never sends a slot a generation older than one it sent before, with or without NEW_GEN.
    • If the same request comes back at a lower position (re-add after preemption, streaming-input update, prefix replay), first_pos ≤ hi. Both sides roll back: rows at positions ≥ first_pos are dropped (§7.2). Rows below stay valid because a causal hidden state depends only on earlier tokens. The generation does not change.
  • No CLOSE message exists. A finished request's window stays on the worker until its slot is reused, which takes at most max_slots × (W × row_bytes + KV) of worker memory. Cancellation and abort are therefore implicit. An explicit RESET is available.

7.2 Worker apply rule (per ReqEntry, in order)

  1. If generation is older than the slot's: status STALE_GENERATION; ignore the entry (the window is kept). This check comes first and holds whatever the flags: NEW_GEN never makes an older generation acceptable. If it is newer, or NEW_GEN is set (with the slot's own generation: a resync): empty the window and adopt the generation.

  2. Validate: 0 ≤ n_rows ≤ W; first_pos ≥ 0; if n_rows > 0 then anchor_pos == first_pos + n_rows; the rows fit in the block. On failure: BAD_ENTRY.

  3. Apply rows [p0, p0+n):

    • if the window is empty, or p0 > hi + 1 (a gap): lo = p0;
    • else (p0 ≤ hi + 1): drop rows ≥ p0, i.e. roll back, and set lo = min(lo, p0);
    • then hi = p0 + n − 1 and lo = max(lo, hi − W + 1).

    If n_rows == 0: require hi + 1 ≥ anchor_pos, then roll back to hi = anchor_pos − 1.

  4. After applying, require hi == anchor_pos − 1, else BAD_ENTRY. Then window_rows = hi − max(lo, anchor_pos − W) + 1 and expected = min(W, anchor_pos).

  5. If NEED_DRAFT:

    • draft with queries [anchor_token, noise × (N−1)] at positions min(anchor_pos + j, max_model_len − 1), j = 0..N−1, over the window rows;
    • status PARTIAL_WINDOW if window_rows < expected. This is non-fatal: the drafts are produced but degraded, and the verifier keeps the output exact. Otherwise status OK.
  6. Draft (query) rows and draft tokens are never inserted into the window. The next round's rows replace them.

7.3 Rank 0's device ring (the window's backup copy)

  • Rank 0 writes every valid row it sends into ring[slot, pos mod W] with the same [lo, hi] rule, on the GPU. Size: 4 slots × 128 × 30,720 B = 15 MiB, rank 0 only.
  • This ring is rank 0's own copy of every live window. The worker's copy is a cache that rank 0 can rebuild at any time. In capacity mode the 15 MiB counts as retained memory.

7.4 Resync (failure recovery without a local drafter)

Rank 0 marks a slot dirty when:

  • a STEP that carried rows for it failed, timed out or got no reply (rank 0 cannot know whether the worker applied it);
  • after a reconnect, NO_SESSION or STALE_SEQ;
  • the slot got a per-request status other than OK.

The next time a dirty slot appears in a batch, rank 0 sends NEW_GEN | RESYNC with the slot's whole ring window. That means rows [max(lo, anchor_pos − W), hi], already including this round's new rows. The worker's window then holds exactly the rows it would have held with no failure. Its derived KV is recomputed in a different batch shape (≤128 rows at once instead of 1–6 per step), and EXL3 kernel rounding depends on the batch size, so drafts after a resync can differ at near-ties (lane O2 observed 2 of 5 positions in one test). The verifier keeps the output exact; only proposals can change. No content-addressed prefix store is needed while SWA bounded replay is on (§2). If a future config turns bounded replay off, a hit at h leaves [h − 128, h − 1] without rows: the worker reports PARTIAL_WINDOW and rank 0 counts it. A PREFIX_RESTORE op is reserved (op 6) for that case and is not part of v1.

7.5 Sessions, sequence numbers, connections

  • session: a random nonzero u32 per Spark proxy start. It also changes after an engine restart.
  • seq: starts at 1 and strictly increases. The worker records the last applied seq and rejects seq ≤ last with STALE_SEQ, without applying the frame. The client never retries a STEP; after a failure it sends a new STEP (with RESYNC) under a new seq.
  • Timeout: the client closes the connection and later reconnects with the same session, sending HELLO again. Frames from the dead connection that the worker processes later are either already older than the RESYNC (the worker processes frames in the order received) or rejected by seq.
  • The worker accepts a new connection on a listener by closing the previous one on that listener. Newest wins.

8. Budgets

8.1 Bytes per round (TAPS_BF16, 30,720 B/row; headers excluded except where shown)

case rows sent (max) request reply
decode, 1 request, k=5 1 + accepted ≤ 6 ≤ 184,320 B + 96 B headers 32 + 8 + 40 = 80 B
decode, 2 requests, k=5 ≤ 12 ≤ 368,640 B 32 + 16 + 80 = 128 B
decode, 3 requests, k=4 ≤ 15 ≤ 460,800 B 32 + 24 + 120 = 176 B
decode, 4 requests, k=4 ≤ 20 ≤ 614,400 B 32 + 32 + 160 = 224 B
one 4,096-token prefill chunk (1 request) 128 3,932,160 B 80 B
a step holding r prefill requests (r ≤ 4) 128 each r × 3.93 MB
without the trailing-window rule (for contrast) 4,096 125.8 MB

At the nominal 40 Gb/s (≈ 4.6 GB/s effective) these come to:

  • 40 µs for one 6-row decode request, 134 µs for 4 requests;
  • 0.85 ms per prefill request-chunk.

Lane S1's measured latency and throughput replace these figures. With main_x the numbers would be one third (§3).

8.2 Memory

where item size
Spark rank 0 device ring (§7.3) 15 MiB
Spark rank 0 device staging (gather + ring-resync stage) 2 × 512 rows × 30 KiB = 30 MiB, bounded by max_rows_per_step
Spark rank 0 pinned message buffer (rows sent zero-copy over TCP; copied once into the ofl send slot) 15 MiB
Spark rank 0/1 broadcast tensor < 1 KiB
worker context rows 4 × 128 × 30 KiB = 15 MiB (or the derived KV, worker's choice)
worker per-step input ≤ 512 rows × 30 KiB ≈ 15 MiB

9. Status codes

9.1 Frame status (AppHeader.status in replies)

code name client action
0 OK
1 BAD_VERSION fatal configuration error: log, disable remote (policy fallback)
2 IDENTITY_MISMATCH fatal: log both digests, disable remote
3 CONFIG_MISMATCH fatal: log, disable remote
4 NO_SESSION HELLO, then resync all live slots
5 STALE_SEQ the frame was not applied: HELLO, resync
6 MALFORMED round failed: resync the slots in that STEP; count it
7 CAPACITY round failed (too many rows or slots): resync; count it
8 INTERNAL round failed: resync; count it
9 UNSUPPORTED_OP fatal: disable remote

9.2 Per-request status (ReqStatus.status)

code name meaning client action
0 OK drafts valid use them
1 PARTIAL_WINDOW drafts valid, window had fewer than expected rows use them; count it; mark the slot dirty (resync next round)
2 STALE_GENERATION entry ignored zero drafts for this request this round; dirty. Rank 0 never sends an older generation (§7.1), so the worker's state does not match rank 0's: rank 0 starts a new session (a HELLO with a new session id drops every window, §6.2) and resyncs every live slot (v1.5)
3 BAD_ENTRY entry rejected (positions or sizes) zero drafts this round; dirty; log the entry
4 INTERNAL the worker failed on this entry zero drafts this round; dirty

10. Failure policies (Spark side; VLLM_DSPARK_REMOTE_POLICY)

The per-round deadline is VLLM_DSPARK_REMOTE_TIMEOUT_MS, default 50 ms. A normal round is a few ms; one verify step is 33–48 ms.

policy local drafter on a failed round memory
capacity not allocated: no draft weights, graphs or draft KV layers drafts = 0, confidences = 0.0. Depth policy conf then picks depth 0 (target-only cost). Counted. The circuit breaker below applies. VLLM_DSPARK_REMOTE_ON_FAIL=raise raises instead (engine-fatal; for benchmarks) freed: drafter weights, Markov cache, draft graphs and workspace, draft SWA KV layers. Retained: 15 MiB ring + staging
resilience kept, its context KV updated every round (eager precompute of the new rows) that round runs the local drafter (the decision is broadcast, so both ranks run it); the circuit breaker below applies nothing freed; the local drafter is retained and reported as not freed
shadow kept, full local propose every round n/a remote and local proposals compared and logged (per-position agreement, first-mismatch histogram, margins). Local drafts are used unless VLLM_DSPARK_REMOTE_SHADOW_USE=remote

Circuit breaker (rank 0, every policy; v1.6). A worker that answers but keeps failing would otherwise cost every engine round its round trip (frame errors, per-request errors) or, after each reconnect, one full deadline (timeouts). Rank 0 therefore counts consecutive failed attempts: rounds that sent a STEP and got a timeout, a transport error, a refused reply (§6.3), a frame error, or per-request errors for every request that needed drafts.

  • After VLLM_DSPARK_REMOTE_TRIP failed attempts in a row (default 3), the breaker opens. The next VLLM_DSPARK_REMOTE_COOLDOWN engine rounds (default 256) send nothing. They fail fast like a round without a connection: capacity zero drafts, resilience the local drafter, shadow no comparison. They are counted as fallback_cooldown.
  • After the cooldown, the next round that sends a STEP with a request that needs drafts is a probe. Success closes the breaker; failure opens it again for another cooldown.
  • The cooldown counts engine rounds, connected or not. These rounds are neither failures nor successes (v1.8):
    • rounds that send nothing (no connection, nothing to send, or a connection newer than the one the round's entries were built for);
    • rounds whose STEP succeeds but has no request that needs drafts (prefill appends only). They never exercise drafting, so they neither reset the count nor close a probe. If such a STEP fails, it is a failure.
  • A success resets the count. A round where only some requests failed is a success.
  • Rank 0 keeps its ring exact while the breaker is open. Every slot of a skipped round is marked dirty, so the probe resyncs those windows (§7.4).
  • Transport failures also keep the client's wall-clock reconnect backoff: 1 s, doubling up to 30 s, reset by a successful HELLO.
  • VLLM_DSPARK_REMOTE_TRIP=0 turns the breaker off: every round tries again, as before v1.6.
  • Steady-state cost with a worker that times out on every STEP: one 50 ms deadline per 257 rounds (≈ 9 s at 35 ms/round, ≈ 0.5 %). Without the breaker it is one deadline per reconnect cycle (≈ 1 s, ≈ 5 %).

A failure in the middle of a request is safe in every policy:

  • capacity only loses proposals for that round. The target's KV and the verifier are untouched, and the worker's window is rebuilt from rank 0's ring (§7.4).
  • resilience has the local context KV in sync every round, so the local drafter can take over at any round boundary.

11. What the worker must match numerically (lane O2)

  • Same tensors: the identity in §6.7. Same main_proj (EXL3 K5) and main_norm.
  • Context KV computed exactly like precompute_and_store_context_kv: wkv from the fused wq_a|wkv, kv_norm, RoPE at the context positions. RoPE parameters are those of the drafter's attention layers (target-config RoPE; compress_ratio = 0 layers).
  • Query block: [anchor, noise×4], non-causal inside the block, window 128.
  • The draft MoE: 128 experts, top-3, plus the shared expert.
  • mHC with hc_mult 4, ending in hc_collapse with the last layer's FFN pre-mix.
  • Base logits = the target's head (model-exl3-head, EXL3 K6) over norm(head_hidden).
  • Sequential Markov bias from the previous sampled token, using the cached rows of markov_top_tokens.json (markov_cache.py) where they exist and the GEMM path elsewhere, like markov_bias_for_ids.
  • Greedy argmax with lowest-index ties.
  • Confidence from cat(head_hidden, markov_embed) per position.
  • Representation changes are allowed only on the drafter side, and must be recorded (e.g. bf16 window KV instead of the Spark's fp8 record).

12. Optional extensions (reserved, not v1)

  • PREFIX_RESTORE (op 6): content-addressed window restore, for configurations without SWA bounded replay.
  • A split round, STEP_CTX + STEP_ANCHOR: send the taps before sampling finishes, so the worker can precompute context KV while the Spark samples. Worth ≈ the tap transfer time per round.
  • Payload kind 2 (MAINX_BF16), if measurements reverse §3.
  • Probabilistic drafting (draft probabilities over the 129,280-entry vocabulary are ~2.5 MB per request per round) is out of scope. The remote speculator refuses to start unless draft_sample_method == "greedy", with no dspark_draft_topk and no watermarking.

13. Change log

version date change
1.0 2026-10-22 first version, derived from the live proposer (lane O3)
1.1 2026-10-22 wire unchanged (version stays 1). §5: O1 ofl mapping, limits and ports (18620). §6.7: identity from Spark-side source hashes; the worker must tie its extracted files to them; live digests recorded.
1.2 2026-10-22 wire unchanged. §7.4: after a resync the window rows are exact, but drafts may differ at near-ties because KV is recomputed in another batch shape (lane O2). §8.2: rank-0 buffers corrected (45 MiB device, 15 MiB pinned). §4.2: async variant described.
1.3 2026-10-22 ReqEntry byte 3 = n_out (proposed by lane O2). Backward compatible both ways (old senders send 0 = all N; old workers ignore it), so the wire version stays 1. Positions ≥ n_out return zeros; positions < n_out equal the full-block draft.
1.4 2026-10-22 Wire format unchanged (version stays 1). §6.3: the Spark now refuses replies with values the target must not see (draft ids outside the vocabulary, confidences outside [0, 1] or not finite, bad margins, unknown status, window_rows out of bounds or inconsistent with the status, a nonzero n_out tail or nonzero drafts for unconsumed entries) as a failed round + resync (S5 #3). A worker that follows §6.3 and §7.2 is unaffected (lane O2's DraftCore passes O3's suites unchanged).
1.5 2026-10-22 Wire format unchanged. §7.2 step 1 made explicit: an older generation is STALE_GENERATION even with NEW_GEN (S5 #2; the reference worker exempted NEW_GEN, now fixed; lane O2 fixes its worker). §7.1: rank 0's slot generation changes only on a new owner, so it never sends an older one. §9.2: on STALE_GENERATION rank 0 starts a new session and resyncs, since a same-session resync would stay stale.
1.6 2026-10-22 Wire format unchanged. §10: the circuit breaker 1.0–1.5 promised (VLLM_DSPARK_REMOTE_TRIP 3, VLLM_DSPARK_REMOTE_COOLDOWN 256 rounds) is now implemented as specified (S5 #5: it did not exist; frame errors were retried every round). The text now states exactly which rounds are failed attempts, which are neutral, and how the probe and the resync work; "reconnect + HELLO" after the cooldown is replaced by "one probe round" (transport failures reconnect on their own backoff).
1.7 2026-10-22 Wire format unchanged; explanation corrected (S5 optional). §2 / §4.3 described the draft window with the causal per-token formula (127 context rows). The DSpark block kernel (sparse_swa.py:1253-1255) anchors one 128-row context window [anchor_pos − 128, anchor_pos − 1] to the whole block, plus the full query block. The rules themselves (W = 128 rows kept and sent, expected = min(W, anchor_pos)) were already 128-row; lane O2's worker matches the non-causal kernel.
1.8 2026-10-22 Wire format unchanged. §10 refined after an independent review of the 1.6 breaker: a successful STEP that carries no request needing drafts (prefill appends only) is neutral, so it cannot reset the failure count or close a probe without drafting having worked. A STEP whose connection is newer than the epoch its round was built for is refused on rank 0 (nothing sent, neutral) and the next round resyncs.