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
23 changes: 20 additions & 3 deletions cpp/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,29 @@ cc_library(
],
)

cc_library(
name = "tensorrt_executorch_optimization_profile_selection",
hdrs = [
"src/torch_tensorrt/executorch/OptimizationProfileSelection.h",
],
strip_include_prefix = "src/torch_tensorrt/executorch",

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.

This puts a bare OptimizationProfileSelection.h on the include path of every app that links the backend.

strip_include_prefix here is the header's own directory, so the header ends up with no directory prefix at all. Every other header target in this repo strips to a directory:

strip_include_prefix = "include"   # -> torch_tensorrt/executorch/TensorRTBlobHeader.h
strip_include_prefix = "include"   # -> torch_tensorrt/executorch/TensorRTBindingNames.h

Since this target is in deps of tensorrt_executorch_backend, the virtual include dir propagates transitively, so a downstream app with its own file of that name can shadow ours (or vice versa). Bazel-only hygiene, no behavior change, so low priority, but the rest of the project deliberately avoids this.

If you do change it, two things to watch. The bare spelling is what makes one #include work in both build systems, because the header lives in src/ and the CMake build only puts cpp/include on the path (cpp/src/torch_tensorrt/executorch/CMakeLists.txt:37), so switching to strip_include_prefix = "src" alone would fix Bazel and break CMake. And the two existing include sites would need updating too:

  • cpp/src/torch_tensorrt/executorch/EngineHandle.h
  • tests/cpp/executorch/test_optimization_profile_selection.cpp

Simplest version is probably to move the header to cpp/include/torch_tensorrt/executorch/, include it as "torch_tensorrt/executorch/OptimizationProfileSelection.h" in both places, and strip to include like the siblings. It is already effectively public since the test depends on it.

deps = select({
":linux_x86_64": ["@tensorrt//:nvinfer"],
":sbsa": ["@tensorrt_sbsa//:nvinfer"],
"//conditions:default": [],
}),
)

cc_library(
name = "tensorrt_executorch_backend",
srcs = [
"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 +138,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 +162,8 @@ filegroup(
name = "executorch_backend_source_files",
srcs = [
"src/torch_tensorrt/executorch/CMakeLists.txt",
"src/torch_tensorrt/executorch/EngineHandle.h",
"src/torch_tensorrt/executorch/OptimizationProfileSelection.h",
"src/torch_tensorrt/executorch/README.md",
"src/torch_tensorrt/executorch/TensorRTBackend.cpp",
"src/torch_tensorrt/executorch/TensorRTBlobHeader.cpp",
Expand Down
87 changes: 51 additions & 36 deletions cpp/include/torch_tensorrt/executorch/TensorRTBackend.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,8 @@

#include <executorch/runtime/backend/interface.h>

#include <cstdint>
#include <memory>
#include <mutex>
#include <string>
#include <vector>

namespace torch_tensorrt {
namespace executorch_backend {
Expand All @@ -40,39 +38,6 @@ class TRTLogger : public nvinfer1::ILogger {
void log(Severity severity, const char* msg) noexcept override;
};

struct InputProfileBounds {
nvinfer1::Dims min{};
nvinfer1::Dims max{};
};

struct EngineHandle {
TRTLogger logger;
TRTUniquePtr<nvinfer1::IRuntime> runtime;
TRTUniquePtr<nvinfer1::ICudaEngine> engine;
TRTUniquePtr<nvinfer1::IExecutionContext> exec_ctx;
std::vector<std::string> input_binding_names;
std::vector<std::string> output_binding_names;
std::vector<InputProfileBounds> input_profile_bounds;
std::vector<void*> cached_input_ptrs;
std::vector<size_t> cached_input_sizes;
std::vector<void*> cached_output_ptrs;
std::vector<size_t> cached_output_sizes;
size_t num_inputs = 0;
size_t num_outputs = 0;
int device_id = 0;
bool unified_memory = false;
std::mutex mu;
// Makes the skip-sync fast path safe to reuse: TensorRT forbids reconfiguring or
// destroying an execution context while one of its enqueues is in flight, so when
// execute() returns without an end sync it records this event; the next execute()
// and the destructor wait on it before touching exec_ctx. One event/flag pair
// suffices because a handle runs on a single thread at a time.
cudaEvent_t inflight_event = nullptr;
bool inflight_pending = false;

~EngineHandle();
};

class TensorRTBackend final : public ::executorch::runtime::BackendInterface {
public:
bool is_available() const override;
Expand Down Expand Up @@ -119,5 +84,55 @@ class CudaStreamGuard {
bool prev_set_;
};

// Pass instead of an index to have each delegate pick a profile from the runtime
// input shapes rather than being told one.
inline constexpr int32_t kAutoSelectProfile = -1;

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.

Using -1 inside the same int32_t as the index makes a computed index risky.

int32_t idx = find_profile(name);    // returns -1 when not found
OptimizationProfileGuard guard(idx); // silently becomes auto-select, not an error

Same shape of problem with bool: OptimizationProfileGuard(true) quietly becomes index 1. Python guards against exactly this by using a type that cannot be confused with an index, and rejecting bool outright:

if isinstance(profile, bool) or not isinstance(profile, int):
    raise TypeError(...)

Since this is a public header it gets expensive to change after release. A named constructor would make the intent unmistakable and keep the index space clean:

OptimizationProfileGuard guard = OptimizationProfileGuard::automatic();

Not blocking, just much cheaper to settle now.


// Selects, for the calling thread, which TensorRT optimization profile the
// delegate runs; scope it around Module::forward() / Module::execute(). A
// profile is identified by its index in the export-time profile list, so name
// them to match whatever the exporter declared:
//
// constexpr int32_t kDecodeProfile = 0; // export order: decode first,
// constexpr int32_t kPrefillProfile = 1; // then prefill
//
// executorch::extension::Module module("model.pte");
// {
// OptimizationProfileGuard profile_guard(kPrefillProfile);
// auto result = module.forward(prefill_inputs);
// }
//
// The guard records a request for the current thread and does nothing else: it
// never inspects the Module, Method, or delegate handles, and never calls
// TensorRT. Each TensorRT delegate reads the request inside its own execute(),
// where the engine, its lock, and the execution stream are already available,
// and switches there. Without a guard every delegate runs profile 0.
//
// Composes with CudaStreamGuard, which is orthogonal: the stream guard says
// where the GPU work runs, this one says which profile it runs under. A switch
// is issued on whichever stream execute() selected.
//
// Contract: construct the guard on the thread that calls forward()/execute()
// (ExecuTorch does not support concurrent execution of one Module anyway).
// Nested guards restore the enclosing request on scope exit.
//
// One execution sees one consistent request, but several TensorRT engines in a
// method apply it independently as they run. TensorRT offers no way to undo a
// switch, so if a later engine rejects the request (a pinned index it does not
// have, or no profile matching its inputs) it returns an error with earlier
// engines already switched.
class OptimizationProfileGuard {
public:
// profile_index: an exact profile to pin, or kAutoSelectProfile.
explicit OptimizationProfileGuard(int32_t profile_index);

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.

Could you document the multi-delegate hazard here?

The guard sets one thread-local that every TensorRT delegate in the method reads, so if a .pte has two engines whose profile lists differ, index 1 can mean prefill in one and decode in the other. The comment below says the delegates see one consistent request, which is true of the integer but not of its meaning.

For contrast, the other two runtimes both target something specific:

# Python: targets a module object, can be scoped to one submodule
with optimization_profile(trt_gm, 1): ...

and the C++ runtime keeps active_profile_index per engine instance.

I think the thread-local is the right call here given the ExecuTorch BackendInterface. Its official set_option channel is process-global, which would be worse under concurrency. So this is not a redesign request, just a docs one so a user with two engines is not surprised.

One idea worth a thought, not for this PR: BackendExecutionContext::get_method_name() is available inside execute(), so if prefill and decode were exported as two methods the profile could be chosen from the method name with no ambient state at all.

~OptimizationProfileGuard();
OptimizationProfileGuard(const OptimizationProfileGuard&) = delete;
OptimizationProfileGuard& operator=(const OptimizationProfileGuard&) = delete;

private:
int32_t prev_index_;
bool prev_set_;
};

} // namespace executorch_backend
} // namespace torch_tensorrt
8 changes: 8 additions & 0 deletions cpp/src/torch_tensorrt/executorch/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ add_library(torchtrt_executorch_backend INTERFACE)
add_library(torchtrt::executorch_backend ALIAS torchtrt_executorch_backend)
add_dependencies(torchtrt_executorch_backend executorch_trt_backend)

# The archive is linked by file below rather than by target, so the include path
# does not come along with it. Carry it here: a runner that scopes CudaStreamGuard
# or OptimizationProfileGuard needs the public header.
target_include_directories(torchtrt_executorch_backend
INTERFACE
"${CMAKE_CURRENT_LIST_DIR}/../../../include"
)

if(MSVC)
target_link_libraries(torchtrt_executorch_backend
INTERFACE
Expand Down
59 changes: 59 additions & 0 deletions cpp/src/torch_tensorrt/executorch/EngineHandle.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* 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.
*
* Private state of a TensorRT ExecuTorch delegate.
*
* This header is deliberately not installed. EngineHandle grows fields as the
* backend gains features, so keeping it out of the public API means a new
* header can never disagree about its layout with an already-built backend
* archive.
*/
#pragma once

#include "OptimizationProfileSelection.h"
#include "torch_tensorrt/executorch/TensorRTBackend.h"

#include <NvInfer.h>
#include <cuda_runtime.h>

#include <cstdint>
#include <mutex>
#include <string>
#include <vector>

namespace torch_tensorrt {
namespace executorch_backend {

struct EngineHandle {
TRTLogger logger;
TRTUniquePtr<nvinfer1::IRuntime> runtime;
TRTUniquePtr<nvinfer1::ICudaEngine> engine;
TRTUniquePtr<nvinfer1::IExecutionContext> exec_ctx;
std::vector<std::string> input_binding_names;
std::vector<std::string> output_binding_names;
ProfileTable profiles;
std::vector<void*> cached_input_ptrs;
std::vector<size_t> cached_input_sizes;
std::vector<void*> cached_output_ptrs;
std::vector<size_t> cached_output_sizes;
size_t num_inputs = 0;
size_t num_outputs = 0;
int device_id = 0;
bool unified_memory = false;
std::mutex mu;
// Makes the skip-sync fast path safe to reuse: TensorRT forbids reconfiguring or
// destroying an execution context while one of its enqueues is in flight, so when
// execute() returns without an end sync it records this event; the next execute()
// and the destructor wait on it before touching exec_ctx. One event/flag pair
// suffices because a handle runs on a single thread at a time.
cudaEvent_t inflight_event = nullptr;
bool inflight_pending = false;

~EngineHandle();
};

} // namespace executorch_backend
} // namespace torch_tensorrt
135 changes: 135 additions & 0 deletions cpp/src/torch_tensorrt/executorch/OptimizationProfileSelection.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* 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;
// True when every input dim is pinned to one extent in every profile. Such an

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.

This comment is not true for a multi-profile engine whose profiles are each individually static.

Take profile 0 pinned at seq == 1 and profile 1 pinned at seq == 128. The min.d[d] != max.d[d] check in initialize_input_profiles never trips, so all_inputs_static stays true, yet the engine accepts two shapes, not "one shape only".

Harmless today because the only reader also requires table.size() == 1, where the claim does hold. But as written it invites someone to reuse the flag as "this engine is single-shape", which would be wrong. Could you reword it to describe what it actually computes (no dim varies within any one profile) and note it is only meaningful together with size() == 1?

// engine accepts one shape only, so a request for a profile it does not have
// changes nothing it can do.
bool all_inputs_static = true;
// 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
};

enum class ProfileSelection {
// Created this enum to decouple the profile header from executorch so that we can test it seperately

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.

Typo: "seperately" -> "separately".

kOk,
// A pinned index this engine does not have, and it is not static enough for
// that to be harmless.
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) {
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)) {
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;
}

// An engine that accepts exactly one shape has nothing to switch, so a request
// aimed at its multi-profile siblings in the same method is satisfied by
// profile 0 rather than failing the whole execution. A dynamic engine that
// lacks the index is a real mismatch and is reported.
if (index > 0 && table.size() == 1 && table.all_inputs_static) {

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.

This tolerance treats two engines that are equally unable to honor the pin differently.

Pinning index 1:

single-profile STATIC  engine -> silently runs profile 0, returns kOk
single-profile DYNAMIC engine -> kRequestedProfileUnavailable

Both have exactly one profile, so neither has an index 1. The comment says the point is not to fail an innocent single-profile sibling when the guard was aimed at a multi-profile one, but that applies just as much to the dynamic sibling, which still fails. It also does not help two multi-profile engines with different counts (say 3 and 2, pin index 2), since neither is size() == 1.

The two existing runtimes each pick one rule and stick to it:

out-of-range pin, single-profile engine
Python _TorchTensorRTModule.set_optimization_profile raises ValueError, always
C++ TRTEngine::set_active_profile_with_stream silently no-ops for all single-profile engines

Could we match one of them? If you keep the tolerance, applying it to all single-profile engines regardless of static or dynamic, plus a warning log, would at least make an ineffective pin visible rather than silent.

Narrow case in practice (one .pte mixing a static engine with a multi-profile one, and a nonzero pin), so not blocking, but the asymmetry will be hard to explain later.

selected = 0;
return ProfileSelection::kOk;
}

return ProfileSelection::kRequestedProfileUnavailable;
}

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