feat(server): native KV clients backed by server-owned CUDA arenas - #417
feat(server): native KV clients backed by server-owned CUDA arenas#417xiaguan wants to merge 4 commits into
Conversation
Add a native (non-Python) registration path where the SERVER allocates the KV arena and shares it back through a CUDA IPC handle in the registration response, instead of importing client-exported memory. Why this direction: the goal is NIC (GPUDirect RDMA) registration of KV memory, which only works on memory the registering process owns - an IPC-imported pointer cannot back ibv_reg_mr/dma-buf. Allocating in the server makes the arena RDMA-registerable here, while the client only runs compute kernels on its imported mapping, which CUDA IPC fully supports. Compared to the client-allocates design (#414/#416), the 64-byte IPC handle travels inside the existing gRPC response, so the SCM_RIGHTS fd side-channel, its socket lifecycle, and the out-of-band fd/RPC ordering all disappear; registration is one round trip. - proto: NativeKvTensor layer views + native_alloc_size on RegisterContextRequest, arena_ipc_handle on the response, LoadRequest.wait_for_completion, Flush RPC; version tag +native-arena-v1 - native_arena.rs: cuMemAlloc + zero + cuIpcGetMemHandle, layer views by offset arithmetic, freed on registry drop - registry: native contexts live in a separate map beside the Python bookkeeping; the actor thread runs allocation and teardown - service: native branch in register_context_batch; synchronous native load via the existing inproc engine path; teardown order flipped to engine-unregister-before-registry-drop so raw pointers never dangle - lib: --python-registry=false runs torch-free (cudarc device init) Ownership contract: the server owns the arena for the lifetime of the registration. A client that loses the server must exit; there is no reconnect. Client death leaks nothing - the arena is freed by unregister, session cleanup, or HTTP cleanup. E2E on a real GPU: register -> child process imports the handle, writes a pattern -> save -> child wipes -> load(wait_for_completion) -> child verifies bit-exact restore -> unregister frees the arena. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With default-features off (the openinfer client's pegaflow-core configuration) both use statements are active and rustc rejects the duplicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-review hardening of the native-arena path: - http_server cleanup now unregisters the engine before the registry drop frees native arenas, matching the ordering the other two teardown paths already enforce (registry-first was a use-after-free window) - all three teardown paths add an engine flush barrier so saves already in the write pipeline drain before cuMemFree; saves still queued inside a GPU worker remain unfenced until the engine-level drain-before-unregister work lands, and the comments now say so instead of overclaiming - native registration re-confirms its registry context after the engine publish and rolls the engine back if a concurrent session/HTTP cleanup freed the arena in between (previously the engine kept raw pointers into freed memory and the orphaned instance was never cleaned up) - cuCtxSynchronize after the arena memset: the zero-fill is host-async and CUDA IPC has no cross-process stream ordering, so the zeroed-arena invariant was unbacked before the handle left the server - native layer-view construction extracted out of the register handler, dropping the side-effecting closure Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ruff-action without a version floats to the newest release; ruff 0.16.0 shipped new UP/PLW rules that fail on untouched example files and broke this PR's CI (master last ran against 0.15.x). Pin the version so lint upgrades are deliberate commits that carry their own fixes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The arena being allocated once at client/server startup and remaining fixed during normal inference looks reasonable; I do not think allocation frequency or fragmentation should block this PR. The remaining blocker is teardown ordering. Please add an engine-level drain-before-unregister operation: stop accepting new work for the instance, wait for both GPU worker queues and their active transfers to complete, remove the engine instance, and only then drop the registry arena. One related contract to confirm/document: before a native client sends |
|
@GentleCold re teardown ordering: Agreed on allocation: fixed-size arena at registration is fine; that is not a blocker. On teardown ordering — we are intentionally not adding engine-level drain-before-unregister for this PR. Client contract: before
Under that contract, GPU workers for the instance have no outstanding work when the engine drops layouts and the registry frees the arena, so the residual window does not apply to a correct client. We are not building a shared-ref shutdown / generation / drain barrier in the engine for this. A previous attempt at full GPU drain-on-unregister was large and messy relative to the real failure mode, which is almost always a client lifecycle bug (or forced session/HTTP cleanup while traffic is still in flight). Session-disconnect and admin cleanup can still race inflight work; that is accepted for now the same way the Python IPC path never fenced GPU workers before unmap — native just makes a buggy client more obviously fatal ( Stream sync before Save: yes — the client must synchronize the CUDA stream that wrote the imported arena before calling So: no drain-before-unregister in this PR; client guarantees idle-before-unregister + pre-Save stream sync. Happy to revisit a minimal disconnect-only join later if forced cleanup races become a real operational issue, but not as a merge blocker here. |
Summary
Adds a native (non-Python) KV registration path with inverted ownership: the server allocates the fused GPU KV arena and shares it back to the client as a CUDA IPC handle inside the registration response. This is a redesign of #414/#416 that keeps their goal (RDMA-capable KV memory) while deleting the fd side-channel and the ownership machinery those PRs needed.
Why invert the ownership
The end goal is registering KV memory into the NIC (GPUDirect RDMA). That registration only works on memory the registering process owns —
ibv_reg_mr/dma-buf on an IPC-imported pointer fails, which is why #414 had the client allocate via VMM and export a POSIX fd. But a POSIX fd cannot travel in a gRPC message, which forced:SCM_RIGHTSside-channel (fd_channel.rs, ~520 lines with its hardening) with an out-of-band(instance_id, device_id)keying protocol, reordering waits, TTLs, and stale-socket recovery;Arc<VmmRegistration>, operation gates, two-phase teardown, spawn shields) to keep the imported mapping alive against client crashes and RPC cancellation.Allocating in the server dissolves both problems at once:
RegisterContextResponse. Registration is one round trip; there is no side-channel, no fd, no ordering invariant.Verified empirically on a real GPU before building this: cross-process
cudaMalloc+cuIpcGetMemHandle→ import → kernel read/write in both directions, bit-exact.Changes
NativeKvTensor{offset,size,block_stride}views +native_alloc_sizeonRegisterContextRequest;arena_ipc_handleon the response;LoadRequest.wait_for_completion;FlushRPC; version tag+native-arena-v1.native_arena.rs(new, isolated):cuMemAlloc+ zero +cuIpcGetMemHandle; layer views arebase + offsetarithmetic; freed on registry drop, on the registry actor thread.register_context_batch(both paths converge on the pre-existingregister_context_layer_batch_strided); synchronous native load via the pre-existing_inprocengine path; teardown order flipped to engine-unregister-before-registry-drop so raw pointers never dangle over a freed arena.--python-registry=false: torch-free startup (cudarc device init), serving native clients only. Defaulttruekeeps the vLLM connector path identical._stridedregistration and_inprocload already existed on master.Diff is +937/−79 vs #414+#416's +2712/−751, with near-zero churn on the Python path.
Scope (native v1)
One GPU, one fused arena, multiple layer views per registration (
tp_size=1,world_size=1enforced). Multi-arena (GLM5.2) is out of scope and fails validation early. Same-host only — CUDA IPC is host-local, same constraint as the fd design.Testing
native_arena_rpc_e2eon a real GPU: register → a child process imports the handle (CUDA forbids importing in the exporting process) and writes a pattern → save → child wipes →wait_for_completionload → child verifies bit-exact restore → unregister frees the arena.cd python && uv run pytest— 202 passed.cpu_roundtripand Qwen3-4Bkv_offload_cpu_hit(HBM-evicted prefix restored from the CPU tier) pass against this server in torch-free mode on an RTX 5070 Ti.Review hardening (second commit)
An adversarial review pass surfaced and fixed: HTTP-cleanup freeing arenas before the engine forgot its pointers (the one teardown path with the old ordering); a save-pipeline flush barrier on all three teardown paths (saves still queued inside a GPU worker remain unfenced until the engine-level drain-before-unregister lands — the comments state this window explicitly); a post-publish registry confirmation so a cleanup racing the two-step registration can no longer leave the engine pointing at a freed arena;
cuCtxSynchronizeafter the arena zero-fill (host-async memset + no cross-process stream ordering).Not in this PR
ibv_reg_mr/dma-buf) of the arena — the allocation is owner-side and RDMA-ready; wiring RDMA is a follow-up.Replaces #414 and #416.