Skip to content

feat(transfer)!: encode per-slot schema + NUMA source map in QueryBlocksForTransfer#354

Open
xiaguan wants to merge 8 commits into
masterfrom
feat/query-schema-source-map
Open

feat(transfer)!: encode per-slot schema + NUMA source map in QueryBlocksForTransfer#354
xiaguan wants to merge 8 commits into
masterfrom
feat/query-schema-source-map

Conversation

@xiaguan

@xiaguan xiaguan commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

What & why

QueryBlocksForTransfer previously returned per-block / per-slot RDMA metadata as nested protobuf (TransferBlockInfo / TransferSlotInfo), with the wire size scaling as blocks × slots. This branch replaces that with a compact, run-encoded TransferPlan (new pegaflow-transfer-wire crate, postcard-encoded into a single bytes transfer_plan field): the response carries a per-slot segment schema plus a per-slot NUMA source map once, and the requester rebuilds each block's slabs on the NUMA the holder actually used.

The "schema source map" point: the holder tells the requester not just where the bytes live, but which NUMA each slot is on, so the cross-node RDMA fetch materializes and rebuilds NUMA-correctly instead of landing everything on one node.

What's in it (5 commits)

  • feat: TransferPlan wire encoding — new pegaflow-transfer-wire crate + transfer_plan/{encode,schema}; QueryBlocksForTransferResponse carries bytes transfer_plan (postcard) instead of nested messages. Wire size scales with blocks, not blocks × slots.
  • fix: split oversized RDMA reads at 128MB + fix: enable IBV_ACCESS_RELAXED_ORDERING — RDMA read-path fixes the plan-driven fetch depends on.
  • feat: per-slot NUMA source map — thread holder-side per-slot NUMA through transfer_plan/{view,merge,schema,rebuild} + materialize; the requester allocates one slab per distinct chunk NUMA and rebuilds each slot on its source NUMA. Rebuild rejects UNKNOWN NUMA (crash-early; encode warns and the fetch boundary logs + meters the error, so the degradation is never silent). Restores SealedBlock::from_slots to build with empty slot_numas (SSD-prefetch path; was regressed into a guaranteed panic). Adds the p2p RDMA integration coverage.
  • test: assert rebuild rejects unknown NUMA loudly — locks in the crash-early guard so it can't silently regress.

Also: the kernel-backend integration test now self-skips when the driver can't JIT-load its PTX (older driver vs newer CUDA toolkit) instead of failing the suite — it still runs wherever the driver supports the kernel.

Breaking change

⚠️ Wire-incompatible: QueryBlocksForTransferResponse fields are renumbered and TransferBlockInfo / TransferSlotInfo are removed in favor of bytes transfer_plan. Holder and requester must be upgraded together.

Testing

  • Local gate green (cuda-13,rdma): cargo fmt / cargo clippy -D warnings / cargo test --release all pass.
  • Unit coverage in transfer_plan::tests: per-block NUMA preservation through rebuild, one slab per distinct chunk NUMA, loud UNKNOWN-NUMA rejection, encode→materialize→rebuild byte round-trip, wire-size scaling.
  • p2p RDMA integration tests run on an 8-GPU, 2-NUMA-socket node with 400G InfiniBand — all 4 pass, byte-exact:
    • normal MHA TP>1 → cross-slot multi-NUMA exercised (distinct={0,1})
    • MLA replica (no DCP) → cross-block multi-NUMA via server save-NUMA round-robin (distinct={0,1})
    • single-writer baseline
    • The multi-NUMA cases self-adapt to single-NUMA boxes (skip the cross-NUMA assertion, still verify topology + data roundtrip).

Note

Related transfer work is in #350 (run-encode QueryBlocksForTransfer addresses) on a separate branch — no shared commits, but both touch the transfer fetch path, so mind merge order.

🤖 Generated with Claude Code

xiaguan and others added 7 commits June 15, 2026 18:18
…sfer

Introduce pegaflow-transfer-wire and transfer_plan module to run-encode
block placements, materialize RDMA reads with coalescing, and shrink
the query RPC metadata path.

Co-authored-by: Cursor <cursoragent@cursor.com>
Coalesced transfers above ~1GiB hit LOC_LEN_ERR on mlx5; split each
TransferDesc inline before building RdmaOps.

Co-authored-by: Cursor <cursoragent@cursor.com>
Relax PCIe ordering for pinned host MRs on the rc_backend path.

Co-authored-by: Cursor <cursoragent@cursor.com>
Thread holder-side per-slot NUMA from SealedBlock through the transfer
plan (view/merge/schema/rebuild) so the requester rebuilds each block's
slabs on the NUMA the holder used. Remote chunks carry numa_node and
rebuild rejects UNKNOWN (crash early).

Add p2p RDMA integration coverage for the slot->NUMA shapes the source
map must reproduce:
- normal MHA TP>1: cross-slot NUMA (each rank owns a slot column,
  saves with its own GPU's NUMA; a single block spans NUMA)
- MLA replica (no DCP): cross-block NUMA via the server save-NUMA
  round-robin (one save RPC per block, rotating the candidate NUMA)
Both self-adapt: on a single-NUMA box they still verify the multi-worker
topology and the data roundtrip, logging that cross-NUMA was not exercised.

Fixes uncovered while wiring the tests:
- SealedBlock::from_slots builds with empty slot_numas again instead of
  routing through the strict-equal from_slots_with_numas (which panicked
  for every non-empty block). Matches the ssd_cache "empty OR full"
  contract; SSD-prefetch blocks keep their NUMA in SlotMeta.
- transfer_plan round-trip fixture seeds a known NUMA so rebuild's UNKNOWN
  guard does not trip; deduped the two stride-block helpers.
- kernel-backend integration test skips when the driver cannot load its
  PTX (older driver vs newer CUDA toolkit) instead of failing the suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-slot NUMA source map's crash-early guard (rebuild errors on an
UNKNOWN remote-chunk NUMA) was only exercised incidentally before. Lock it
in so it cannot silently regress into accepting UNKNOWN and materializing
on an arbitrary node. Encode also warns (view.rs) and the production fetch
boundary logs + meters the error (rdma_fetch), so the degradation is
observable end to end, never silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The QueryBlocksForTransfer handler locked the found blocks inside the
engine call, then encoded the TransferPlan. Encoding was fallible but the
handler propagated its error with `?`, so on failure the requester never
received the session id and could never release the lock — the blocks
stayed pinned against LRU eviction until the 120s lock GC swept them.

Release the lock explicitly when encoding fails. To make that release
meaningful, the encode path is now genuinely panic-free: block_views_from_found
and derive_slot_schemas return Result, and the reachable cross-block geometry
mismatches (a namespace shared by incompatible KV layouts) become Err instead
of asserts that would unwind the task and leak the lock anyway. This also fixes
a latent out-of-bounds panic in derive_slot_schemas, which indexed every block
by block 0's slot/segment shape before validating that the shapes matched.

Remove dead code surfaced along the way:
- SlotSchema.numa_node was write-only; NUMA lives on RemoteChunk. Drop the
  wire field and its sole writer.
- assign_chunks ran a second coalesce pass whose `last.end == start` condition
  the first pass (`start <= last.end`) already subsumes. One pass coalesces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A coalesced QueryBlocksForTransfer plan hands the backend one big
TransferDesc per NUMA node. batch_transfer_async mapped each desc to a
single NIC (and, via the per-desc QP bucket, a single QP), so a multi-GB
read saturated one NIC while the rest sat idle — large-segment p2p
throughput collapsed to ~1/4 (one NUMA group's worth of NICs).

Stripe each desc into NIC_STRIPE_BYTES (8 MiB) sub-ranges and round-robin
them across its NUMA node's NICs, so one large read fans out over every
NIC and QP in the group. The upper layer stays agnostic: it submits byte
ranges; fanning them across the fabric is the transfer library's job.

Single node, 8x400G NIC loopback, ~10 GiB/set (median GiB/s):

  segment   master   branch   branch+fix
  4 KiB      7.14    10.33    15.72
  64 KiB    66.01    25.24    95.05
  256 KiB  104.67    26.75   123.33

Adds a hardware-free unit test asserting a large desc touches every NIC
in its group.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@xiaguan

xiaguan commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

Perf: large-segment NIC-striping fix (cedfc25)

While validating this branch's p2p RDMA fetch path I found (and fixed in cedfc25) a large-segment throughput regression vs master.

Root cause. The coalesced TransferPlan hands the transfer backend one big TransferDesc per NUMA node. batch_transfer_async mapped each desc to a single NIC (and, via the per-desc QP bucket, a single QP), so a multi-GB read saturated one NIC while the rest sat idle — aggregate throughput capped at roughly one NUMA group's worth of NICs.

Fix. Stripe each desc into 8 MiB sub-ranges, round-robin across its NUMA node's NICs (and their QPs), so one large coalesced read fans out over the whole group. The upper layer stays agnostic — it submits byte ranges; fanning them across the fabric is the transfer library's job.

Measured — single node, 8×400G IB NICs, 2 NUMA sockets, NIC loopback, ~10 GiB/set, median of 3 measured sets (box under other load, so absolute numbers are loopback+contention-bound; read the relative A/B/C):

segment master this branch (pre-fix) this branch (+fix)
4 KiB 7.14 10.33 15.72
64 KiB 66.01 25.24 95.05
256 KiB 104.67 26.75 123.33

Pre-fix the branch fell to 0.26–0.38× of master at large segments; with the fix it's 1.2–2.2× master across the board — coalescing cuts WQE overhead at the IOPS end, striping restores full NIC parallelism at the bandwidth end.

Caveat: single-node NIC loopback is not wire bandwidth; numbers are for relative comparison only. A unit test (large_desc_fans_out_across_every_nic_in_group) locks in the fan-out invariant without hardware.

The QueryBlocksForTransfer encode path and the requester-side
rebuild_sealed_blocks both built nested per-cell Vec structures
(blocks * slots * 2 ~= 2.6M tiny allocations for a 4500-block batch),
then traversed them. Replace both with flat, column-major SoA:

- encode: BlockView{Vec<SlotView{Vec<SegmentView>}>} -> BlockMatrix with
  contiguous [slot][segment][block] pointer/length buffers, so schema
  derivation and chunk coalescing each scan a contiguous block-ordered
  slice; one reused SegmentPoint scratch buffer across all columns.
- rebuild: drop the Vec<Vec<Vec<Option>>> matrix for two flat buffers
  plus a per-chunk ChunkLoc that validates each placement run span once.

Plan-build CPU cost for 4500 blocks: encode 209ms -> 70ms (3.0x),
rebuild 383ms -> 101ms (3.7x). All validation/error paths preserved;
the segment-count consistency check moved earlier into the matrix builder.

Add a `bench` feature exposing transfer_plan::bench_support (RebuildFixture,
QueryFixture) built from the real encode -> materialize pipeline on a
vLLM-shaped fixture (slots = layers*tp, split K/V), gated so it never ships
in production. Include criterion benches (transfer_query, transfer_rebuild)
and encode_profile/rebuild_profile examples as isolated profiler drivers.

Also slim large_number_of_concurrent_sessions: it allocated one real
cudaHostAlloc mapped pool per dummy block (num_sessions * blocks_per_session
= 500 pools, each reserving ~100MB), OOM-killing the test box and blocking
the pre-commit hook. The lock manager only refcounts opaque Arc<SealedBlock>
handles, so all entries now share one block via Arc::clone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@xiaguan

xiaguan commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

Small-batch regression check

The Performance numbers in the description are large-batch. This confirms tiny fetches (1-block warm hit, MLA single-block read) pay no fixed-overhead penalty from the TransferPlan encode / geometry / postcard work. Same 8-GPU, 2-NUMA, 8×400G node, single-host loopback, warm measure set (median of 4–5), byte-verified on all 8 ranks.

blocks bytes/set master e2e this branch e2e
1 2.2 MiB 2.03 ms 1.59 ms 1.3×
8 18 MiB 3.41 ms 2.20 ms 1.6×
64 144 MiB 16.38 ms 5.87 ms 2.8×

No regression — faster at every size. Even at 1 block the branch wins: master still emits per-block RDMA descriptors (1 block = 576 cells across 36 layers × TP8 × split-K/V) plus nested protobuf, whereas the TransferPlan coalesces to 2 descriptors, so build_transfer_tasks + submit_transfer stay ~0 on the branch vs master's 0.1 ms (1 blk) → ~7 ms (64 blk), and query is cheaper too. The SoA rebuild is serial, so there is no thread-spawn cost on small fetches — the 1-block fixed overhead is just query ~0.15 ms + rebuild ~0.02 ms.

Stage detail at the extremes (ms, measure set, connect amortized to 0):

query build+submit rdma rebuild
1 blk master 0.20 0.16 0.04 0.03
1 blk branch 0.15 ~0.01 0.10 0.02
64 blk master 5.5 ~7.0 1.75 1.4
64 blk branch 0.95 ~0.07 1.0 1.4

The only line item where the branch is ever slightly higher is rdma_wait at ≤8 blocks (sub-0.1 ms of coalesced-read setup), and it is already lower by 64 blocks.

🤖 Generated with Claude Code

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant