trtllm-serve can serve encoder-only models (BERT-style classifiers, reward
models, text-embedding models) through an OpenAI-compatible POST /v1/embeddings
endpoint with native dynamic batching — coalescing many independent concurrent
requests into a single forward pass for high throughput, the way the NVIDIA Triton
Inference Server dynamic batcher does.
This replaces the need to run a separate Triton Inference Server in front of an
encoder model: point your existing OpenAI-style embeddings client at trtllm-serve
and it works unchanged.
Launch an embeddings server with the embeddings subcommand:
trtllm-serve embeddings <hf_model_or_path> \
--max_batch_size 32 \
--max_queue_delay 0.005 \
--max_queue_size 2048 \
--host 0.0.0.0 --port 8000Send a request with any OpenAI-compatible client or curl:
curl http://localhost:8000/v1/embeddings \
-H "Content-Type: application/json" \
-d '{"model": "<model>", "input": ["hello world", "foo bar"]}'from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="tensorrt_llm")
resp = client.embeddings.create(model="<model>", input=["hello world", "foo bar"])
for item in resp.data:
print(item.index, len(item.embedding))The response is the standard OpenAI embeddings shape:
{
"object": "list",
"data": [
{"object": "embedding", "index": 0, "embedding": [ ... ]},
{"object": "embedding", "index": 1, "embedding": [ ... ]}
],
"model": "<model>",
"usage": {"prompt_tokens": 8, "total_tokens": 8}
}The endpoint accepts the standard OpenAI /v1/embeddings fields:
| Field | Type | Notes |
|---|---|---|
model |
str | Model name. |
input |
str | list[str] | list[int] | list[list[int]] | Text(s) or pre-tokenized token-id list(s). |
encoding_format |
"float" (default) | "base64" |
base64 packs little-endian float32 values. |
dimensions |
int (optional) | Matryoshka output size. Only supported by Matryoshka-trained text-embedding models; rejected with 400 otherwise. None of the served models are Matryoshka-trained (BERT classifiers / reward models emit label/score tensors; Qwen3-Embedding emits a fixed-width pooled vector), so this is currently always rejected. |
user |
str (optional) | Ignored; accepted for compatibility. |
add_special_tokens |
bool (default true) |
TRT-LLM extension. Encoder models such as BERT generally need their special tokens (e.g. [CLS]/[SEP]) added during tokenization. |
There are no required TRT-LLM-specific request fields — existing OpenAI-compatible
embeddings clients work by pointing at the trtllm-serve URL.
A lightweight in-server batcher coalesces concurrent requests in front of the encoder forward pass. It exposes three knobs that mirror the Triton dynamic batcher:
trtllm-serve embeddings flag |
Behavior | Triton equivalent |
|---|---|---|
--max_batch_size |
Upper bound on the number of requests fused into one forward pass. A batch reaching this size is dispatched immediately. | maximum / preferred_batch_size |
--max_queue_delay (seconds) |
Hold window: how long an incoming request waits for others to join its batch before dispatch. | max_queue_delay_microseconds |
--max_queue_size |
Maximum number of in-flight queued requests. Further requests are rejected with HTTP 429 (backpressure). | default_queue_policy.max_queue_size |
A batch is dispatched as soon as any of these fires: it reaches --max_batch_size,
adding the next request would exceed the engine's --max_num_tokens budget, or the
--max_queue_delay hold window elapses.
If you currently serve an encoder model with the Triton inflight_batcher_llm backend
and a config.pbtxt dynamic_batching { ... } block, map the settings directly:
Triton config.pbtxt |
trtllm-serve embeddings |
|---|---|
dynamic_batching.preferred_batch_size / model max batch |
--max_batch_size |
dynamic_batching.max_queue_delay_microseconds |
--max_queue_delay (in seconds, e.g. 100 µs → 0.0001) |
dynamic_batching.default_queue_policy.max_queue_size |
--max_queue_size |
Adopt the same values you tuned in Triton as a starting point, then adjust for your latency/throughput budget.
| Condition | HTTP status |
|---|---|
Input longer than --max_seq_len |
400 |
Request queue full (--max_queue_size reached) |
429 |
| Invalid request body | 400 |
Embedding responses are unary (non-streaming).
The endpoint is model-output-agnostic: it returns whatever per-request vector the model emits, serialized into the OpenAI embeddings schema.
- Classifier / reward models (e.g. a BERT sequence classifier): the returned
vector is the model's class-logit / score vector (
[num_labels]). - Text-embedding models — the Qwen3-Embedding family (
Qwen3-Embedding-0.6B,-4B,-8B) is supported. These ship as aQwen3ForCausalLMdecoder plus a sentence-transformers pooling pipeline; the embeddings server detects this and serves the L2-normalized last-token hidden state (a[hidden_size]sentence-embedding vector — 1024 / 2560 / 4096 respectively), with no extra flags. A configurable pooling method (CLS / mean) for other sentence-transformers backbones remains a follow-up.
Notes:
- The embeddings path uses the synchronous
llm.encode()fast path (EncoderExecutor): a single forward pass per batch, no KV cache, sampler, or decode loop. - One encoder model per server instance. Generation and embedding modes are not mixed in one server.
- Single-GPU per server. The encode path runs in-process and does not use the
multi-GPU worker proxy, so the
embeddingscommand does not expose tensor/pipeline parallelism flags (if a--configfile sets them, startup fails with a clear error). To scale out, see Scaling out across GPUs below. - A single in-server worker drives the GPU (no
num_workersknob): the GPU serializes forwards and the underlying executor is not safe for concurrent calls. Increase throughput with--max_batch_size/--max_queue_delay, not more workers.
Embedding / encoder-only models are usually small and fit comfortably on a single GPU.
The recommended way to use more GPUs is therefore data parallelism: run one
single-GPU trtllm-serve embeddings instance per GPU and put a load balancer in front
of them. There is no cross-GPU communication, so throughput scales close to linearly
with the number of replicas.
# One replica per GPU (8x B200 example), each on its own port.
for i in $(seq 0 7); do
CUDA_VISIBLE_DEVICES=$i trtllm-serve embeddings <model> --port $((8000 + i)) &
done
# Then point any HTTP load balancer (nginx, k8s Service, etc.) at ports 8000-8007.Tensor / pipeline parallelism (sharding a single model across GPUs) is only needed
for an embedding model too large to fit on one GPU — uncommon for encoder-only models.
It is not yet supported by the embeddings command and is planned as a follow-up.
The server reuses the existing Python llm.encode() API
(LLM(..., encode_only=True)) under the hood; the only addition is the async
coalescing layer plus the HTTP surface. The synchronous llm.encode() API continues
to work unchanged for direct Python callers.