Skip to content

Commit d9dc0be

Browse files
authored
fix: clamp embedding batch size to the ONNX graph batch axis (#970)
A model registered through TextEmbedding.add_custom_model() against the Qwen3 causal-LM ONNX weights crashed on any batch larger than one: onnxruntime ... Shape mismatch attempting to re-use buffer. {1,1,18,18} != {8,1,18,18} CustomTextEmbedding inherits OnnxTextEmbedding.embed, whose default is batch_size=256, while the graph is pinned to a batch of one. Qwen3TextEmbedding avoided this by hardcoding batch_size=1 in its own embed; CustomTextEmbedding had no such override. Read the batch axis from the loaded session instead of hardcoding it: _detect_static_batch_size() records the literal the graph declares (or None when the axis is symbolic) at load time, and _embed_documents() clamps the requested batch size to it on both the sequential and the worker-pool path. A graph with a dynamic batch axis keeps batching at the size the caller asked for. The inputs alone are not authoritative -- the Qwen3 export declares input_ids as ['batch_size', 'sequence_length'] yet emits last_hidden_state as [1, 'sequence_length', 1024] -- so the outputs are inspected as well.
1 parent 7a19f2d commit d9dc0be

7 files changed

Lines changed: 242 additions & 4 deletions

File tree

qwen3_embed/common/onnx_model.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def _post_process_onnx_output(self, output: OnnxOutputContext, **kwargs: Any) ->
5858
def __init__(self) -> None:
5959
self.model: ort.InferenceSession | None = None
6060
self.model_input_names: set[str] | None = None
61+
self.static_batch_size: int | None = None
6162
self.tokenizer: Tokenizer | None = None
6263
self.special_token_to_id: dict[str, int] = {}
6364

@@ -167,6 +168,28 @@ def _instantiate_onnx_session(
167168
)
168169
return session, input_names
169170

171+
@staticmethod
172+
def _detect_static_batch_size(session: "ort.InferenceSession") -> int | None:
173+
"""Return the batch size the graph is pinned to, or None if it is dynamic.
174+
175+
ONNX declares each dimension either as an int (static) or as a symbolic
176+
``dim_param`` string such as ``"batch_size"`` (dynamic). Both the inputs
177+
and the outputs are inspected because they can disagree: causal-LM
178+
exports keep the symbolic ``batch_size`` on ``input_ids`` while the graph
179+
body was traced with a fixed batch, and only the output declaration
180+
carries the literal. Feeding a larger batch to such a graph fails deep
181+
inside onnxruntime with a buffer shape mismatch, so the smallest literal
182+
found on a batch axis wins.
183+
"""
184+
declared = [
185+
node.shape[0]
186+
for node in (*session.get_inputs(), *session.get_outputs())
187+
# Rank < 2 has no batch axis to speak of, e.g. a scalar side input
188+
# declared as ``[1]`` is a length, not a batch of one.
189+
if node.shape is not None and len(node.shape) >= 2 and isinstance(node.shape[0], int)
190+
]
191+
return min(declared) if declared else None
192+
170193
def _load_onnx_model(
171194
self,
172195
model_dir: Path,
@@ -179,6 +202,7 @@ def _load_onnx_model(
179202
config=config,
180203
)
181204
self.model_input_names = set(input_names)
205+
self.static_batch_size = self._detect_static_batch_size(self.model)
182206
self.tokenizer, self.special_token_to_id = load_tokenizer(model_dir=model_dir)
183207
return self.model, input_names
184208

qwen3_embed/text/onnx_embedding.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ def embed(
103103
104104
Args:
105105
documents: Iterator of documents or single document to embed
106-
batch_size: Batch size for encoding -- higher values will use more memory, but be faster
106+
batch_size: Batch size for encoding -- higher values will use more memory, but be faster.
107+
Capped at the batch size the ONNX graph is pinned to, if it declares one.
107108
parallel:
108109
If > 1, data-parallel encoding will be used, recommended for offline encoding of large datasets.
109110
If 0, use all available cores.

qwen3_embed/text/onnx_text_model.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,21 @@ def onnx_embed(
8989
input_ids=onnx_input.get("input_ids", input_ids),
9090
)
9191

92+
def _effective_batch_size(self, batch_size: int) -> int:
93+
"""Clamp the requested batch size to what the loaded graph accepts.
94+
95+
A graph whose batch axis is dynamic keeps the requested batch size; one
96+
pinned to a fixed batch is capped at that value, because exceeding it
97+
aborts the run inside onnxruntime instead of raising something a caller
98+
can act on. The requested size is also returned untouched when no
99+
session is loaded in this process -- with ``lazy_load`` plus
100+
``parallel`` the graph only exists in the workers.
101+
"""
102+
static_batch_size = getattr(self, "static_batch_size", None)
103+
if static_batch_size is None:
104+
return batch_size
105+
return min(batch_size, static_batch_size)
106+
92107
def _embed_documents(
93108
self,
94109
model_name: str,
@@ -116,7 +131,7 @@ def _embed_documents(
116131
if parallel is None or is_small:
117132
if not hasattr(self, "model") or self.model is None:
118133
self.load_onnx_model()
119-
for batch in iter_batch(documents, batch_size):
134+
for batch in iter_batch(documents, self._effective_batch_size(batch_size)):
120135
yield from self._post_process_onnx_output(
121136
self.onnx_embed(batch, **kwargs), **kwargs
122137
)
@@ -148,7 +163,8 @@ def _embed_documents(
148163
start_method=start_method,
149164
),
150165
)
151-
for batch in pool.ordered_map(iter_batch(documents, batch_size), **params):
166+
batches = iter_batch(documents, self._effective_batch_size(batch_size))
167+
for batch in pool.ordered_map(batches, **params):
152168
yield from self._post_process_onnx_output(batch, **kwargs)
153169

154170
def _token_count(self, texts: str | Iterable[str], batch_size: int = 1024, **_: Any) -> int:

qwen3_embed/text/text_embedding.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,8 @@ def embed(
191191
192192
Args:
193193
documents: Iterator of documents or single document to embed
194-
batch_size: Batch size for encoding -- higher values will use more memory, but be faster
194+
batch_size: Batch size for encoding -- higher values will use more memory, but be faster.
195+
Capped at the batch size the ONNX graph is pinned to, if it declares one.
195196
parallel:
196197
If > 1, data-parallel encoding will be used, recommended for offline encoding of large datasets.
197198
If 0, use all available cores.

tests/test_integration.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,47 @@ def test_custom_model_parallel_mixed_case(self):
294294
assert emb.shape == (1024,)
295295

296296

297+
# ═══════════════════════════════════════════════════════════════════════════
298+
# Embedding: Custom model on a static-batch graph
299+
# ═══════════════════════════════════════════════════════════════════════════
300+
301+
302+
@pytest.mark.integration
303+
class TestCustomModelStaticBatchGraph:
304+
"""A custom model on causal-LM weights must not crash on the default batch size.
305+
306+
The Qwen3 ONNX graph is pinned to a batch of one, so embedding more
307+
documents than that in a single run used to abort inside onnxruntime with
308+
``Shape mismatch attempting to re-use buffer``.
309+
"""
310+
311+
def test_default_batch_size_on_static_batch_graph(self):
312+
CustomTextEmbedding._SUPPORTED.clear()
313+
model_id = "Org/Custom-Qwen-Static-Batch"
314+
TextEmbedding.add_custom_model(
315+
model_description=DenseModelDescription(
316+
model=model_id,
317+
sources=ModelSource(hf="n24q02m/Qwen3-Embedding-0.6B-ONNX"),
318+
model_file="onnx/model_quantized.onnx",
319+
dim=1024,
320+
),
321+
pooling=PoolingType.LAST_TOKEN,
322+
normalization=True,
323+
)
324+
try:
325+
model = TextEmbedding(model_name=model_id)
326+
docs = [f"Document number {i} about topic {chr(65 + i)}." for i in range(8)]
327+
# No batch_size: the default of 256 is what the caller gets by default.
328+
embeddings = list(model.embed(docs))
329+
finally:
330+
CustomTextEmbedding._SUPPORTED.clear()
331+
332+
assert len(embeddings) == 8
333+
for emb in embeddings:
334+
assert emb.shape == (1024,)
335+
assert abs(np.linalg.norm(emb) - 1.0) < 1e-3
336+
337+
297338
# ═══════════════════════════════════════════════════════════════════════════
298339
# Reranker: Basic operations
299340
# ═══════════════════════════════════════════════════════════════════════════

tests/test_onnx_model.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,87 @@ def test_add_extra_session_options():
285285
ConcreteOnnxModel.add_extra_session_options(session_options, {"invalid_option": True})
286286

287287

288+
def _shape_node(name: str, shape: list[Any]) -> MagicMock:
289+
"""A stand-in for an onnxruntime NodeArg carrying a declared shape."""
290+
node = MagicMock()
291+
node.name = name
292+
node.shape = shape
293+
return node
294+
295+
296+
class TestDetectStaticBatchSize:
297+
"""A literal on the batch axis pins the graph; a dim_param string does not."""
298+
299+
def test_fully_dynamic_returns_none(self):
300+
session = MagicMock()
301+
session.get_inputs.return_value = [
302+
_shape_node("input_ids", ["batch_size", "sequence_length"]),
303+
]
304+
session.get_outputs.return_value = [
305+
_shape_node("last_hidden_state", ["batch_size", "sequence_length", 768]),
306+
]
307+
assert OnnxModel._detect_static_batch_size(session) is None
308+
309+
def test_literal_on_output_only(self):
310+
"""Shapes as declared by n24q02m/Qwen3-Embedding-0.6B-ONNX model_quantized.onnx:
311+
the inputs still say ``batch_size`` while the output says ``1``."""
312+
session = MagicMock()
313+
session.get_inputs.return_value = [
314+
_shape_node("input_ids", ["batch_size", "sequence_length"]),
315+
_shape_node("attention_mask", ["batch_size", "sequence_length"]),
316+
]
317+
session.get_outputs.return_value = [
318+
_shape_node("last_hidden_state", [1, "sequence_length", 1024]),
319+
]
320+
assert OnnxModel._detect_static_batch_size(session) == 1
321+
322+
def test_literal_on_input(self):
323+
session = MagicMock()
324+
session.get_inputs.return_value = [
325+
_shape_node("input_ids", [4, "sequence_length"]),
326+
]
327+
session.get_outputs.return_value = [
328+
_shape_node("last_hidden_state", ["batch_size", "sequence_length", 768]),
329+
]
330+
assert OnnxModel._detect_static_batch_size(session) == 4
331+
332+
def test_smallest_literal_wins(self):
333+
session = MagicMock()
334+
session.get_inputs.return_value = [
335+
_shape_node("input_ids", [4, "sequence_length"]),
336+
]
337+
session.get_outputs.return_value = [
338+
_shape_node("last_hidden_state", [1, "sequence_length", 768]),
339+
]
340+
assert OnnxModel._detect_static_batch_size(session) == 1
341+
342+
def test_rank_one_node_is_not_a_batch_axis(self):
343+
session = MagicMock()
344+
session.get_inputs.return_value = [
345+
_shape_node("input_ids", ["batch_size", "sequence_length"]),
346+
_shape_node("max_new_tokens", [1]),
347+
]
348+
session.get_outputs.return_value = [
349+
_shape_node("last_hidden_state", ["batch_size", "sequence_length", 768]),
350+
]
351+
assert OnnxModel._detect_static_batch_size(session) is None
352+
353+
354+
def test_load_records_static_batch_size(model: ConcreteOnnxModel, mock_ort):
355+
"""The batch axis is read once, when the session is created."""
356+
session_mock = mock_ort.InferenceSession.return_value
357+
session_mock.get_inputs.return_value = [
358+
_shape_node("input_ids", ["batch_size", "sequence_length"]),
359+
]
360+
session_mock.get_outputs.return_value = [
361+
_shape_node("last_hidden_state", [1, "sequence_length", 1024]),
362+
]
363+
364+
model._load_onnx_model(Path("dummy"), "model.onnx", OnnxSessionConfig(threads=None))
365+
366+
assert model.static_batch_size == 1
367+
368+
288369
def test_preprocess_onnx_input(model: ConcreteOnnxModel):
289370
"""Test _preprocess_onnx_input returns unchanged input by default."""
290371
data = {"input_ids": np.array([[1, 2, 3]])}

tests/test_text_onnx_text_model.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,80 @@ def test_parallel_uses_forkserver_or_spawn(self) -> None:
434434
assert call_kwargs["config"].start_method in ("forkserver", "spawn")
435435

436436

437+
class TestOnnxTextModelStaticBatchGraph:
438+
"""A graph pinned to a fixed batch must never be fed a larger one.
439+
440+
Feeding it more rows aborts inside onnxruntime with a buffer shape
441+
mismatch, so the requested batch size is clamped to what the graph
442+
declares -- and left alone when the batch axis is dynamic.
443+
"""
444+
445+
def _session(self, batch_dim: int | str) -> MagicMock:
446+
"""Mock session shaped like a real text-embedding graph."""
447+
session = MagicMock()
448+
session.run.return_value = [np.ones((1, 4), dtype=np.float32)]
449+
node_ids = MagicMock()
450+
node_ids.name = "input_ids"
451+
node_ids.shape = ["batch_size", "sequence_length"]
452+
session.get_inputs.return_value = [node_ids]
453+
node_out = MagicMock()
454+
node_out.name = "last_hidden_state"
455+
node_out.shape = [batch_dim, "sequence_length", 4]
456+
session.get_outputs.return_value = [node_out]
457+
return session
458+
459+
def _tokenizer(self) -> MagicMock:
460+
"""Tokenizer whose output length tracks the batch it is given."""
461+
enc = MagicMock()
462+
enc.ids = [1, 2, 3, 0]
463+
enc.attention_mask = [1, 1, 1, 0]
464+
tok = MagicMock()
465+
tok.encode_batch.side_effect = lambda documents: [enc] * len(documents)
466+
return tok
467+
468+
def _model(self, batch_dim: int | str) -> tuple[ConcreteOnnxTextModel, MagicMock]:
469+
session = self._session(batch_dim)
470+
m = ConcreteOnnxTextModel()
471+
m.model = session
472+
m.model_input_names = {"input_ids"}
473+
m.tokenizer = self._tokenizer()
474+
m.static_batch_size = m._detect_static_batch_size(session)
475+
return m, session
476+
477+
@staticmethod
478+
def _batch_widths(session: MagicMock) -> list[int]:
479+
return [call[0][1]["input_ids"].shape[0] for call in session.run.call_args_list]
480+
481+
def test_static_batch_graph_is_fed_one_document_at_a_time(self) -> None:
482+
m, session = self._model(batch_dim=1)
483+
documents = [f"document {i}" for i in range(8)]
484+
485+
list(m._embed_documents("t", "/tmp", documents=documents))
486+
487+
assert self._batch_widths(session) == [1] * 8
488+
489+
def test_dynamic_batch_graph_keeps_the_requested_batch(self) -> None:
490+
m, session = self._model(batch_dim="batch_size")
491+
documents = [f"document {i}" for i in range(8)]
492+
493+
list(m._embed_documents("t", "/tmp", documents=documents))
494+
495+
assert self._batch_widths(session) == [8]
496+
497+
def test_caller_batch_size_below_the_graph_limit_is_respected(self) -> None:
498+
m, session = self._model(batch_dim=4)
499+
documents = [f"document {i}" for i in range(6)]
500+
501+
list(m._embed_documents("t", "/tmp", documents=documents, batch_size=2))
502+
503+
assert self._batch_widths(session) == [2, 2, 2]
504+
505+
def test_unloaded_session_leaves_the_batch_size_alone(self) -> None:
506+
"""With lazy_load plus parallel the graph lives only in the workers."""
507+
m = ConcreteOnnxTextModel()
508+
assert m._effective_batch_size(256) == 256
509+
510+
437511
class TestTextEmbeddingWorkerProcess:
438512
"""Lines 183-185 — TextEmbeddingWorker.process yields (idx, OnnxOutputContext)."""
439513

0 commit comments

Comments
 (0)