Skip to content

perf(embedding): build embedding base64 directly from the tensor (numpy.tobytes) - #10231

Closed
tzulingk wants to merge 2 commits into
mainfrom
perf/embedding-opt2
Closed

perf(embedding): build embedding base64 directly from the tensor (numpy.tobytes)#10231
tzulingk wants to merge 2 commits into
mainfrom
perf/embedding-opt2

Conversation

@tzulingk

@tzulingk tzulingk commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

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, this PR's numpy.tobytes optimization provides no measurable benefit at a real 1024-dim embedding; the 30–37% gains reported below 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

Recommended embedding fix. Captures the full −30–35% latency win cross-node with a one-line, behavior-preserving change. Benchmarks (below) show it matches the shared-memory path (#10220) without that PR's same-node constraint or extra failure modes, so it — not SHM — is the one to ship for text embeddings. (Complements the already-merged base64 wire-format change in #10139: that made shipping the payload cheap; this makes producing it cheap.)

Overview:

Speeds up embedding-response serialization on the worker by building the base64 payload directly from the pooling tensor via numpy.tobytes, instead of going through a Python float list + struct.pack varargs expansion.

The current path is tensor.detach().cpu().flatten().tolist()struct.pack("<{N}f", *floats) → base64. For a batch-15 × 3072-dim response that's 46,080 Python float objects materialized and then unpacked as 46,080 positional args into struct.pack — an O(N) interpreter-level pass over every element. This PR replaces it with tensor → numpy.tobytes() → base64, a single C-level copy. The emitted base64 bytes are identical on little-endian hosts.

Measured on GB200 (Qwen3-Embedding-0.6B, dim 3072): −30% at batch 15 (124 → 87 ms) and −35% at batch 64 (470 → 307 ms) per-request latency. The win grows with batch size, since it scales with the number of floats serialized. It also works cross-node (no shared memory required) and complements the base64-wire-format change in #10139 (this PR makes producing that base64 cheaper; #10139 made shipping it cheaper).

Details:

  • components/src/dynamo/vllm/handlers.py: new _pooling_output_to_base64() builds base64 straight from the tensor. Shared tensor-prep (detach().cpu().flatten().to(float32)) is factored into a small helper reused by _pooling_output_to_list, so there's no duplicated tensor handling. A list/tuple fallback preserves behavior for non-tensor pooling outputs.
  • .to(torch.float32) makes bf16/fp16 pooling outputs match the struct.pack("<f") width, keeping the emitted bytes identical.

Benchmark (GB200, Qwen3-Embedding-0.6B, dim 3072, ISL 80):

Per-request latency (ms, avg). Small batches: rate-limited (no queueing), 200/100 reqs. Large batches: closed-loop --concurrency 1, 40/25 reqs.

batch response (raw f32) baseline (tolist+struct.pack) numpy.tobytes (this PR) SHM (#10220) numpy.tobytes + SHM this PR vs baseline
15 ~184 KB 124.25 86.94 85.49 85.31 −30%
64 ~786 KB 469.54 306.57 290.69 294.72 −35%
128 ~1.5 MB 934.16 584.31 573.98 566.64 −37%
256 ~3 MB 1,860.85 1,182.62 1,128.59 1,126.00 −36%
512 ~6 MB 3,684.53 2,364.84 2,301.73 2,200.67 −36%
1024 ~12 MB 7,491.99 4,694.72 4,615.99 4,447.78 −37%

The fix holds a consistent −30 to −37% vs baseline, and the absolute saving grows with batch (37 ms at batch 15 → ~2.8 s at batch 1024) — the baseline's tolist() + struct.pack("<{N}f", *floats) is an O(N) interpreter-level pass that scales badly. SHM (#10220) tracks this PR within ~2–5% across all sizes (within run-to-run noise; the two configs ran on different nodes), so the shared-memory transport buys little over this serialization fix for text embeddings. Baseline large-batch runs used fewer samples (n=20 for 128/256, n=10 for 512/1024) since each request is so slow.

Gap to bare vllm serve (standalone vLLM v0.21.0 — the same vLLM Dynamo is built on, so this is the floor that isolates Dynamo's overhead):

batch bare vLLM dynamo baseline dynamo (this PR) this PR vs vLLM gap closed
15 35.70 124.25 86.76 2.4× ~42%
64 70.48 469.54 304.27 4.3× ~41%
128 124.19 934.16 584.31 4.7× ~43%
256 233.09 1,860.85 1,182.62 5.1× ~42%
512 441.74 3,684.53 2,364.84 5.4× ~41%
1024 876.34 7,491.99 4,694.72 5.4× ~42%

This PR closes ~42% of the Dynamo-over-vLLM overhead at every batch (gap closed = 1 − (thisPR − vLLM)/(baseline − vLLM)). A residual gap remains (Dynamo+this PR is 2.4–5.4× bare vLLM) — the two-process architecture's inherent cost: the extra worker→frontend serialization hop (base64 encode + transport + Rust decode) plus the Rust HTTP frontend, tokenizer, and request-plane, none of which single-process vllm serve pays. That residual is what SHM/single-process target separately.

Where should the reviewer start?

  • components/src/dynamo/vllm/handlers.py_pooling_output_to_base64 and its use in the embedding response loop.

Related:

🤖 Generated with Claude Code

…py.tobytes)

Build the worker's base64 embedding payload straight from the pooling tensor
via numpy.tobytes, instead of tensor.tolist() + struct.pack("<{N}f", *floats).
For a batch-15 x 3072-dim response that removes materializing 46,080 Python
float objects and unpacking them as 46,080 positional args into struct.pack --
an O(N) interpreter-level pass replaced by a single C-level copy. Emitted
base64 bytes are identical on little-endian hosts.

Shared detach/cpu/flatten/float32 tensor-prep is factored into
_flatten_pooling_tensor, reused by _pooling_output_to_list so the tensor
handling isn't duplicated; the fast path still avoids .tolist().

Measured on GB200 (Qwen3-Embedding-0.6B, dim 3072): -30% at batch 15
(124 -> 87 ms) and -35% at batch 64 (470 -> 307 ms) per-request latency.
Works cross-node (no shared memory). Complements #10139 (base64 wire format):
that made shipping the payload cheap, this makes producing it cheap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Tzu-Ling <tzulingk@nvidia.com>
Drop the _flatten_pooling_tensor helper; inline
data.detach().cpu().flatten().to(torch.float32) at its single use in
_pooling_output_to_base64. _pooling_output_to_list reverts to its original
form, so the net diff vs main is just the response-loop switch + the new
_pooling_output_to_base64 helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Tzu-Ling <tzulingk@nvidia.com>
@tzulingk
tzulingk requested review from krishung5 and nv-tusharma June 2, 2026 20:02
@tzulingk
tzulingk marked this pull request as ready for review June 2, 2026 20:03
@tzulingk
tzulingk requested review from a team as code owners June 2, 2026 20:03
@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The PR refactors embedding base64 serialization in EmbeddingWorkerHandler by extracting tensor-aware conversion logic into a new helper function. The embedding response loop now calls _pooling_output_to_base64() directly, replacing the previous Python list conversion and dimension truncation approach.

Changes

Embedding Base64 Serialization Refactor

Layer / File(s) Summary
Tensor-aware base64 serialization helper
components/src/dynamo/vllm/handlers.py
New _pooling_output_to_base64(data, dimensions=None) utility converts torch tensor pooling outputs to base64-encoded bytes: flattens tensors, converts to float32, applies optional dimension truncation, and base64-encodes raw bytes; falls back to list-based encoding for non-tensor inputs.
Embedding response construction with tensor serialization
components/src/dynamo/vllm/handlers.py
EmbeddingWorkerHandler.generate() embedding loop refactored to call the new serialization helper with optional dimensions parameter, eliminating the previous inline list conversion and dimension slicing logic.

🎯 2 (Simple) | ⏱️ ~10 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: switching to a performance-optimized approach (numpy.tobytes) for building base64 embeddings directly from tensors.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering all template sections with substantial technical detail about the optimization, benchmarks, and implementation approach.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@tzulingk

tzulingk commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

Corrected benchmark — the earlier embedding-serialization gains were a pooling bug, not a real win

While 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)

EmbeddingWorkerHandler built PoolingParams() with no task. On vLLM 0.21, encode() with task=None returns per-token output (the full n_tokens × hidden hidden-state matrix, un-normalized) instead of one pooled, L2-normalized vector. So /v1/embeddings returned n_tokens × 1024 floats — e.g. dim 2048 for "hi", 3072 for "hello world", ~81,920 for an 80-token prompt — un-normalized.

This is why the prior benchmarks looked the way they did: at ISL≈80, dynamo was serializing ~80× more floats than bare vllm serve (which pools to 1024). The "30–37% opt-2 win" and "SHM ≈ opt-2" results were entirely the cost of (de)serializing that oversized payload. Fix = PoolingParams(task="embed") → fixed 1024-dim, ‖v‖=1.0, matching bare vLLM and DIS-2154's "15 × 1024 floats".

Corrected same-node matrix

GB200 (single node), vLLM 0.21, Qwen/Qwen3-Embedding-0.6B, dim 1024, ISL 80, warmup 15. Small batches (15/64) rate-limited (rate 10); large batches (128–1024) closed-loop --concurrency 1. All configs run back-to-back on the same node with identical params.

p50 latency (ms):

batch bare vLLM dyn baseline dyn opt-2 dyn SHM dyn opt-2+SHM
15 28.3 26.9 32.0 27.6 25.7
64 46.3 57.4 65.0 54.6 50.2
128 81.5 87.6 93.4 78.1 75.8
256 144.0 161.4 154.0 151.2 142.4
512 261.6 313.9 293.8 285.0 269.4
1024 542.4 631.7 604.0 589.2 573.0

p90 latency (ms):

batch bare vLLM dyn baseline dyn opt-2 dyn SHM dyn opt-2+SHM
15 30.6 28.2 34.4 29.4 26.9
64 51.7 82.3 81.5 68.4 65.1
128 88.1 100.5 95.7 80.3 89.6
256 176.3 164.8 193.1 156.4 175.1
512 265.8 618.5 670.4 616.2 597.2
1024 674.2 977.1 1029.6 962.5 955.8

p99 latency (ms):

batch bare vLLM dyn baseline dyn opt-2 dyn SHM dyn opt-2+SHM
15 32.6 36.3 36.2 31.2 29.4
64 56.2 284.0 392.4 76.1 203.5
128 91.2 375.1 164.8 154.1 150.4
256 187.8 463.2 532.1 455.0 449.2
512 271.0 661.3 694.6 632.4 608.7
1024 737.0 1001.5 1056.7 958.3 959.0

Findings

  • opt-2 ≈ baseline ≈ SHM ≈ opt-2+SHM at p50 — all four dynamo serialization paths are within run-to-run noise of each other. opt-2 is not faster than baseline (slightly slower at small batches); SHM / opt-2+SHM are marginally lower but within noise. At a real 1024-dim embedding, the tolist()+struct.pack cost is negligible, so optimizing it changes nothing.
  • bare vllm serve has the tightest tail (p90/p99 ≈ p50). Every dynamo variant has a fat tail — the frontend↔worker IPC hop + Python GIL add occasional slow requests (see p99 at batch 64/512/1024). At p50 dynamo is within ~10–20% of vLLM; on the tail vLLM wins clearly.
  • The dim/normalization bug, not serialization, was the whole story. DIS-2154's 1.95× (dynamo 123.7 ms / vLLM 63.4 ms) was on vLLM 0.19; the larger gaps in later runs were the per-token payload on vLLM 0.21, not a regression.

Recommendation

Ship the correctness fix #10248 (PoolingParams(task="embed")). The serialization optimizations — #10231 (numpy.tobytes) and #10220 (SHM) — do not justify on perf grounds for realistic text-embedding sizes and are recommended for closure.

@tzulingk

tzulingk commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

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 (PoolingParams(task="embed")). Closing in favor of that.

@tzulingk tzulingk closed this Jun 3, 2026
auto-merge was automatically disabled June 3, 2026 02:30

Pull request was closed

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

Labels

backend::vllm Relates to the vllm backend perf size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant