Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/scripts/verify-executorch-reference-runner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,12 @@ require_tar_entry() {
require_tar_entry "torch_tensorrt/src/torch_tensorrt/executorch/CMakeLists.txt"
require_tar_entry "torch_tensorrt/examples/executorch_reference_runner/CMakeLists.txt"
require_tar_entry "torch_tensorrt/BUILD"
# Every source the packaged CMakeLists.txt names must ship. A missing one aborts
# the configure step below for all targets, not just the one that needs it, so
# check them here to fail at packaging with a clear message instead.
require_tar_entry "torch_tensorrt/examples/executorch_reference_runner/main.cpp"
require_tar_entry "torch_tensorrt/examples/executorch_reference_runner/multi_profile_main.cpp"
require_tar_entry "torch_tensorrt/examples/executorch_reference_runner/multi_profile_benchmark.cpp"

export TORCH_TENSORRT_ROOT="${verify_root}/torch_tensorrt"
export TORCHTRT_EXECUTORCH_SOURCE_DIR="${TORCH_TENSORRT_ROOT}/src/torch_tensorrt/executorch"
Expand All @@ -311,8 +317,13 @@ fi

cmake "${cmake_args[@]}"

# The multi-profile targets are built but not run: they need a Gemma-3 .pte and a
# GPU. Compiling them here is what keeps the packaged sources from rotting, since
# nothing else in CI touches them.
cmake --build "${verify_root}/build-executorch-reference-runner" \
--target example_executorch_runner \
example_executorch_multi_profile_runner \
example_executorch_multi_profile_benchmark \
-j"${MAX_JOBS:-$(nproc)}"

runner_log="${verify_root}/my_runner.log"
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/executorch-static-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ jobs:

# this is to build the libtorchtrt.tar.gz
bazel build //:libtorchtrt --compilation_mode opt --config=linux
# these need no GPU, so this is the one job that can run them
bazel test //tests/cpp/executorch:executorch_backend_tests --config=linux
executorch_cmake_location="$(bazel query @executorch//:executorch/CMakeLists.txt --output=location)"
export EXECUTORCH_SOURCE_DIR="$(dirname "${executorch_cmake_location%%:*}")"
export EXECUTORCH_ROOT="${EXECUTORCH_SOURCE_DIR}"
Expand Down
14 changes: 11 additions & 3 deletions core/runtime/TRTEngine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -695,15 +695,23 @@ void TRTEngine::set_active_profile(int64_t profile_index) {
}

void TRTEngine::set_active_profile_with_stream(int64_t profile_index, const c10::cuda::CUDAStream& stream) {
if (num_optimization_profiles <= 1) {
// An index this engine does not have is a request that cannot be honored, so
// say so rather than no-op quietly. Covers the single-profile engine too, where
// 0 is the only valid index. Reachable only by driving the engine directly; the
// Python wrapper validates the index against the profile count first.
if (profile_index < 0 || profile_index >= num_optimization_profiles) {
LOG_WARNING(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

On a multi-profile engine this turns a hard failure into a warning.

Before, an out-of-range index skipped the <= 1 guard, reached
setOptimizationProfileAsync, got false back, and TORCHTRT_CHECK threw. Now it
warns and returns, so set_active_profile(99) on a 2-profile engine goes from raising
to silently continuing on whatever profile was already loaded, which means silently
mistuned kernels.

Making the single-profile case non-silent is a genuine improvement. Would you consider
keeping the throw for an out-of-range index on a multi-profile engine, and warning only
where the engine could not have done anything differently (one profile, any nonzero
index)?

Reachability is limited: set_optimization_profile validates first and raises
ValueError, so only a caller driving the engine directly can hit this. I also checked
the warning cannot spam a hot loop, since every per-call caller is gated on
num_optimization_profiles > 1 and passes an index it already checked with
profile_fits.

One small thing while you are here: this fixed the .cpp comment that pointed at
TorchTensorRTModule.resolve_profile_index, but the identical reference survives at
TRTEngine.h:300. That name has never existed as code (git log -S finds it only in
those two comments); the real validator is
TorchTensorRTModule.set_optimization_profile.

"Ignoring optimization profile index " << profile_index << ": this engine has " << num_optimization_profiles
<< " optimization profile(s), so it stays on profile "
<< active_profile_index << ".");
return;
}
if (profile_index == active_profile_index) {
return;
}

// setOptimizationProfileAsync returns false for an out-of-range index; the
// index is validated upstream in TorchTensorRTModule.resolve_profile_index.
// The index is in range by the check above, so a false return here is TensorRT
// refusing the switch for some other reason.
TORCHTRT_CHECK(
exec_ctx()->setOptimizationProfileAsync(static_cast<int32_t>(profile_index), stream.stream()),
"Failed to switch to optimization profile index " << profile_index);
Expand Down
33 changes: 30 additions & 3 deletions cpp/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,39 @@ cc_library(
],
)

cc_library(
name = "tensorrt_executorch_optimization_profile_selection",
hdrs = [
"include/torch_tensorrt/executorch/OptimizationProfileSelection.h",
],
strip_include_prefix = "include",
# The header includes <NvInfer.h>, so it cannot build where the deps below
# resolve to an empty list.
target_compatible_with = select({
":linux_x86_64": [],
":sbsa": [],
"//conditions:default": ["@platforms//:incompatible"],
}),
deps = select({
":linux_x86_64": ["@tensorrt//:nvinfer"],
":sbsa": ["@tensorrt_sbsa//:nvinfer"],
"//conditions:default": [],
}),
)

cc_library(
name = "tensorrt_executorch_backend",
srcs = [
# Private, deliberately not in hdrs: EngineHandle grows fields as the
# backend gains features and is never installed, so nothing outside this
# library may depend on its layout.
"src/torch_tensorrt/executorch/EngineHandle.h",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

EngineHandle.h in srcs rather than hdrs deviates from the rest of the repo, where .cpp goes in srcs and .h in hdrs without exception.

It works, and putting a deliberately private header in srcs is a legitimate Bazel idiom that matches the "not installed" note in the file. Just unexpected for a reader, so a one-line comment saying it is intentionally private would help.

"src/torch_tensorrt/executorch/TensorRTBackend.cpp",
],
hdrs = [
"include/torch_tensorrt/executorch/TensorRTBackend.h",
],
strip_include_prefix = "include",
# Build the TensorRT backend as a static library. The final application
# links this target together with the ExecuTorch runtime it was compiled
# against, avoiding any runtime plugin/dlopen dependency.
Expand All @@ -123,19 +148,19 @@ cc_library(
":sbsa": [],
"//conditions:default": ["@platforms//:incompatible"],
}),
strip_include_prefix = "include",
deps = [
":tensorrt_executorch_binding_names",
":tensorrt_executorch_blob_header",
":tensorrt_executorch_optimization_profile_selection",
] + select({
":linux_x86_64": [
"@executorch//:executorch_headers",
"@cuda//:cudart",
"@executorch//:executorch_headers",
"@tensorrt//:nvinfer",
],
":sbsa": [
"@executorch//:executorch_headers",
"@cuda//:cudart",
"@executorch//:executorch_headers",
"@tensorrt_sbsa//:nvinfer",
],
"//conditions:default": [],
Expand All @@ -147,6 +172,7 @@ filegroup(
name = "executorch_backend_source_files",
srcs = [
"src/torch_tensorrt/executorch/CMakeLists.txt",
"src/torch_tensorrt/executorch/EngineHandle.h",
"src/torch_tensorrt/executorch/README.md",
"src/torch_tensorrt/executorch/TensorRTBackend.cpp",
"src/torch_tensorrt/executorch/TensorRTBlobHeader.cpp",
Expand All @@ -166,6 +192,7 @@ filegroup(
filegroup(
name = "executorch_api_headers",
srcs = [
"include/torch_tensorrt/executorch/OptimizationProfileSelection.h",
"include/torch_tensorrt/executorch/TensorRTBackend.h",
"include/torch_tensorrt/executorch/TensorRTBindingNames.h",
"include/torch_tensorrt/executorch/TensorRTBlobHeader.h",
Expand Down
158 changes: 158 additions & 0 deletions cpp/include/torch_tensorrt/executorch/OptimizationProfileSelection.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/*
* Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree.
*
* Which TensorRT optimization profile an execution runs under.
*
* Kept free of ExecuTorch, CUDA, and the engine itself so the policy can be
* exercised without a GPU; reporting the outcome is left to the caller.
*/
#pragma once

#include <NvInfer.h>

#include <cstdint>
#include <vector>

namespace torch_tensorrt {
namespace executorch_backend {

// The [min, max] dim envelope one optimization profile allows for one input.
struct InputProfileBounds {
nvinfer1::Dims min{};
nvinfer1::Dims max{};
};

// Everything a profile decision depends on, read from the engine once at init().
struct ProfileTable {
// Indexed [profile][input]. The outer size is the engine's optimization
// profile count, which is at least 1; a single-profile engine keeps exactly
// one row and never switches.
std::vector<std::vector<InputProfileBounds>> bounds;
// The profile currently loaded into the execution context.
int32_t active = 0;

int32_t size() const {
return static_cast<int32_t>(bounds.size());
}
};

// What the calling thread asked for, as resolved from OptimizationProfileGuard.
enum class ProfileRequest {
kUnset, // no guard in scope
kPinned, // an exact index
kAuto, // choose from the input shapes
};

// Its own enum rather than executorch's Error so that this header stays
// independent of executorch and can be tested separately.
//
// Two axes: whether execution continues, and which message the caller prints.
// The two failure values stay apart rather than being merged and re-derived from
// the request kind, because the empty-table guard in select_profile() returns
// kNoProfileMatchesInputs for every request kind -- so one merged value would put
// the message back at the mercy of which branches each request can reach.
enum class ProfileSelection {
kOk,
// Succeeded, but the pin could not be honored and profile 0 was used instead.
// Distinct from kOk so the caller can warn that the pin did nothing here.
kPinIgnoredSingleProfile,
// A pinned index this engine does not have and cannot substitute for.
//
// Fatal here, where TRTEngine::set_active_profile_with_stream only warns for the
// same mistake. That is deliberate, not an oversight: each runtime is strict at
// its outermost validating layer and lenient below it. The standard runtime
// rejects an out-of-range index in TorchTensorRTModule.set_optimization_profile
// before the engine is reached, so its engine-level check is a backstop.
// OptimizationProfileGuard cannot validate anything -- it never sees an engine,
// by design -- so execute() is the only place an ExecuTorch caller's bad index
// can be caught at all. Downgrading this to a warning would leave the whole
// ExecuTorch path with no index validation anywhere.
kRequestedProfileUnavailable,
// Auto-selection ran out of profiles.
kNoProfileMatchesInputs,
};

inline bool dims_fit(const nvinfer1::Dims& dims, const InputProfileBounds& bounds) {
if (dims.nbDims != bounds.min.nbDims) {
return false;
}
for (int d = 0; d < dims.nbDims; ++d) {
if (dims.d[d] < bounds.min.d[d] || dims.d[d] > bounds.max.d[d]) {
return false;
}
}
return true;
}

inline bool profile_fits(const ProfileTable& table, int32_t profile, const std::vector<nvinfer1::Dims>& input_dims) {
const auto& bounds = table.bounds[static_cast<size_t>(profile)];
for (size_t i = 0; i < input_dims.size(); ++i) {
if (!dims_fit(input_dims[i], bounds[i])) {
return false;
}
}
return true;
}

// Resolves one thread's profile request against one engine. `index` is read
// only for ProfileRequest::kPinned.
inline ProfileSelection select_profile(
const ProfileTable& table,
ProfileRequest request,
int32_t index,
const std::vector<nvinfer1::Dims>& input_dims,
int32_t& selected) {
// init() rejects an engine reporting no profiles, so this is unreachable in the
// backend. Checked here so the policy is safe to call on its own rather than on
// the strength of a guard in another translation unit.
if (table.bounds.empty()) {
return ProfileSelection::kNoProfileMatchesInputs;
}

if (request == ProfileRequest::kUnset) {
selected = 0;
return ProfileSelection::kOk;
}

if (request == ProfileRequest::kAuto) {
// Sticky first-fit: keep the loaded profile while it still fits, so shapes
// that alternate between two equally valid profiles don't thrash the
// context. Only rescan from 0 once it stops fitting. Overlapping profiles
// therefore resolve by history, not by lowest index; pin explicitly when
// that matters.
if (profile_fits(table, table.active, input_dims)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

select_profile can index bounds out of range on a malformed table.

The guard above checks only the outer vector, so bounds.empty() being false says
nothing about active naming a valid row:

  if (table.bounds.empty()) {
    return ProfileSelection::kNoProfileMatchesInputs;
  }
  ...
    if (profile_fits(table, table.active, input_dims)) {

profile_fits then does table.bounds[static_cast<size_t>(profile)] (line 91) with no
range check. A size-2 table with active = 5 segfaults. The same applies if a bounds
row is shorter than input_dims, since bounds[i] is indexed over input_dims.size()
rather than the row length.

Neither is reachable through execute() today. profiles.active is only written at
TensorRTBackend.cpp:596 with a value select_profile already validated, and
initialize_input_profiles builds exactly num_inputs bounds per row. So this is
latent, not a live bug, and I would not hold the PR for it.

Raising it because of the comment right above the guard:

Checked here so the policy is safe to call on its own rather than on the strength of
a guard in another translation unit.

That is the bar this header sets for itself, and it is installed public API, so "on its
own" includes callers you do not control. The empty-table case got defense in depth;
the two sibling cases that actually crash did not. Either range-check active and the
row length, or narrow that comment to state the precondition. Fine as a follow-up.

selected = table.active;
return ProfileSelection::kOk;
}
for (int32_t p = 0; p < table.size(); ++p) {
if (profile_fits(table, p, input_dims)) {
selected = p;
return ProfileSelection::kOk;
}
}
return ProfileSelection::kNoProfileMatchesInputs;
}

if (index >= 0 && index < table.size()) {
selected = index;
return ProfileSelection::kOk;
}

// A single-profile engine has no choice to get wrong: profile 0 is the only
// thing it can run, whether or not its shapes are dynamic. So a pin aimed at a
// multi-profile sibling in the same method must not fail it. An engine with
// several profiles is different -- substituting one would be a guess -- so an
// index it lacks stays an error there.
if (index > 0 && table.size() == 1) {
selected = 0;
return ProfileSelection::kPinIgnoredSingleProfile;
}

return ProfileSelection::kRequestedProfileUnavailable;
}

} // namespace executorch_backend
} // namespace torch_tensorrt
Loading
Loading