perf(embedding): same-node shared-memory fast path for the embedding response - #10220
perf(embedding): same-node shared-memory fast path for the embedding response#10220tzulingk wants to merge 1 commit into
Conversation
Adds an opt-in (DYN_EMBEDDING_SHM=1) same-node transport for the embedding response, following the multiprocessing.shared_memory handle-passing pattern from PR #8065 (mm_kwargs_transfer), adapted for the worker->frontend direction with a Rust reader. Worker (handlers.py): _write_embeddings_to_shm stacks the batch into one contiguous [count, dim] little-endian f32 buffer (torch -> numpy.tobytes), writes it to a POSIX shared-memory segment, and yields a small {name, count, dim} handle with empty `data`. It unregisters the segment from multiprocessing's resource_tracker so the worker and frontend don't both try to unlink it -- the frontend owns cleanup. Frontend (Rust): NvCreateEmbeddingResponse gains an internal-only `data_shm` handle field (skipped when serializing to the client); the aggregator carries it through the stream fold; the /v1/embeddings handler reads /dev/shm/<name> via std::fs::read, splits into count x dim f32, builds the client's requested encoding (float or base64), and unlinks the segment. No libc/mmap/new deps -- POSIX shm is tmpfs-backed at /dev/shm on Linux. Requires frontend + worker to share /dev/shm (same-node / shared IPC). When DYN_EMBEDDING_SHM is unset the existing base64-over-NATS path is unchanged. This is the SHM half of DIS-2177. opt-2 (DIS-2178) stacks on top: with raw f32 bytes already in SHM, the base64 encode/decode is dropped entirely. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Tzu-Ling <tzulingk@nvidia.com>
Corrected benchmark — the earlier embedding-serialization gains were a pooling bug, not a real winWhile re-benchmarking on GB200 I found the dynamo embedding worker was returning the wrong output on vLLM 0.21, which invalidates the prior numbers on this issue. After fixing it and re-measuring same-node, the serialization optimizations (numpy.tobytes / SHM) provide no measurable benefit at a correct-size embedding. The bug (fixed in #10248)
This is why the prior benchmarks looked the way they did: at ISL≈80, dynamo was serializing ~80× more floats than bare Corrected same-node matrixGB200 (single node), vLLM 0.21, p50 latency (ms):
p90 latency (ms):
p99 latency (ms):
Findings
RecommendationShip the correctness fix #10248 ( |
|
Closing: the corrected same-node benchmark (see the matrix comment above) shows this optimization provides no measurable benefit at a real 1024-dim embedding — the earlier gains were artifacts of a pooling bug that made the worker emit per-token output (~80x oversized payload) on vLLM 0.21. The actual fix is #10248 ( |
Warning
Superseded — recommend closing. Re-benchmarking on GB200 found the dynamo embedding worker returns per-token output (wrong shape, dim scales with input length) on vLLM 0.21 — fixed in #10248. With that corrected, the SHM transport provides no measurable benefit at a real 1024-dim embedding; the earlier wins were artifacts of the oversized per-token payload. See the corrected same-node p50/p90/p99 matrix in the comment below. Recommend closing in favor of #10248.
Important
Status: parked — not recommended for text embeddings. Benchmarks (GB200, Qwen3-Embedding-0.6B, batch 15 → 1024) show this SHM path matches the much simpler
numpy.tobytesserialization fix in #10221 — same latency — while adding a same-node constraint (frontend + worker must share/dev/shm), per-requestshm_open/unlinksyscalls, and a leak-on-error risk. There is also no way for it to compound with #10221: when SHM is on, the base64 path #10221 optimizes is never executed.Recommendation: ship #10221 for text embeddings; keep this PR for the large-payload / multimodal path (e.g. frontend→worker image
mm_kwargs), where the wire transfer itself dominates and shared memory pulls meaningfully ahead. Holding as draft until there's such a consumer.Overview:
For small embedding models the GPU forward pass is sub-millisecond, so per-request framework overhead dominates end-to-end latency. A large slice of that overhead is serializing the embedding vectors on the worker → frontend hop: the Python worker turns the pooling tensor into a base64 string and ships it as JSON, and the Rust frontend parses + decodes it.
This PR adds an opt-in, same-node shared-memory fast path for the embedding response. Instead of serializing the vectors onto the wire, the worker writes them as raw
f32bytes into a POSIX shared-memory segment (/dev/shm) and sends only a tiny{name, count, dim}handle in the normal response; the frontend reads the bytes directly and unlinks the segment. This is the samemultiprocessing.shared_memoryhandle-passing pattern as the multimodalmm_kwargs_transferpath, adapted to the worker→frontend direction with a Rust reader.Enabled with
DYN_EMBEDDING_SHM=1. Measured on GB200 (Qwen3-Embedding-0.6B, dim 3072): for small batches it matches the cheaper serialization fix; for larger response payloads (e.g. batch 64, ~786 KB) it begins to pull ahead, and the gap grows with payload size.Architecture:
The frontend (Rust HTTP) and worker (Python/vLLM) stay as two separate processes — no merged CLI, no single-process mode. The only change is how the response payload crosses between them when they're co-located (sharing
/dev/shm):sequenceDiagram autonumber participant C as Client participant F as Frontend<br/>(Rust, HTTP) participant W as Worker<br/>(Python, vLLM) participant S as /dev/shm<br/>(POSIX shared memory) C->>F: POST /v1/embeddings F->>W: request (token ids) over request plane W->>W: engine.encode → pooling tensor [count, dim] alt DYN_EMBEDDING_SHM=1 (same-node fast path) W->>S: write [count, dim] f32 (numpy.tobytes) W-->>F: response { data: [], data_shm: {name, count, dim} } Note over W,F: only a tiny handle crosses the wire F->>S: std::fs::read("/dev/shm/<name>") F->>S: remove_file (unlink — frontend owns cleanup) F->>F: build vectors in client's encoding (float / base64) else default (base64 over the wire) W->>W: tensor → base64 W-->>F: response { data: [ {embedding: "<base64>"}, ... ] } F->>F: base64 → float (if client asked for float) end F-->>C: embeddings JSONKey properties:
DYN_EMBEDDING_SHMis unset, the existing base64-over-the-wire path is used unchanged. SHM requires the frontend and worker to share/dev/shm(one pod / shared IPC); cross-node deployments keep the normal path.data_shmfield that is never serialized to the client; the public/v1/embeddingsresponse is identical./dev/shm, so the frontend just usesstd::fs::read/remove_file— nolibc/mmap.Details:
Worker (Python —
components/src/dynamo/vllm/handlers.py)_write_embeddings_to_shm()stacks the batch into one contiguous[count, dim]little-endianf32buffer (torch.stack→numpy.tobytes), writes it to a freshmultiprocessing.shared_memorysegment, and returns the{name, count, dim}handle.{ "data": [], "data_shm": {...} }whenDYN_EMBEDDING_SHMis set, else the unchanged base64 list.resource_trackerso the worker doesn't race the frontend to unlink it — the frontend owns cleanup.Frontend (Rust)
lib/llm/src/protocols/openai/embeddings.rs:NvCreateEmbeddingResponsegains an internaldata_shm: Option<EmbeddingShmHandle>(#[serde(skip_serializing_if)], so never sent to the client)..../embeddings/aggregator.rs:mergecarries the handle through the stream fold.lib/llm/src/http/service/openai.rs:read_shm_embeddings()doesstd::fs::read("/dev/shm/<name>")→count × dimf32→ vectors in the client's requested encoding →remove_file(unlink). Theembeddingshandler invokes it before the existing base64-decode step.Benchmark (GB200, Qwen3-Embedding-0.6B, dim 3072, ISL 80):
Per-request latency (ms, avg). σ = standard deviation of per-request latency across the run (run noise, not an error bar on the mean);
%figures compare the avg. Small batches: rate-limited (no queueing), 200/100 reqs. Large batches: closed-loop--concurrency 1, 40/25 reqs.tolist+struct.pack)(avg ms.) Both serialization-killers beat the baseline by ~30–37%, growing with batch. SHM's edge over the much simpler
numpy.tobytes(#10221) is only ~2–5% and within run-to-run noise at every size (the two ran on different nodes) — i.e. statistically indistinguishable for text embeddings. SHM's real advantage is reserved for payloads large enough that the wire transfer dominates (multimodal), which is why this PR is parked rather than recommended for the embedding path.Where should the reviewer start?
lib/llm/src/http/service/openai.rs—read_shm_embeddings+ thedata_shmbranch in theembeddingshandler (the read + unlink).lib/llm/src/protocols/openai/embeddings.rsand.../embeddings/aggregator.rs— the internaldata_shmfield and the fold carry-through.components/src/dynamo/vllm/handlers.py—_write_embeddings_to_shmand the gated yield.Caveats / follow-ups:
/dev/shmuntil the container restarts. A worker-side reaper / TTL is wanted before this is used outside benchmarking.numpy.tobytes— captures most of the same win without shared memory and works cross-node; this SHM path is aimed at larger payloads where the wire transfer itself dominates.🤖 Generated with Claude Code