Skip to content

Segregate IExecutionProvider optional capabilities into mix-in interfaces - #29087

Open
GopalakrishnanN wants to merge 5 commits into
mainfrom
GopalakrishnanN/SegregateEPInterface
Open

Segregate IExecutionProvider optional capabilities into mix-in interfaces#29087
GopalakrishnanN wants to merge 5 commits into
mainfrom
GopalakrishnanN/SegregateEPInterface

Conversation

@GopalakrishnanN

@GopalakrishnanN GopalakrishnanN commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Introduces narrow, optional-capability mix-in interfaces for execution providers in a new header core/framework/execution_provider_capabilities.h, plus matching Get*Capability() query hooks on IExecutionProvider that return the capability pointer when supported and nullptr otherwise.

Mix-in Grouped methods Query hook
IGraphCaptureCapability IsGraphCaptureEnabled, IsGraphCaptured, ReplayGraph, ReleaseCapturedGraph, GetGraphCaptureNodeAssignmentPolicy GetGraphCaptureCapability()
ITuningCapability GetTuningContext GetTuningCapability()
IDataLayoutCapability GetPreferredLayout, ShouldConvertDataLayoutForOp GetDataLayoutCapability()
ICompileCapability (non-minimal / extended-minimal build) Compile, GetCompiledModelCompatibilityInfo, ValidateCompiledModelCompatibilityInfo GetCompileCapability()

What this PR is — and is not

This is a structural / readability refactor, not a runtime optimization. To set expectations honestly:

  • No memory or performance gain is claimed. Object layout actually gains an extra vtable per implemented mix-in. The cost is negligible, but it is a cost, not a saving — so the justification is API design, not throughput.
  • The value is explicit, typed capability detection. Today an optional capability is a defaulted no-op virtual on the base, so "does this EP support X?" is unanswerable — every caller can call it on every EP and silently get a default. With a query hook, support becomes a first-class question: if (auto* c = ep->GetGraphCaptureCapability()) { ... }.
  • It lowers the cost of authoring/reviewing an EP: a capability is declared in one narrow interface, and a new (or plugin) EP author sees a focused per-capability contract instead of the full IExecutionProvider surface.

Why bundling on the base is worth addressing

IExecutionProvider is the most widely implemented interface in ORT and has accumulated many defaulted virtuals for capabilities only a subset of EPs implement (CUDA-graph capture, TunableOp tuning, subgraph compilation, non-default data layout). Bundling them on the base couples every EP and every caller to the union of all capabilities. Segregating each cluster behind a queryable mix-in is a step toward an IExecutionProvider whose base surface is the contract every EP must satisfy, with optional behavior expressed à la carte.

Const-correctness (addresses review feedback)

The hooks are now split by whether acquiring the capability is a read-only query or part of the mutating path, which removes the earlier const_cast / mutable workarounds:

  • GetDataLayoutCapability() const returns a const IDataLayoutCapability*. Data-layout preference is purely read-only (GetPreferredLayout / ShouldConvertDataLayoutForOp are const), so it must not allow mutating an EP through a const reference. Implementations return this; from a const method with no const_cast.
  • GetGraphCaptureCapability(), GetTuningCapability(), and GetCompileCapability() are non-const and return non-const pointers, because those capabilities expose mutating operations (graph replay/release, tuning state recorded through ITuningContext, subgraph compilation). Acquiring them is honestly part of the mutating execution path, not a const query.

As a result there is no const_cast in any implementation or test, and the test fakes no longer need a mutable call-counter — ITuningCapability::GetTuningContext() is pure virtual, so reaching the concrete result already proves the call routed through the mix-in.

Compatibility

Purely additive and non-breaking:

  • The legacy per-capability virtuals on IExecutionProvider remain in place; existing EPs and callers are unaffected.
  • The hooks default to nullptr; an EP opts in only by implementing a mix-in.
  • Migration is incremental; a cluster's legacy virtuals can be removed from the base only after all of its implementers and callers have moved over.

Follow-up (intentionally not in this PR)

Slimming the base for the cleanest cluster — data layout — is a deliberate, separate change. GetPreferredLayout() / ShouldConvertDataLayoutForOp() are overridden by ~9 EPs (CUDA, QNN, NNAPI, XNNPACK, WebGPU, WebNN, JS, the plugin bridge, and the internal-testing EP) and called from graph_partitioner.cc and layout_transformation.cc. Migrating all implementers and callers and then deleting the base virtuals is the end-to-end "before/after" that demonstrates the coupling reduction — but it touches several EPs that are not built in a CPU-only configuration, so it is kept out of this infrastructure-only PR to keep this change reviewable and independently verifiable.

Tests

onnxruntime/test/framework/execution_provider_capabilities_test.cc exercises the hooks with lightweight CPU-only fake EPs:

  • a capability-less EP returns nullptr from every hook;
  • each capability is independently queryable and routes through its mix-in;
  • capabilities are independent (implementing one does not imply another);
  • multiple capabilities coexist on one EP with correct multiple-inheritance dispatch;
  • the const data-layout hook yields a usable const capability with no casts.

Copilot AI left a comment

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.

Pull request overview

This PR introduces a new set of optional-capability mix-in interfaces for IExecutionProvider and adds typed query hooks on IExecutionProvider so callers can explicitly detect and use supported capabilities (graph capture, tuning, compilation, data-layout) without depending on the full base interface.

Changes:

  • Add execution_provider_capabilities.h defining IGraphCaptureCapability, ITuningCapability, IDataLayoutCapability, and ICompileCapability (build-guarded).
  • Add Get*Capability() query hooks to IExecutionProvider (defaulting to nullptr) to expose supported mix-ins.
  • Add unit tests using lightweight fake EPs to validate the query mechanism and capability independence.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
onnxruntime/test/framework/execution_provider_capabilities_test.cc Adds unit tests exercising the new capability query hooks with fake EP implementations.
include/onnxruntime/core/framework/execution_provider.h Adds forward declarations and the new Get*Capability() query hooks on IExecutionProvider.
include/onnxruntime/core/framework/execution_provider_capabilities.h Introduces the segregated optional-capability mix-in interface definitions.

Comment on lines +487 to +491
/** Return this EP's TunableOp tuning capability, or nullptr if unsupported. */
virtual ITuningCapability* GetTuningCapability() const noexcept { return nullptr; }

/** Return this EP's data-layout preference capability, or nullptr if unsupported. */
virtual IDataLayoutCapability* GetDataLayoutCapability() const noexcept { return nullptr; }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d52ecb0: GetDataLayoutCapability() now returns const IDataLayoutCapability*, and the tuning / graph-capture / compile hooks are non-const because those capabilities are on the mutating path. No const_cast remains.

Comment on lines +63 to +65
ITuningCapability* GetTuningCapability() const noexcept override {
return const_cast<TuningEp*>(this);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d52ecb0 - the override returns this with no const_cast.

Comment on lines +77 to +79
IDataLayoutCapability* GetDataLayoutCapability() const noexcept override {
return const_cast<DataLayoutEp*>(this);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d52ecb0 - GetDataLayoutCapability() returns const IDataLayoutCapability* and the override returns this directly.

Comment thread onnxruntime/test/framework/execution_provider_capabilities_test.cc Outdated

// An EP that supports graph capture, implemented via the segregated mix-in and
// surfaced through the GetGraphCaptureCapability() query hook.
class GraphCaptureEp : public IExecutionProvider, public IGraphCaptureCapability {

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.

class GraphCaptureEp : public IExecutionProvider, public IGraphCaptureCapability

The goal of this PR is not clearly articulated. What does it gain, memory, performance?

It does make objects binary layout more complicated as this object now has more than one virtual table and casting now has to be done very carefully.

@GopalakrishnanN GopalakrishnanN Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair pushback, and I have rewritten the PR description to state the intent explicitly. Summary:

  • This is a structural / interface-segregation refactor, NOT a memory or performance change. Object layout does gain a vtable per implemented mix-in, so it is a (negligible) cost, not a saving. The justification is API design.
  • The gain is explicit, typed capability detection. Today an optional capability is a defaulted no-op virtual on the base, so which EP supports X is unanswerable and every caller can call it on every EP. A query hook makes it first-class: if (auto* c = ep->GetGraphCaptureCapability()) use c.
  • It lowers EP authoring/review cost: a capability is one narrow interface, and a new/plugin EP author sees a focused per-capability contract instead of the full IExecutionProvider surface.

The multiple-vtable / careful-casting concern is real. The change is deliberately additive: the legacy virtuals stay, the hooks default to nullptr, and the only cast is confined to the opting-in EP returning this. If the ISP gain is not judged worth the extra vtable, I am happy to park this.

virtual IGraphCaptureCapability* GetGraphCaptureCapability() noexcept { return nullptr; }

/** Return this EP's TunableOp tuning capability, or nullptr if unsupported. */
virtual ITuningCapability* GetTuningCapability() const noexcept { return nullptr; }

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.

Now, instead of those default methods we need to deal with default methods that cast to the correct interface by dispatching it to the correct virtual table (it now has more than one), however, this will not compile cleanly as if the method is const.
Now if you return this it has a const qualifier which will have to be cast away which we do not want but the const_cast now is dictated by the interface.

@GopalakrishnanN GopalakrishnanN Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d52ecb0. The hooks are now split by whether acquiring the capability is read-only or on the mutating path:

  • GetDataLayoutCapability() const returns const IDataLayoutCapability* (pure query).
  • GetGraphCaptureCapability(), GetTuningCapability(), GetCompileCapability() are non-const and return non-const pointers (they expose mutating operations).
    Implementers now return this with no const_cast.


// ITuningCapability. Returns nullptr (no real tuning state); a call counter
// proves the call is routed to the concrete implementation through the mix-in.
ITuningContext* GetTuningContext() const override {

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.

ITuningContext*

So the object is const, but its state can be modified?

@GopalakrishnanN GopalakrishnanN Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed - that was the signal that tuning does not belong behind a const query. GetTuningCapability() is now non-const (d52ecb0), so acquiring the tuning capability is honestly on the mutating path rather than reachable through a const EP reference.

int tuning_query_count() const { return tuning_query_count_; }

private:
mutable int tuning_query_count_ = 0;

@yuslepukhin yuslepukhin Jun 18, 2026

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.

mutable int

Why mutable?
This is the consequence of improper constness which will manifest itself eventually in multi-threading bugs.

@GopalakrishnanN GopalakrishnanN Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in d52ecb0. ITuningCapability::GetTuningContext() is pure virtual, so reaching the concrete result already proves the call routed through the mix-in - the mutable call-counter was unnecessary and is gone.

Gopalakrishnan Nallasamy and others added 4 commits July 6, 2026 15:03
…aces

Introduce IGraphCaptureCapability, ITuningCapability, and ICompileCapability mix-in interfaces that cluster the optional graph-capture, tuning, and compilation methods currently spread across IExecutionProvider as defaulted virtuals.

Add non-breaking capability-query hooks (GetGraphCaptureCapability/GetTuningCapability/GetCompileCapability) that default to nullptr so callers can discover support by capability instead of relying on the large defaulted-virtual surface. Legacy virtuals are retained for compatibility; this is an additive first step toward migrating EPs off them.

Add a CPU-only unit test proving unsupported capabilities return null, an exposed capability is queryable and usable, and capabilities are independent.
…erface

Add IDataLayoutCapability grouping the coupled GetPreferredLayout() and ShouldConvertDataLayoutForOp() virtuals, with a GetDataLayoutCapability() query hook on IExecutionProvider (defaults to nullptr). Additive and non-breaking; only NHWC-preferring EPs implement it. Includes fake-EP unit tests.
…dispatch

Add positive 'queryable and usable' tests for ITuningCapability (call-counted routing) and ICompileCapability (Compile + ValidateCompiledModelCompatibilityInfo), a multi-capability EP test verifying correct mix-in dispatch under multiple inheritance, and compile-null independence checks. 7 tests pass.
Address review feedback on the segregated capability hooks:

- GetDataLayoutCapability() now returns a const IDataLayoutCapability* (data-layout preference is a read-only query, so it must not allow mutating the EP through a const reference).

- GetTuningCapability() is now non-const, signalling that acquiring the tuning capability is part of the mutating execution path (tuning records state through ITuningContext).

- Implementations return 'this' directly; removes the const_cast in every mix-in override and the mutable call-counter in the tuning test.
@GopalakrishnanN
GopalakrishnanN force-pushed the GopalakrishnanN/SegregateEPInterface branch from 7da74ea to d52ecb0 Compare July 6, 2026 22:20
@GopalakrishnanN

Copy link
Copy Markdown
Contributor Author

Review summary

What this is: a purely additive interface-segregation refactor (478+/0−, 3 files) — four narrow mix-ins (IGraphCaptureCapability, ITuningCapability, IDataLayoutCapability, ICompileCapability), matching Get*Capability() query hooks on IExecutionProvider (default nullptr), and a CPU-only gtest. Legacy virtuals retained; no EP or caller migrated.

Verdict: no blocking issues — this is safe to land as an inert, spec-sound infrastructure step. I verified the linchpin directly: all 11 mix-in signatures mirror the legacy IExecutionProvider virtuals exactly (name / params / cv- / ref-qualifiers, including ReplayGraph(int, bool = true) and the nested FusedNodeAndGraph / GraphViewer types), so C++'s final-overrider rule ([class.virtual]/2) makes "one definition satisfies both the legacy virtual and the mix-in pure virtual" provably correct — one derived override becomes the final overrider for both vtable slots, no ambiguity. return this up-conversion under multiple inheritance is correct, and commit 4's const rework legitimately removes the const_casts (not a workaround). I also confirmed the new header flows into packaging automatically (GLOB_RECURSE + directory install() on core/framework/*.h) and the test auto-compiles in non-minimal builds (CONFIGURE_DEPENDS glob).

The items below are about whether the PR achieves its stated goal and about future-migration safety, not about the code that lands.

Findings

ID Severity Finding Disposition
M1 Major Hooks default to nullptr independently of mix-in inheritance → capability implementation and discovery can silently disagree Confirmed with a concrete example (below)
M2 Major No compile-time guard against legacy↔mix-in signature drift Confirmed — matches today; a future mismatch could silently fork a vtable slot
M3 Major (design) / non-blocking here Non-const hooks (tuning, graph-capture, compile) are unusable on a const IExecutionProvider&, unlike the const legacy virtuals they will replace Confirmed structural; inert while legacy is retained
M4 Major (doc) Narrow mix-in docs drop safety-critical contracts present on the legacy virtuals Confirmed
m5 Minor TuningCapabilityIsQueryableAndUsable is weaker after the call-counter was removed Test is valid (routing proof holds); strengthening is optional
m6 Minor Legacy C++ shared-provider bridge has no version handshake ABI-inert now; the versioned C OrtEp boundary is insulated
m7 Minor Stale motivation comment; one-directional cross-refs; test-coverage gaps Confirmed
n8 Nit Unused #include <memory>; doc-comment style divergence; thin ITuningCapability doc; keep ReplayGraph defaults equal Confirmed

The four Majors

M1 — Discovery/implementation can silently disagree (the central risk). Because the base hooks default to nullptr independently of mix-in inheritance, an EP can implement a capability yet be reported as not supporting it through the new hook. Concretely, PluginExecutionProvider (core/session/plugin_ep/ep_plugin_provider_interfaces.h) already implements Compile, GetPreferredLayout, ShouldConvertDataLayoutForOp, GetCompiledModelCompatibilityInfo, IsGraphCaptureEnabled, and ReplayGraph — but overrides none of the new hooks, so GetCompileCapability() / GetDataLayoutCapability() / GetGraphCaptureCapability() all return nullptr on it despite genuine support. It fails closed (safe), but the discovery mechanism is unreliable until every capable EP is migrated. Consider coupling inheritance to the hook (e.g. a CRTP/adapter base that supplies a final hook so the two cannot diverge), and landing one production EP + its caller as a vertical slice with legacy/mix-in parity tests.

M2 — No drift guard. The "one override, two bases" contract holds only while the signatures stay identical by hand. The dangerous drift case is subtle: if a future edit leaves the non-pure legacy virtual un-overridden (e.g. a const typo), you get a silent second vtable slot where the legacy and capability call paths reach different bodies; the pure-mix-in side would instead hard-error, which is safe. A static_assert(std::is_same_v<...>) on member-function-pointer types for each mirrored method makes the invariant self-enforcing for cheap.

M3 — Const asymmetry. GetTuningCapability() / GetGraphCaptureCapability() / GetCompileCapability() are non-const, but the legacy virtuals they intend to replace are const and callable on a const IExecutionProvider& (there are real const tuning paths, e.g. cuda_kernel.h, matmul.cc, gemm.cc). Tuning is the clearest: ITuningCapability's only method GetTuningContext() is const, so the hook could mirror the const data-layout pattern (const ITuningCapability* GetTuningCapability() const); the non-const choice is a policy signal the interface doesn't enforce, and the doc comment presents it as a const-correctness requirement. Not a problem while the legacy virtuals remain, but it's a decision worth settling before they're removed — otherwise const callers get pushed toward const_cast.

M4 — Docs drop safety contracts. Legacy Compile warns "do NOT cache the GraphViewer in FusedNodeAndGraph.filtered_graph … only valid for the duration of the call"; the mix-in ICompileCapability::Compile omits it, and ReleaseCapturedGraph omits its synchronize-against-concurrent-Run expectation. Since the stated value is that an author reads the narrow per-capability contract, that contract needs to carry the lifetime/concurrency caveats verbatim — otherwise a mix-in implementer could cache a temporary viewer and hit a use-after-free.

Minor / nits worth folding in

  • m5: the tuning test's "reaching a null context proves routing" is logically valid (the mix-in method is pure, so any defined return proves dispatch), but it's weaker than the removed counter. A mutable counter needs no const_cast, so it could be restored, or add an identity check (tc == static_cast<ITuningCapability*>(&ep)). Optional.
  • m7: the header's top "Motivation" comment lists three of the four clusters (data-layout was added later and never folded in); the legacy virtuals have no forward pointer to their segregated replacements (only the reverse), so an author migrating off a legacy virtual won't discover the mix-in. Test coverage omits an implemented-mix-in-but-missing-hook case, a const-access case, and legacy/mix-in parity.
  • m6: appending virtuals is ABI-inert here, but before any host caller starts calling a hook, the legacy shared-provider bridge needs version-locking so a new core can't call a hook on an older provider's vtable.
  • n8: <memory> is unused in the test; the two headers use different doc-comment styles; a one-line note that ReplayGraph's = true defaults must stay equal across both declarations would prevent a future silent desync.

Verification notes / scope

  • I did not build or run this (no full ORT build in scope for an additive header). The "7 tests pass" claim is plausible — the test auto-globs into non-minimal builds and the signatures check out — but it was not executed as part of this review.
  • Not independently verified: a concrete const-EP caller of the compile compat-info methods (the const concern is confirmed for tuning); real GPU/NPU EP behavior; and binary ABI compatibility (reasoned from the bridge source, not a link test).

Overall: merge-ready as inert infrastructure. To make it deliver its intended value safely, the cheap nets (M2 static_asserts, M4 doc caveats, the M3 tuning-const decision, and the nits) are worth folding in now, with M1's vertical slice + M6 version-locking tracked as the follow-up that actually removes the base virtuals.

…ift guard

- Restore legacy doc caveats the mix-ins had dropped: ICompileCapability::Compile
  now warns not to cache the GraphViewer beyond the call, and
  IGraphCaptureCapability::ReleaseCapturedGraph documents the concurrent-Run
  thread-safety contract.
- Make GetTuningCapability() a const, read-only query returning
  const ITuningCapability*, matching the const legacy GetTuningContext() and the
  const data-layout hook. Update the TuningEp fake and its test accordingly.
- Add compile-time signature-drift static_asserts (in the unit test) pinning each
  mix-in method to its legacy IExecutionProvider counterpart, so a future change
  that would silently split the shared vtable slot fails the build instead.
- Strengthen the tuning test with a subobject-identity assertion and note that
  ReplayGraph's sync default must stay equal across both declarations.
- Fix the stale motivation comment to include the data-layout cluster, add
  legacy->mix-in cross-references, and drop the now-unused <memory> include.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@GopalakrishnanN

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback in 945af36. Summary of what changed:

  • Signature-drift guard: added a small MemberSignature trait plus static_asserts in the unit test that pin every capability mix-in method to its legacy IExecutionProvider counterpart. This catches the dangerous silent case where a drifted non-pure legacy virtual would stop being overridden and split into a second vtable slot (the pure mix-in side already hard-errors).
  • Const tuning hook: GetTuningCapability() is now a const, read-only query returning const ITuningCapability*, matching the const legacy GetTuningContext() and the const data-layout hook. Updated the TuningEp fake and its test to match. > Note: this reverses the earlier deliberate choice to keep the hook non-const — happy to revert that one line if you'd rather keep it non-const.
  • Restored dropped doc caveats: ICompileCapability::Compile again warns not to cache the GraphViewer beyond the call, and IGraphCaptureCapability::ReleaseCapturedGraph documents the concurrent-Run thread-safety contract.
  • Stronger tuning test: added a subobject-identity assertion so the test actually proves correct pointer adjustment under multiple inheritance, not just a null result.
  • Docs/nits: fixed the stale "Motivation" comment to include the data-layout cluster, added legacy->mix-in cross-references, noted that ReplayGraph's sync default must stay equal across both declarations, and dropped the unused <memory> include.

Deferred as follow-ups since this PR is intentionally infra-only: wiring a production EP through a mix-in as a vertical slice (+ parity tests), and version-locking the shared-provider bridge.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants