Skip to content

fix(executorch): copy the engine in bulk instead of byte by byte - #4473

Merged
lanluo-nvidia merged 1 commit into
pytorch:mainfrom
shoumikhin:trt-engine-serialization-perf
Aug 18, 2026
Merged

fix(executorch): copy the engine in bulk instead of byte by byte#4473
lanluo-nvidia merged 1 commit into
pytorch:mainfrom
shoumikhin:trt-engine-serialization-perf

Conversation

@shoumikhin

Copy link
Copy Markdown
Contributor

The problem

Serializing a TensorRT engine to bytes goes through bytes(tensor.untyped_storage()).
That reads like a bulk copy, but it iterates the storage one element at a time in Python,
at roughly two seconds per megabyte:

16.8 MB bytes(untyped_storage()) 36.27 s
16.8 MB memoryview(... .numpy()) 0.01 s

For a model whose graph produces many engines, this step dominates the entire export. A
program with 8.8 GB of engines spends about five hours here. One with 70 GB spends closer
to two days. It is easy to mistake for a slow engine build, because the export simply sits
there making no visible progress.

It is also wrong for a view

contiguous() is a no-op for a tensor that is already contiguous, including a row slice of
a larger tensor. In that case the storage holds neighbouring bytes, so the serialized
engine comes out longer than the tensor it came from:

base=torch.arange(8, dtype=torch.uint8).reshape(2, 4)
row=base[0] # tensor is [0, 1, 2, 3]bytes(row.contiguous().untyped_storage()) # [0, 1, 2, 3, 4, 5, 6, 7]bytes(memoryview(row.contiguous().numpy())) # [0, 1, 2, 3]

Today's engines happen to own their whole storage, so this does not bite in practice yet.
It is a trap for anyone who later produces the engine buffer as a slice.

The fix

Copy through a memoryview over the tensor's own buffer:

engine_info[ENGINE_IDX] =bytes(
memoryview(serialized_engine.cpu().contiguous().view(torch.uint8).numpy())
)

The comment on the previous code explains what it was avoiding: .numpy().tobytes()
allocates a second full-size buffer, which roughly doubles peak memory for a multi-gigabyte
engine. memoryview keeps that property, since it is a view rather than a copy, while
doing the copy in one shot and respecting the tensor's bounds.

Testing

Verified byte-for-byte equality against the previous path for the shapes this code sees:

host contiguous uint8 old 8.4 MB new 8.4 MB identical
device uint8 old 8.4 MB new 8.4 MB identical
non-contiguous view old 16.8 MB new 8.4 MB differs, and the new value is correct

The third row is the bug above: the old path returned the whole storage rather than the
tensor.

Also exported a multi-method model that produces ten engines totalling 8.8 GB. Before the
change the serialization step ran for over an hour and a half without finishing; after it,
the same step completes in minutes and produces a program that loads and runs.

@github-actionsgithub-actionsBot added the component: api [Python] Issues re: Python API label Aug 9, 2026
@shoumikhin
shoumikhinforce-pushed the trt-engine-serialization-perf branch from 21ac6db to 4063483CompareAugust 9, 2026 23:37
Serializing an engine went through bytes() on the tensors storage object.
@shoumikhin
shoumikhinforce-pushed the trt-engine-serialization-perf branch from 4063483 to e0cf2b9CompareAugust 13, 2026 02:50
@github-actionsgithub-actionsBot added the component: tests Issues re: Tests label Aug 13, 2026

@lanluo-nvidialanluo-nvidia 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.

LGTM

Conarnar added a commit to Conarnar/TensorRT that referenced this pull request Aug 18, 2026
Export resolves engine info for two purposes, and neither wants the base64 form
serialize() produces. validate_engine_program reads only flags;
replace_execute_engine wants the engine as a byte tensor. Both were paying an
encode in C++ and, for the second, a matching decode here.
Take each half from the accessor that provides it:
metadata_only=True on get_engine_info_from_state and _resolve_engine_info,
so a metadata reader never triggers engine serialization.
_resolve_engine_tensor returns the engine as a uint8 tensor directly, which
also drops the torch.frombuffer rebuild.
Both fall back to the old path when the runtime lacks the accessors, so this
does not require a matching runtime. They are missing only when the
Torch-TensorRT C++ library is older than this Python package -- a source or
editable build where only the Python half was rebuilt. The symptom would
otherwise be silence, since the fallback is correct and merely slower, so it
warns once per accessor naming the cause.
_resolve_engine_object is factored out of _resolve_engine_info because the
engine arg is a get_attr before ExecuTorch lifts constants and a placeholder
after; handling only the first made the tensor accessor silently fall back to
base64 on every graph that had been through staging, which is exactly the case
that matters.
Nothing is cached. With the accessors present a metadata read is a member read
away from free, so there is nothing worth memoizing; a runtime without them
re-serializes per read, which is the price of keeping this change small.
backend.py and partitioner.py are deliberately untouched. Their engine reads go
through the no_op_placeholder branch, which already carries the engine as a
tensor argument and serializes nothing, so routing them through the accessors
would add a parameter that changes no work.
Measured on a base carrying pytorch#4473, with pytorch#4489 present in the loaded runtime,
exporting a model with a 67MB engine; six runs per configuration, interleaved.
The passes that read engine info go from 1.40s to 0.11s and the ExecuTorch
lowering phase from 2.35s to 1.08s, a 54% reduction. Engine-state reads are
nearly all of that: 1.07s to 0.07s, with the Python-side base64 decode going
from 0.26s to nothing. Serializing the engine is only about 0.07s of a 0.54s
__getstate__, so most of what disappears is the base64 encode and the string
copies feeding it. A metadata-only read costs 10-20us.
@lanluo-nvidia
lanluo-nvidia merged commit fbb10c9 into pytorch:mainAug 18, 2026
135 of 184 checks passed
Conarnar added a commit to Conarnar/TensorRT that referenced this pull request Aug 18, 2026
Export resolves engine info for two purposes, and neither wants the base64 form
serialize() produces. validate_engine_program reads only flags;
replace_execute_engine wants the engine as a byte tensor. Both were paying an
encode in C++ and, for the second, a matching decode here.
Take each half from the accessor that provides it:
metadata_only=True on get_engine_info_from_state and _resolve_engine_info,
so a metadata reader never triggers engine serialization.
_resolve_engine_tensor returns the engine as a uint8 tensor directly, which
also drops the torch.frombuffer rebuild.
Both fall back to the old path when the runtime lacks the accessors, so this
does not require a matching runtime. They are missing only when the
Torch-TensorRT C++ library is older than this Python package -- a source or
editable build where only the Python half was rebuilt. The symptom would
otherwise be silence, since the fallback is correct and merely slower, so it
warns once per accessor naming the cause.
_resolve_engine_object is factored out of _resolve_engine_info because the
engine arg is a get_attr before ExecuTorch lifts constants and a placeholder
after; handling only the first made the tensor accessor silently fall back to
base64 on every graph that had been through staging, which is exactly the case
that matters.
Nothing is cached. With the accessors present a metadata read is a member read
away from free, so there is nothing worth memoizing; a runtime without them
re-serializes per read, which is the price of keeping this change small.
backend.py and partitioner.py are deliberately untouched. Their engine reads go
through the no_op_placeholder branch, which already carries the engine as a
tensor argument and serializes nothing, so routing them through the accessors
would add a parameter that changes no work.
Measured on a base carrying pytorch#4473, with pytorch#4489 present in the loaded runtime,
exporting a model with a 67MB engine; six runs per configuration, interleaved.
The passes that read engine info go from 1.40s to 0.11s and the ExecuTorch
lowering phase from 2.35s to 1.08s, a 54% reduction. Engine-state reads are
nearly all of that: 1.07s to 0.07s, with the Python-side base64 decode going
from 0.26s to nothing. Serializing the engine is only about 0.07s of a 0.54s
__getstate__, so most of what disappears is the base64 encode and the string
copies feeding it. A metadata-only read costs 10-20us.
Conarnar added a commit to Conarnar/TensorRT that referenced this pull request Aug 19, 2026
Export resolves engine info for two purposes, and neither wants the base64 form
serialize() produces. validate_engine_program reads only flags;
replace_execute_engine wants the engine as a byte tensor. Both were paying an
encode in C++ and, for the second, a matching decode here.
Take each half from the accessor that provides it:
metadata_only=True on get_engine_info_from_state and _resolve_engine_info,
so a metadata reader never triggers engine serialization.
_resolve_engine_tensor returns the engine as a uint8 tensor directly, which
also drops the torch.frombuffer rebuild.
Both fall back to the old path when the runtime lacks the accessors, so this
does not require a matching runtime. They are missing only when the
Torch-TensorRT C++ library is older than this Python package -- a source or
editable build where only the Python half was rebuilt. The symptom would
otherwise be silence, since the fallback is correct and merely slower, so it
warns once per accessor naming the cause.
_resolve_engine_object is factored out of _resolve_engine_info because the
engine arg is a get_attr before ExecuTorch lifts constants and a placeholder
after; handling only the first made the tensor accessor silently fall back to
base64 on every graph that had been through staging, which is exactly the case
that matters.
Nothing is cached. With the accessors present a metadata read is a member read
away from free, so there is nothing worth memoizing; a runtime without them
re-serializes per read, which is the price of keeping this change small.
backend.py and partitioner.py are deliberately untouched. Their engine reads go
through the no_op_placeholder branch, which already carries the engine as a
tensor argument and serializes nothing, so routing them through the accessors
would add a parameter that changes no work.
Measured on a base carrying pytorch#4473, with pytorch#4489 present in the loaded runtime,
exporting a model with a 67MB engine; six runs per configuration, interleaved.
The passes that read engine info go from 1.40s to 0.11s and the ExecuTorch
lowering phase from 2.35s to 1.08s, a 54% reduction. Engine-state reads are
nearly all of that: 1.07s to 0.07s, with the Python-side base64 decode going
from 0.26s to nothing. Serializing the engine is only about 0.07s of a 0.54s
__getstate__, so most of what disappears is the base64 encode and the string
copies feeding it. A metadata-only read costs 10-20us.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla signedcomponent: api [Python]Issues re: Python APIcomponent: testsIssues re: Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@shoumikhin@lanluo-nvidia