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
15 changes: 14 additions & 1 deletion .github/scripts/verify-executorch-reference-runner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,12 @@ require_tar_entry "torch_tensorrt/bin/example_executorch_runner"
require_tar_entry "torch_tensorrt/lib/libextension_cuda.so"
require_tar_entry "torch_tensorrt/examples/executorch_reference_runner/kv_cache_decode_check.cpp"
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 @@ -305,7 +311,14 @@ fi

cmake "${cmake_args[@]}"

build_targets=(example_executorch_runner)
# 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.
build_targets=(
example_executorch_runner
example_executorch_multi_profile_runner
example_executorch_multi_profile_benchmark
)
if [[ -n "${kv_model_path}" ]]; then
build_targets+=(kv_cache_decode_check)
fi
Expand Down
25 changes: 22 additions & 3 deletions core/runtime/TRTEngine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -720,15 +720,34 @@ 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 cannot be honored. A single-profile engine
// is the one case where that is not a mistake to fail on: it has profile 0 and
// nothing to switch to, so a pin aimed at some other engine is ignored with a
// warning rather than raising. A multi-profile engine handed an index it lacks
// could have done something different, and a negative index is a computed value
// gone wrong on any engine, so both stay hard errors -- silently running on
// mistuned kernels is worse than stopping. Same split as the ExecuTorch backend
// (kPinIgnoredSingleProfile vs kRequestedProfileUnavailable).
//
// Reachable only by driving the engine directly; the Python wrapper validates
// the index against the profile count first.
if (num_optimization_profiles <= 1 && profile_index > 0) {
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;
}
TORCHTRT_CHECK(
profile_index >= 0 && profile_index < num_optimization_profiles,
"Optimization profile index " << profile_index << " is out of range: this engine has "
<< num_optimization_profiles << " optimization profile(s).");
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
2 changes: 1 addition & 1 deletion core/runtime/TRTEngine.h
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ struct TRTEngine : torch::CustomClassHolder {
// method names are exposed via torchbind in register_jit_hooks.cpp
// (``num_optimization_profiles``, ``_active_profile_index``,
// ``_auto_select_profiles``, ``set_active_profile``). Index validation lives
// in the runtime-agnostic TorchTensorRTModule.resolve_profile_index.
// in the runtime-agnostic TorchTensorRTModule.set_optimization_profile.
int64_t num_optimization_profiles = 1; // cuda_engine->getNbOptimizationProfiles()
int64_t active_profile_index = 0; // profile currently loaded in exec_ctx
bool auto_select_profiles = false; // opt-in shape-based selection (per call)
Expand Down
31 changes: 29 additions & 2 deletions cpp/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,33 @@ 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 = [
Expand All @@ -161,18 +185,19 @@ cc_library(
deps = [
":tensorrt_executorch_binding_names",
":tensorrt_executorch_blob_header",
":tensorrt_executorch_optimization_profile_selection",
":tensorrt_executorch_weight_streaming_budget",
] + select({
":linux_x86_64": [
"@cuda//:cudart",
"@executorch//:executorch_headers",
"@executorch//:extension_cuda",
"@cuda//:cudart",
"@tensorrt//:nvinfer",
],
":sbsa": [
"@cuda//:cudart",
"@executorch//:executorch_headers",
"@executorch//:extension_cuda",
"@cuda//:cudart",
"@tensorrt_sbsa//:nvinfer",
],
"//conditions:default": [],
Expand All @@ -184,6 +209,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 @@ -204,6 +230,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
210 changes: 210 additions & 0 deletions cpp/include/torch_tensorrt/executorch/OptimizationProfileSelection.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/*
* 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.
*/

/**
* @file OptimizationProfileSelection.h
* @brief 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 {

/// @brief The [min, max] dim envelope one optimization profile allows for one input.
struct InputProfileBounds {
nvinfer1::Dims min{}; ///< Smallest shape the profile accepts.
nvinfer1::Dims max{}; ///< Largest shape the profile accepts.
};

/// @brief Everything a profile decision depends on, read from the engine once at init().
struct ProfileTable {
/**
* @brief Bounds 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;
/// @brief The profile currently loaded into the execution context.
int32_t active = 0;

/// @return The engine's optimization profile count.
int32_t size() const {
return static_cast<int32_t>(bounds.size());
}
};

/// @brief 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.
};

/**
* @brief The outcome of one profile decision.
*
* 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, ///< The selected profile is usable.
/**
* @brief 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,
/**
* @brief A pinned index this engine does not have and cannot substitute for.
*
* Fatal, on the same split the standard runtime uses in
* TRTEngine::set_active_profile_with_stream: an engine that had profiles to
* choose between must not quietly run on one the caller did not ask for.
* 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.
*/
kRequestedProfileUnavailable,
/// @brief Auto-selection ran out of profiles.
kNoProfileMatchesInputs,
};

/**
* @brief Whether one input shape falls inside one profile's envelope.
*
* @param dims The runtime shape of the input.
* @param bounds The profile's [min, max] envelope for that input.
* @return true when the ranks match and every extent is in range.
*/
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;
}

/**
* @brief Whether one profile accepts every input shape of an execution.
*
* A profile index or bounds row that cannot describe these inputs answers "does
* not fit" rather than indexing past the end of the table. Neither is reachable
* from the backend, which builds one row of num_inputs bounds per profile and
* only ever stores an index this function approved -- but this is installed
* public API, and on the auto path the answer also happens to be the useful one:
* a ProfileTable carrying a stale ProfileTable::active rescans from 0 instead of
* crashing.
*
* @param table The engine's profile bounds.
* @param profile Index of the profile to test.
* @param input_dims Runtime shape of each input, in binding order.
* @return true when the profile accepts all of them.
*/
inline bool profile_fits(const ProfileTable& table, int32_t profile, const std::vector<nvinfer1::Dims>& input_dims) {
if (profile < 0 || profile >= table.size()) {

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.

Nothing exercises this guard or the rank check above it. I deleted each in turn and all eighteen tests still passed, including under a hardened standard library, so it is a real gap rather than a test that forgets to assert. A negative pinned index reaching profile_fits, and a table whose extra extent is nonzero, would cover both.

Separately, the comment above promises a fit only when the ranks match, but the code only checks the min bound's rank, never the max.

return false;
}
const auto& bounds = table.bounds[static_cast<size_t>(profile)];
if (bounds.size() < input_dims.size()) {
return false;
}
for (size_t i = 0; i < input_dims.size(); ++i) {
if (!dims_fit(input_dims[i], bounds[i])) {
return false;
}
}
return true;
}

/**
* @brief Resolves one thread's profile request against one engine.
*
* @param table The engine's profile bounds and currently loaded profile.
* @param request What the calling thread asked for.
* @param index The pinned profile index; read only for ProfileRequest::kPinned.
* @param input_dims Runtime shape of each input, in binding order.
* @param[out] selected The profile to run, written only when the result allows
* execution to continue (ProfileSelection::kOk or
* ProfileSelection::kPinIgnoredSingleProfile).
* @return What the caller should do, and which message it should print.
*/
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