Embeddings (Encoder-Only Models)#
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.
Quick start#
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 8000
Send 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}
}
Request fields#
The endpoint accepts the standard OpenAI /v1/embeddings fields:
Field |
Type |
Notes |
|---|---|---|
|
str |
Model name. |
|
str | list[str] | list[int] | list[list[int]] |
Text(s) or pre-tokenized token-id list(s). |
|
|
|
|
int (optional) |
Matryoshka output size. Only supported by Matryoshka-trained text-embedding models; rejected with |
|
str (optional) |
Ignored; accepted for compatibility. |
|
bool (default |
TRT-LLM extension. Encoder models such as BERT generally need their special tokens (e.g. |
There are no required TRT-LLM-specific request fields — existing OpenAI-compatible
embeddings clients work by pointing at the trtllm-serve URL.
Dynamic batching#
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:
|
Behavior |
Triton equivalent |
|---|---|---|
|
Upper bound on the number of requests fused into one forward pass. A batch reaching this size is dispatched immediately. |
maximum / |
|
Hold window: how long an incoming request waits for others to join its batch before dispatch. |
|
|
Maximum number of in-flight queued requests. Further requests are rejected with HTTP 429 (backpressure). |
|
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.
Migrating from the Triton Inference Server dynamic batcher#
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 |
|
|---|---|
|
|
|
|
|
|
Adopt the same values you tuned in Triton as a starting point, then adjust for your latency/throughput budget.
Error handling#
Condition |
HTTP status |
|---|---|
Input longer than |
400 |
Request queue full ( |
429 |
Invalid request body |
400 |
Embedding responses are unary (non-streaming).
Output semantics and scope#
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.
Scaling out across GPUs#
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.
Relationship to llm.encode()#
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.