Skip to content

Exporters: run exported models on their backend's runtime (ModelRunner) - #50032

Queued
IlyasMoutawwakil wants to merge 74 commits into
mainfrom
exporters-forward-runtime
Queued

IlyasMoutawwakil wants to merge 74 commits into
mainfrom
exporters-forward-runtime

Conversation

@IlyasMoutawwakil

@IlyasMoutawwakil IlyasMoutawwakil commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

🤗 Transformers CI

What does this PR do?

Stacked on #50029, which adds ExportedModel. This PR makes it run: an export is called like the model it came from, on its backend's runtime.

from transformers.exporters import ExportedModel

model = ExportedModel.from_pretrained("my-org/my-model", subfolder="onnx", device="cuda")
outputs = model(**processor(images=images, return_tensors="pt"))
outputs.pooler_output  # the source model's own output class

Why

  • No glue code. Converting tensors for the backend, matching kwargs to graph inputs, picking a device, rebuilding outputs: every user of an export writes this today. sentence-transformers needs it for its ONNX and OpenVINO backends (sentence-transformers#4178).
  • Drop-in for the model. Same kwargs and same ModelOutput class as the source model, caches included.

How

  • Each component gets a runner for its backend (exporters/runners/runner_<backend>.py), registered with register_backend(exporter, runner).
  • A fresh export builds its runners on its first call; from_pretrained(..., device=...) builds them while loading. .to(device, **runner_kwargs) rebuilds them elsewhere or with backend options: providers and session_options for ONNX Runtime, config and torch_threads for OpenVINO.
  • from_pretrained takes download options as named parameters, as PreTrainedModel.from_pretrained does, and runner options as kwargs, checked before any program file is downloaded.
  • An ONNX Runtime session opened with CUDA graphs (enable_cuda_graph, trt_cuda_graph_enable) replays one: its inputs and outputs keep fixed device buffers. Capture needs a static export.
  • The metadata records torch.export's input and output specs, so a runner rebuilds the model's output structure, and loading registers the cache classes it names.
  • A call drops kwargs the export wasn't traced with, so a processor's whole output can be passed. Exports with several components raise when called; their runtimes come in follow-ups.
Backend Runs through
Dynamo ExportedProgram.module()
ONNX an ONNX Runtime session, inputs and outputs bound in place
OpenVINO the model compiled for the device
ExecuTorch the program's forward

Also fixed: a GatherND at constant indices (the attention mask's) exports as a Gather, since ONNX Runtime's CUDA GatherND breaks graph capture (microsoft/onnxruntime#33875); ONNX input names recorded before a lazily initialized cache grows; bfloat16 convolutions run in float32 for ONNX (no bfloat16 Conv before opset 22); a saved program keeps EncoderDecoderCache.is_updated's int keys; 16-bit OpenVINO exports compile in their own precision, recorded on the model, instead of fp32 (bf16 EmbeddingGemma2 on CPU: 99 → 48 ms static); repeat_kv's expand broadcasts in numpy mode, which OpenVINO's CPU plugin keeps in 16-bit (73 → 52 ms dynamic); a bf16 model whose buffers are views exports to OpenVINO; a quantized ONNX export keeps its large constants when saved.

Performance

EmbeddingGemma2 text embeddings, 8 sentences of 128 tokens, like for like: dynamic exports against torch.compile(dynamic=True), and static ones replayed as CUDA graphs against torch.compile(mode="reduce-overhead"). Reproduce with exporters_runtime.py, a self-contained uv script (uv run exporters_runtime.py --device cuda [--dynamic]); its docstring has the methodology.

CUDA, A100-SXM4-80GB, every row fp16. Fused rows use OnnxConfig(ort_fusions="cuda") (#50309).

Dynamic shapes:

CUDA, dynamic shapes

Static shapes, replayed as CUDA graphs (ONNX Runtime's enable_cuda_graph, TensorRT's trt_cuda_graph_enable), against torch.compile(mode="reduce-overhead"):

CUDA, static shapes, CUDA graphs

CPU, Xeon Platinum 8570, 53 physical cores of one socket, every row bf16.

Dynamic shapes:

CPU, dynamic shapes

Static shapes, against torch.compile(dynamic=False):

CPU, static shapes

Tests

  • test_runtime.py: every backend called fresh and loaded, against eager; runner options and their refusals; CUDA graph replay; caches loaded in a fresh process; the decode's static cache through the runner.
  • test_save_load.py: ONNX exports from the Hub cache, in bfloat16 and past 2GB, run through the runtime.
  • Forward export tests of Llama, BERT, T5, Gemma3, Qwen2-VL, CLIP, Whisper and Mamba on Dynamo, ONNX and OpenVINO, and of Llama and BERT on ExecuTorch.

🤖 Generated with Claude Code

IlyasMoutawwakil and others added 6 commits October 27, 2026 15:32
…t/save_artifact

`export()` and `export_for_generation()` return an `ExportArtifacts`, a mapping of
`ExportedComponent`s (the backend's program and the metadata recorded while tracing it),
saved with `save_pretrained`. Backends implement `export_artifact` and `save_artifact`,
and register through `register_backend`. The configs drop their saving fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n names

`ExportArtifacts.from_pretrained` loads an export from a local directory or the Hub
(only the files the manifest names), through each backend's `load_artifact`;
`push_to_hub` and `save_pretrained(push_to_hub=True)` upload it. ONNX and OpenVINO
record their final graph's input and output names in the metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`.xml` / `.bin` don't name their format, so OpenVINO components save as
`openvino_<name>.xml`, as optimum-intel's do. Other backends keep `<name>.<ext>`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`ExportFormat` is a `StrEnum`, so configs serialize as they are and custom backends
use plain strings. The registry lookup splits into `get_export_config_class` and
`get_exporter_class`. The metadata drops the fields that described the traced program
rather than the saved graph. Overlapping tests are merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nst the Hub

`export_program` / `save_program` / `load_program`, `program_files` and `.program`
replace the `artifact` names, which now only mean the `ExportArtifacts` container.
A loaded ONNX graph doesn't read its weights; calling it runs the saved file in
ONNX Runtime. `get_exporter_class` / `get_export_config_class` are public.
Hub tests move to `test_hub.py`, with staging tests pushing and loading a real export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cuTorch

A loaded ONNX program reads its graph without the weights, and a call runs the saved
file in ONNX Runtime. `num_user_outputs` and `constant_inputs` are recorded again for
Dynamo and ExecuTorch, whose saved programs bind positionally; ONNX and OpenVINO drop
them, as they describe the traced program rather than those saved graphs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@HuggingFaceDocBuilderDev

HuggingFaceDocBuilderDev commented Oct 27, 2026 •

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

Pages changed in this PR:

@echarlaix echarlaix left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very cool work @IlyasMoutawwakil

@ArthurZucker ArthurZucker left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ty

Comment thread docs/source/en/exporters.md Resolved
Comment thread src/transformers/exporters/auto.py Resolved
Comment thread src/transformers/exporters/base.py Resolved
IlyasMoutawwakil and others added 5 commits November 2, 2026 11:21
…cord the export config

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… through one path

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A runner per backend (`exporters/runtimes/`) runs a program as an export returns it or
a load reads it back. `ExportArtifacts.runtime()` picks how an export runs and returns
an `ExportedModel`, called like the model it came from; `AutoExportedModel.from_pretrained`
loads any saved export to run. The per-model forward export tests run through the
runners, and the ExecuTorch test helper keeps only its skip policy around the runner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cuTorch runner

A dynamic export returns a static cache's positions as integers, which the model keeps
inside its cache rather than returning as tensors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…toExportedModel and can_generate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IlyasMoutawwakil IlyasMoutawwakil changed the title Exporters: run exported models (ExportedModel, AutoExportedModel) Exporters: run exported models (ExportedModel) Nov 2, 2026
@IlyasMoutawwakil
IlyasMoutawwakil force-pushed the exporters-forward-runtime branch from 75f27de to 7de7cb1 Compare November 2, 2026 11:16
…ore loading it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IlyasMoutawwakil and others added 6 commits November 2, 2026 14:13
…inding)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e back (save_pretrained, push_to_hub)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Python scalar kwarg (`output_router_logits=False`) is a constant of the graph, not an input: binding it
shifted every later input by one slot. OpenVINO's `infer` already copies its outputs out of the request's
buffers, so the extra copy is dropped, and inputs are shared instead of copied in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dict keys in a saved spec

ExecuTorch has no string value, and a program's string constant (`vision_feature_select_strategy="default"`)
takes `None` in its slot. A cache's pytree context keeps a dict with non-string keys as pairs, since JSON reads
`EncoderDecoderCache.is_updated`'s int keys back as strings and the saved input spec no longer matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and session options

`ExportArtifacts.runtime(device, **runner_kwargs)` hands the options to the backend's runner: an ONNX Runtime
`providers` list (TensorRT with its options, say) and `session_options`. ONNX Runtime sizes its thread pool from every
core of the machine rather than the ones the process may run on, which `intra_op_num_threads` fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mpile properties, share outputs on request

On CPU, OpenVINO otherwise keeps a stateful cache in uint8 and may run fp32 in bf16 where the CPU has it: inference
and KV cache now follow the export's dtype, tuned for latency, and `runtime(config=...)` passes compile properties
over those defaults (the uint8 cache, say). `share_outputs` lets a caller that reads each call's outputs before the
next take views of the request's buffers instead of copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IlyasMoutawwakil and others added 5 commits November 11, 2026 14:06
…onfig lookup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	src/transformers/exporters/auto.py
#	src/transformers/exporters/metadata.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IlyasMoutawwakil IlyasMoutawwakil changed the title Exporters: run exported models (ExportedModel) Exporters: run exported models on their backend's runtime (ModelRunner) Nov 11, 2026
IlyasMoutawwakil and others added 3 commits November 11, 2026 14:27
…ata; import without torch

- _patch_sdpa is back to #50029's: the boolean-mask change is #50309's, with more
- ExportedModel.dtype reads the metadata instead of building every runner
- the bf16 convolution fix keeps its torch ops local, so the module imports without torch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… imports without it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from exporters-artifacts to main November 11, 2026 15:04
IlyasMoutawwakil and others added 6 commits November 11, 2026 07:29
…opies

`patch_16bit_model` upcast the norms and other small 16-bit tensors through `.data`, which keeps a
view's 16-bit base: `torch.export` replays the view when it fakifies the parameters and fails
("size(-1) must be divisible by 2 to view BFloat16 as Float"). A loaded checkpoint holds such views
(EmbeddingGemma2's `layer_scalar`, 504 tensors), so the OpenVINO export of the bf16 model failed while
a randomly initialized one passed. Each slot now holds an f32 copy for the trace (one per tensor, so
shared tensors stay shared) and gets its original tensor back afterwards.
…nt indices exports as Gather

- An ONNX session opened with `enable_cuda_graph` / `trt_cuda_graph_enable` replays CUDA graphs, one per input-shape
  signature (`gpu_graph_id`): each keeps its input buffers and the outputs ONNX Runtime allocated on its first run.
- A mask indexed by constant grids exports as a `GatherND`, whose CUDA kernel breaks graph capture
  (microsoft/onnxruntime#33875): it is now a `Gather` over the flattened indexed dimensions, so static Llama, BERT, T5
  and GPT-2 exports capture.
- The runner maps dtypes with onnx_ir instead of a torch.onnx internal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…time

# Conflicts:
#	docs/source/en/exporters.md
#	docs/source/en/exporters_quantization.md
#	docs/source/en/main_classes/exporters.md
#	src/transformers/exporters/auto.py
#	src/transformers/exporters/base.py
#	src/transformers/exporters/exporter_dynamo.py
#	src/transformers/exporters/exporter_executorch.py
#	src/transformers/exporters/exporter_onnx.py
#	src/transformers/exporters/exporter_openvino.py
#	src/transformers/exporters/metadata.py
#	tests/exporters/test_export.py
#	tests/exporters/test_hub.py
#	tests/exporters/test_quantization.py
#	tests/exporters/test_runtime.py
#	tests/exporters/test_save_load.py
… its temporary directory goes

`onnx_ir.external_data.load_to_model` loads only the initializers, so the quantized graph's `Constant`
values kept pointing at `quantized.onnx.data` in the deleted temporary directory: the saved export
referenced a file that no longer exists, and loading it in a fresh process failed ("External data path
validation failed for initializer"). Running it in the exporting process hid it. Each external tensor
attribute is now loaded into memory too.
… OpenVINO's 16-bit view buffers

- A quantized static export whose mask indices ONNX Runtime's quantizer wrote externally saves and loads.
- A bf16 model whose buffers are views (a loaded EmbeddingGemma2's clipping bounds) exports to OpenVINO and runs.
- One input-binding helper in the ONNX runner; `_load_external_tensor_attributes` names what it loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A static export's inputs keep one shape, and capture needs a static export: the runner only ever held one graph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IlyasMoutawwakil
IlyasMoutawwakil enabled stacked auto-merge November 11, 2026 17:11
@IlyasMoutawwakil
IlyasMoutawwakil disabled stacked auto-merge November 11, 2026 17:12
@IlyasMoutawwakil

IlyasMoutawwakil commented Nov 11, 2026 •

Copy link
Copy Markdown
Member Author

quick heads up, since the approvals a few things changed api-wise:

  • runner files are now runners/runner_<backend>.py (same as quantizer_<backend>.py)
  • from_pretrained takes the download args as named params like PreTrainedModel, and runner options as kwargs (e.g. providers=, session_options=), checked before anything downloads
  • onnx: device or providers, not both. default is cuda only when a gpu is actually there
  • onnx runner supports cuda graphs, opt-in via providers (enable_cuda_graph / trt_cuda_graph_enable), static exports only
  • GatherND at constant indices (the mask's) now exports as Gather, ort's cuda GatherND breaks graph capture (CUDA graph replay of a graph with GatherND fails with an illegal memory access microsoft/onnxruntime#33875). no output changes across the export tests
  • ExportMetadata: dtype/device are now export_dtype/export_device, the input/output name lists are gone (the specs and the programs have them), and the constants are recorded at export (in_constants for scalar kwargs, out_constants for non-tensor outputs like None hidden states)
  • onnx and openvino share their graph i/o plumbing in exporters.utils now (output wrapping, feeds, post-conversion quantization), so openvino no longer imports from the onnx exporter

also fixed a few things the cpu benchmark surfaced (ort quantizer constants, openvino bf16 view buffers), each with a regression test, and benchmarks are updated (fp16, dynamic + static w/ cuda graphs)

and fixed what the onnx + openvino export sweeps through the runners turned up (non-tensor outputs, onnx bool outputs, onnx outputs returning an input as is, openvino broadcasts to 0, openvino bicubic antialias resizing, executorch precomputed kwargs, beam_idx with sliding caches). all the sweep failures pass now

IlyasMoutawwakil and others added 10 commits November 11, 2026 09:26
… runner compiles for

`patch_16bit_model` traces the float32 upcast, so the metadata recorded on the trace said float32 and the runner
compiled every bf16 OpenVINO graph to run in float32. The metadata now takes the model's precision once the patch
has given it back. EmbeddingGemma2 bf16, 8 x 128 tokens, 53 Xeon cores: 99 -> 48 ms static, 118 -> 70 ms dynamic,
min cosine of the pooled embeddings to eager 0.9999.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…keep the metadata's dtype the graph's

The metadata's dtype is what the graph takes and returns, which the generation runtime builds caches and casts inputs
in: for a 16-bit OpenVINO export, the float32 of the trace. The model's own precision is recorded on the OpenVINO model
instead, as the `runtime_options` it compiles with (`INFERENCE_PRECISION_HINT`, `KV_CACHE_PRECISION`), saved with
it; the runner no longer sets them, and `config` still goes over them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…plugin keeps in 16-bit

OpenVINO's frontend converts `aten.expand` (`repeat_kv` for grouped-query attention) into a bidirectional
`Broadcast`, which the CPU plugin runs, when the target shape is computed, as a multiply by a broadcast float32 tensor
in reference code. A numpy broadcast to the shape the bidirectional one produces, the elementwise maximum of the target
and input shapes, gives the same result in the graph's precision. Dynamic-shape EmbeddingGemma2 in bf16, 8 x 128
tokens on 53 Xeon cores: 73 -> 52 ms, bit-identical outputs. The decode fusion tests still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ONNX Runtime's CPU provider has no bfloat16 kernels. Measured against bf16 eager, OpenVINO and ONNX Runtime CUDA stay
within 0.0024 on these models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nVINO graphs

An expand that adds leading dimensions has a target with more entries than its input's rank, so the elementwise
maximum of the two shapes failed OpenVINO's shape inference and the export with it. Only a broadcast whose target and
input have the same rank becomes numpy now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…docs and comments

A replay copies each input into the buffer the graph was captured with, which broadcast a smaller input silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts come back with their classes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-path sweep failures

Rebuild outputs that aren't tensors (`None` hidden states, spatial sizes): the export records them as
`out_constants`, and the traced scalar kwargs as `in_constants`. ONNX bool outputs read back as bool,
an output returning an input as is no longer renames that input, the OpenVINO numpy broadcast uses
the exact bidirectional shape, and ExecuTorch keeps the precomputed kwargs in the caller's inputs.

Output wrapping, name disambiguation, the feeds, the post-conversion quantize step, cummax/cummin and
the assert dropping now live once in `exporters.utils`; one dotted-path importer; one `module_dtype`.
The metadata keeps no I/O name lists (the specs and the programs hold them) and names its precision
and device `export_dtype` / `export_device`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…resizing in OpenVINO graphs

`openvino_feed` read the batch off the first input, a sliding cache's 0-d counter for HyperCLOVAX's
language model; it now reads the cache's own batch axis. The OpenVINO `interpolate` patch built
antialiased bicubic weights with the bilinear triangle filter; it now uses torch's cubic kernel
(UnlimitedOcr's position embeddings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…time

# Conflicts:
#	src/transformers/exporters/exporter_onnx.py
@IlyasMoutawwakil
IlyasMoutawwakil added this pull request to the merge queue Nov 12, 2026
Any commits made after this event will not be merged.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants