Skip to content

feat(executorch): expose composable Edge export API - #4440

Open
shoumikhin wants to merge 4 commits into
pytorch:mainfrom
shoumikhin:executorch-composable-export
Open

feat(executorch): expose composable Edge export API#4440
shoumikhin wants to merge 4 commits into
pytorch:mainfrom
shoumikhin:executorch-composable-export

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Why this PR is needed

torch_tensorrt.save(..., output_format="executorch") is the easiest way to create an ExecuTorch .pte file. It completes the whole export pipeline immediately:

import torch
import torch_tensorrt

model = MyModel().eval().cuda()
inputs = [torch.randn(1, 3, 224, 224, device="cuda")]
trt_module = torch_tensorrt.compile(model, ir="dynamo", arg_inputs=inputs)

torch_tensorrt.save(
    trt_module,
    "model.pte",
    output_format="executorch",
)

That remains the recommended API for common one-step exports.

Some workflows need to stop before final ExecuTorch memory planning and serialization. For example, users may want to:

  • inspect the delegated Edge graph;
  • run custom Edge transforms;
  • send operations that TensorRT does not support to another backend;
  • preserve independent methods such as prefill and decode;
  • add constant methods or generate an ETRecord;
  • choose the final ExecuTorch backend configuration.

This PR adds torch_tensorrt.executorch.export(). It returns ExecuTorch's standard EdgeProgramManager, which is the supported boundary for inspecting and customizing an Edge program before calling to_executorch().

Which API should I use?

Use torch_tensorrt.save() when you want a .pte in one step.

Use torch_tensorrt.executorch.export() when you need control before the final .pte is created:

import torch_tensorrt.executorch

edge = torch_tensorrt.executorch.export(trt_module)

# Inspect or transform the Edge program here.
print(edge.exported_program().graph)

program = edge.to_executorch()
with open("model.pte", "wb") as output:
    program.write_to_file(output)

# Advanced callers must also persist external tensor data when present.
program.write_tensor_data_to_file(".")

save() now delegates to the same implementation, so the simple and advanced paths do not maintain separate lowering logic.

Example: TensorRT with a fallback backend

TensorRT always receives the first chance to claim its prebuilt engine nodes. Additional partitioners run afterward and can claim operations that TensorRT does not support.

Assume cuda_partitioner is an ExecuTorch partitioner configured for your target:

edge = torch_tensorrt.executorch.export(
    trt_module,
    partitioners=[cuda_partitioner],
)

program = edge.to_executorch()

This can produce one ExecuTorch program containing both TensorRT and CUDA delegates.

Example: multiple methods

Assume prefill_program and decode_program are independent, engine-bearing ExportedProgram objects, and the two spec lists are configured for their methods:

edge = torch_tensorrt.executorch.export(
    {
        "prefill": prefill_program,
        "decode": decode_program,
    },
    compile_specs={
        "prefill": prefill_specs,
        "decode": decode_specs,
    },
)

program = edge.to_executorch()

The mapping preserves method names and allows per-method partitioner and compile-spec pipelines. It does not imply that mutable state is shared between methods.

Example: transforms, metadata, and ETRecord

Assume the transform passes and Edge configuration below are provided by the caller:

edge = torch_tensorrt.executorch.export(
    trt_module,
    transform_passes=my_edge_passes,
    compile_config=edge_compile_config,
    constant_methods={"get_vocab_size": 256},
    generate_etrecord=True,
)

program = edge.to_executorch()
program.get_etrecord().save("model_etrecord.bin")

Accepted inputs

The new API accepts:

  • a TensorRT-compiled torch.fx.GraphModule;
  • an engine-bearing torch.export.ExportedProgram;
  • a mapping from method names to independent ExportedProgram objects.

A plain torch.nn.Module must be compiled with Torch-TensorRT first.

Safety and memory behavior

Engine rewriting changes the exported graph. The new API stages an independent copy of graph structure, signatures, state containers, constants, and metadata before rewriting anything. Rewrite and lowering failures therefore do not consume or structurally modify the caller's original program.

Tensor and TensorRT engine payload storage is treated as immutable and shared with the staged structure. This avoids deep-copying potentially multi-gigabyte engines. Custom transform passes must not mutate shared payload objects.

Metadata describing symbolic shapes is also shared rather than copied. It points back into the live shape environment the exported program is guarded on, and that environment holds fake tensors which cannot be copied. Sharing it is what keeps dynamic-shape models working; copying it would also detach the copy from the shape symbols the graph depends on.

When a lifted TensorRT engine is replaced, the old placeholder, graph-signature entry, and constant are removed. Multiple calls that share one engine reuse one materialized uint8 payload buffer.

Compatibility

  • Existing one-step save(..., output_format="executorch") behavior is preserved.
  • .pte serialization and external .ptd persistence are preserved.
  • TensorRT remains the first partitioner.
  • Shared dynamic dimensions declared with Input(shared_dims=...) remain shared.
  • Zero-engine methods are allowed. A later partitioner may claim them, or portable operators may remain undelegated.

Validation

Built from source with the Torch-TensorRT C++ runtime active, against TensorRT and
ExecuTorch, and exercised on both x86_64 and aarch64 CUDA GPUs.

Checked end to end with a real compiled TensorRT engine:

  • static-shape export() writes a loadable .pte;
  • dynamic-shape export() writes a loadable .pte of the same size the previous
    one-step save path produced;
  • the caller's GraphModule still holds its original engine nodes after export();
  • engine payloads are shared rather than re-serialized;
  • save(..., output_format="executorch") still writes a .pte;
  • save() still raises TypeError for a non-list partitioners or compile_specs.

Unit and integration tests in the ExecuTorch test directory: 76 passed.

Two GPU tests in test_cuda_partitioner_composition.py,
test_erfinv_routes_to_cuda_backend and
test_weighted_partition_persists_external_data, fail with
SpecViolationError: Node.meta lowered_module_0 is missing val field. They fail the
same way on main without this change, so they are pre-existing and not caused by
this work.

Note that these tests only run in the L2 tier, which requires the ci: run-l2 label
on a pull request.

Static checks on the changed files: import ordering, formatting, lint, and type
checking.

Related work

PR #4433 forwards additional Edge-lowering options through one-step save(). After this PR lands, #4433 can keep save() as a thin wrapper over torch_tensorrt.executorch.export() instead of extending the previous private lowering path.

Comment thread py/torch_tensorrt/executorch/_export_utils.py
Comment thread py/torch_tensorrt/executorch/_export_utils.py Outdated
Comment thread py/torch_tensorrt/executorch/export.py
## What this adds

`torch_tensorrt.save(..., output_format="executorch")` turns a compiled model
into an ExecuTorch `.pte` file in one step. That is the easiest path and it stays
the recommendation.

Some workflows need to stop earlier. You may want to look at the delegated Edge
graph, run your own Edge transforms, send operations TensorRT cannot handle to
another backend, keep separate methods such as `prefill` and `decode`, add
constant methods, or pick the final ExecuTorch configuration yourself.

This adds `torch_tensorrt.executorch.export()`. It returns ExecuTorch's standard
`EdgeProgramManager`, which is the supported place to inspect and customize an
Edge program before you call `to_executorch()`.

```python
import torch_tensorrt.executorch

edge = torch_tensorrt.executorch.export(trt_module)

# Inspect or transform the Edge program here.
print(edge.exported_program().graph)

program = edge.to_executorch()
with open("model.pte", "wb") as output:
    program.write_to_file(output)

# Advanced callers must also persist external tensor data when present.
program.write_tensor_data_to_file(".")
```

`save()` now calls the same implementation, so the simple and advanced paths
share one lowering path instead of two.

## Which one should I use?

Use `save()` when you just want a `.pte`. Use
`torch_tensorrt.executorch.export()` when you need control before the `.pte` is
created.

## Example: TensorRT with a fallback backend

TensorRT always gets the first chance to claim its prebuilt engine nodes. Any
partitioners you pass run afterward and can claim operations TensorRT does not
support.

```python
edge = torch_tensorrt.executorch.export(
    trt_module,
    partitioners=[cuda_partitioner],
)

program = edge.to_executorch()
```

## Example: multiple methods

```python
edge = torch_tensorrt.executorch.export(
    {
        "prefill": prefill_program,
        "decode": decode_program,
    },
    compile_specs={
        "prefill": prefill_specs,
        "decode": decode_specs,
    },
)
```

Method names are preserved, and each method can have its own partitioner and
compile-spec pipeline. This does not make mutable state shared between methods.

## Accepted inputs

- a TensorRT-compiled `torch.fx.GraphModule`
- an engine-bearing `torch.export.ExportedProgram`
- a mapping from method names to independent `ExportedProgram` objects

A plain `torch.nn.Module` must be compiled with Torch-TensorRT first.

## Safety and memory behavior

Rewriting engine calls changes the exported graph. To avoid damaging the program
you passed in, export first stages its own copy of the graph structure,
signatures, state containers, constants, and metadata. If rewriting or lowering
fails, your original program is left alone.

Tensor and TensorRT engine payloads are treated as read-only and shared with
that staged copy, so a multi-gigabyte engine is not duplicated. Custom transform
passes must not modify these shared payload objects.

Metadata that describes symbolic shapes is a special case. It points back to the
live shape environment that the exported program is guarded on, so it is shared
rather than copied. Copying it would both fail (the shape environment holds fake
tensors that cannot be duplicated) and detach the copy from the symbols the graph
depends on. This is what keeps dynamic-shape models working.

When a lifted TensorRT engine is replaced, the old placeholder, its
graph-signature entry, and its constant are removed. Several calls that share one
engine reuse a single materialized `uint8` payload buffer.

## Compatibility

- Existing one-step `save(..., output_format="executorch")` behavior is preserved,
  including its rejection of non-list `partitioners` and `compile_specs`.
- `"executorch"` is now included in the `output_format` type hint. It was already
  accepted at run time, so this only fixes the annotation.
- `.pte` serialization and external `.ptd` persistence are preserved.
- TensorRT remains the first partitioner.
- Shared dynamic dimensions declared with `Input(shared_dims=...)` remain shared.
- Zero-engine methods are allowed. A later partitioner may claim them, or portable
  operators may remain undelegated.

## Testing

Tested on a CUDA GPU with TensorRT and ExecuTorch installed, building
Torch-TensorRT from source so the C++ runtime was active.

Verified by compiling a real model with TensorRT and then exporting it:

- static-shape `export()` produces a loadable `.pte`
- dynamic-shape `export()` produces a loadable `.pte`, matching the output of the
  previous one-step save path
- the source `GraphModule` still holds its original engine nodes afterward
- engine payloads are shared, not re-serialized
- `save(..., output_format="executorch")` still writes a `.pte`
- `save()` still raises `TypeError` for a non-list `partitioners` or
  `compile_specs`

Also ran the ExecuTorch test directory (76 passed). Two GPU tests in
`test_cuda_partitioner_composition.py` fail, and they fail the same way with this
change reverted, so they are not caused by it.

Static checks: formatting, import ordering, lint, and type checking on the changed
files.
A partitioner can carry method-specific state. ExecuTorch backends bake the
method name into the DelegationSpec built in the partitioner constructor, so
reusing one instance across methods tags every method with the first method's
name, and each delegate then looks up the wrong compiled method at runtime.

export() broadcast a flat partitioners= sequence to every method, which made
that mistake easy and silent. Reject it when there is more than one method and
point at the per-method mapping form instead. A flat sequence still works for a
single method, and compile_specs (plain data, not stateful objects) still
broadcasts unchanged.

Tests: reject a shared instance across two methods, allow a flat sequence for
one method, and confirm a per-method mapping preserves each instance.
…alidation

Three fixes found while reviewing the composable export path.

1. Staging shared a node.meta value wholesale whenever it contained a symbolic
   size or fake tensor anywhere inside it. A multi-output op stores a list of
   fake tensors in meta["val"], so that list stayed shared with the caller's
   program and an Edge transform mutating it would corrupt the caller's input.
   Seed only the shape-bound leaves into the deepcopy memo, so the leaves are
   still shared (they must be, they belong to the live ShapeEnv) while the
   container around them is copied.

2. A partitioner can carry method-specific state: ExecuTorch backends bake the
   method name into the DelegationSpec built in the constructor, so reusing one
   instance across methods tags every method with the first method's name and
   each delegate then looks up the wrong compiled method at runtime. Reject a
   reused instance whether it arrives as a flat sequence broadcast to every
   method or as the same object under two method keys.

3. ExecuTorch dispatches per-method transform passes on isinstance(passes, dict),
   so a Mapping that is not a dict silently ran no passes at all. Normalize it.

Tests: a container holding symbolic leaves is copied while the leaves stay
shared; a reused partitioner is rejected in both forms while distinct instances
and the single-method flat form still work; a non-dict Mapping of transform
passes is normalized to dict.
…tant methods

Two review findings from adversarial testing of the composable export path.

The staged Edge program shares weight and TensorRT engine storage with the
programs the caller passed in. Only structure is copied. That is deliberate (it
keeps a multi-gigabyte engine from being duplicated) but it was documented only
in the export() docstring, while the saving guide showed transform_passes= with
no caveat. An in-place edit in a transform pass, or a change to a source program
after export, silently changes the caller's program and any sibling exported
from it, with no warning. Document the contract where callers read it, and say
plainly that neither case raises.

Also document the several-methods-in-one-pte form, which was described in prose
but had no example, including the requirement that each method gets its own
partitioner instances.

Constant-method names are baked into the .pte as method names, so reject keys
that are not valid Python identifiers instead of forwarding them.

Tests: invalid constant-method keys (spaces, leading digit, empty, non-string)
are rejected; a valid key is forwarded unchanged.
retrace=False, arg_inputs=inputs,
)

``save`` writes both the ``.pte`` and any external ``.ptd`` tensor-data files.

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.

I would suggest here first add the torch_tensorrt.save() as the default method for user wants to export a executorch pte file.
then add the advanced method, when user needs a customization before serialize into a pte file.
So that we explain user both scenario clearly.

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.

Also please wait for PR: 4398 to be merged to main(I will push by the end of today.)
please rebase on top of that.

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

Labels

cla signed component: api [Python] Issues re: Python API component: tests Issues re: Tests documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants