Skip to content

Free executor intermediates once their last consumer has run - #1498

Merged
justinchuby merged 2 commits into
mainfrom
fix/executor-release-dead-values
Aug 19, 2026
Merged

justinchuby merged 2 commits into
mainfrom
fix/executor-release-dead-values

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

The executor already computed last_use for every value, but its only consumer was inplace_dead_inputs, whose conditions (output must match an input in both dtype and shape) in practice cover elementwise chains and nothing else. Any shape-changing operator -- MatMul, Reshape, Attention -- allocated a buffer that then sat resident until the run ended, so a graph is charged for its peak node count rather than its peak live set.

The vision encoder made this concrete: all 2545 node outputs held at once, ~23 GB at 448px, and a 960x686 image OOMing an 80 GB card outright.

before after
448px 67869 MiB 55945 MiB
672px OOM passes
960px (full res) OOM 55797 MiB, passes

Two things that are easy to get wrong

DeviceBuffer has no Drop. It is a bare {device, size, align, ptr, owner} handle. Removing it from the value map looks like a release and is actually a leak -- the first version of this change did exactly that and reported 7075 releases while device memory did not move at all. The buffer must go back through ep.deallocate.

Why this is per-session opt-in, default off. It is unsafe under device graph capture: capture bakes device pointers into the recorded graph, so freeing a buffer lets a later allocation land elsewhere and replay reads the wrong memory. The decoder fails immediately with device capture validation violation (flags=0xc0). Restricting the pass to the eager path is not sufficient, because one session runs eager before it captures. So components that never capture (the pipeline components, run once per request) opt in; the decoder does not.

Dead values still backing a live view are retired from the view table first and otherwise held in pending_dead and retried after each node. Measured worth on its own is near zero (55945 -> 55909 MiB; views are not the bottleneck), but it keeps the pass from silently depending on view lifetimes.

The release guard list deliberately mirrors the inplace_input guards in dispatch.rs -- initializers, graph inputs, external in/outputs, pinned, shared buffers, sequence elements, borrowed. Those two lists should be kept in step.

Validation

  • onnx-runtime-session: 186 + 23 + others, 0 failed
  • onnx-genai-engine --features native-backend,cuda: 575 + others, 0 failed
  • End-to-end on a 30B INT4 pipeline: full-resolution image now describes correctly; decode throughput unchanged.

CI runners are still down, so this was validated locally.

justinchuby and others added 2 commits August 19, 2026 21:22
A native CUDA pipeline run that failed anywhere below the driver surfaced
as "native CUDA decoder forward pass failed" and nothing else. anyhow's
Display prints only the outermost context, so every layer of diagnosis
the lower crates had carefully attached was discarded at the boundary
where a human would read it. Switching the two driver formats to the
alternate `{err:#}` selector prints the whole chain.

With the chain visible, four real dtype gaps became legible instead of
being guessed at:

  * Reduce* on CPU accepted Int64 only for Sum. A graph reducing Int32,
    or taking Max/Min/Prod over integers, hit the dtype guard rather
    than the integer path that was already sitting there.
  * SkipLayerNormalization on CPU required its optional `mean` and
    `inv_std_var` outputs to carry the input dtype. ORT's schema types
    them `float`, but exporters routinely emit them in the activation
    dtype instead, and the write already narrows to whichever was
    declared -- so both should be accepted. The bf16 skip-RMSNorm path
    documents the same allowance.
  * SkipLayerNormalization on CUDA made the mirror-image assumption,
    deriving the stat pointers' dtype from the input rather than from
    the declared output. Threading the resolved stat dtype through to
    the kernel keeps a float32 stat pair on a bf16 input from being
    written as bf16.
  * Range on CPU had no Int32 branch.

Each gap gets tests at the boundary that was previously unreachable.

Note for reviewers: `onnx-runtime-ep-cuda --lib` has five failures on
this machine (two in graph::tests, three in matmul_nbits fp16 tests).
They reproduce identically on an unmodified main, so they are not from
this change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0190e2eb-abe4-451f-b36d-44a035a99b7e
The executor already computed `last_use` for every value, but the only
consumer was `inplace_dead_inputs`, whose conditions are narrow enough
(output must match an input in both dtype and shape) that in practice it
covers elementwise chains and nothing else. Any operator that changes
shape -- MatMul, Reshape, Attention -- allocated a buffer that then sat
resident until the run ended. A graph is therefore charged for its peak
node count rather than its peak live set.

The vision encoder made the difference concrete. Its 2545 node outputs
were all held simultaneously, costing roughly 23 GB at 448px and making
full-resolution images unservable: a 960x686 image OOMed an 80 GB card.
With dead values freed the same run peaks at 55.8 GB and completes.

Four details worth calling out, because each is easy to get wrong:

`DeviceBuffer` is a bare handle with no `Drop`. Dropping it from the
value map looks like a release and is actually a leak -- the first
version of this change did exactly that and reported 7075 releases while
device memory did not move at all. The buffer has to go back through
`ep.deallocate`.

`buffer_shapes` has to be cleared alongside it. `ensure_buffer` treats a
surviving shape entry as proof the allocation is still there and skips
sizing it, so leaving one behind hands the next run a value it believes
is backed and is not. Every other path that takes a buffer out of the
map does the same thing for the same reason.

A memoized loop-invariant `If` skips its branch on later runs and serves
its outputs straight from the buffers an earlier run left resident, and
that memo survives an eager run. Freeing one of those outputs turns the
next skip into a missing-buffer error on the *second* request rather
than the first. `try_move_host_output` already declines for exactly this
reason; this pass is the second buffer-stealing path and now carries the
same guard.

The release is per-session and off by default because it is not safe
under device graph capture. Capture bakes device pointers into the
recorded graph, so freeing a buffer lets a later allocation land at a
different address and replay then reads the wrong memory; the decoder
fails immediately with a capture validation violation. Restricting this
to the eager path is not sufficient either, since one session runs eager
before it captures. Components that never capture -- the pipeline
components, which run once per request -- opt in; the decoder does not.

Aliased values (`pinned`) are never released, even after every alias has
died. That is deliberate: `pinned` is a monotone record of "was ever an
alias source", and releasing on the exact liveness test instead would be
wrong for the decode memo, which restores a step's view table on the
next run and expects the source buffer to still be resident. An earlier
draft did try to reclaim view sources once their views died; it turned
out to be unreachable (every view source is pinned) and, when measured,
worth 36 MiB out of ~12 GB. Not worth a cross-run aliasing analysis.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0190e2eb-abe4-451f-b36d-44a035a99b7e
@justinchuby
justinchuby force-pushed the fix/executor-release-dead-values branch from f1b3797 to 07d947f Compare August 19, 2026 21:31
@justinchuby
justinchuby merged commit 1f6bb58 into main Aug 19, 2026
6 checks passed
@justinchuby
justinchuby deleted the fix/executor-release-dead-values branch August 19, 2026 21:32
justinchuby added a commit that referenced this pull request Aug 19, 2026
…ol (#1499)

Two independent gaps in how pipeline components were brought up.

**The device was never resolved.** A component was loaded without
consulting the session options the rest of the engine had already used
to pick a device, so `--device cuda:3` put the decoder on GPU 3 and the
vision encoder wherever the default landed. `resolved_native_device` now
reads the same session options, and routing warns when a component that
asked for CUDA fell back to CPU -- previously that was silent, and the
only symptom was running two orders of magnitude slower.

**The device pool was never accounted.** A component session builds its
own execution provider, which sizes a standing pool for itself. Nothing
told the memory governor, so the decoder's admission control and the
component each measured the same free VRAM and each concluded it could
take ~90% of it. Adopting the governor under a new
`Holder::PipelineComponentPool` makes that pool a claim the rest of the
engine can see.

Adoption failure is logged rather than fatal: a provider holding no
standing pool legitimately reports zero, and a governor that refuses the
claim leaves the component exactly as unaccounted as it was before.
Worth saying out loud; not worth failing a load over.

Builds on #1498 (which added `set_release_dead_values` on the same
`load` path).

## Validation
`onnx-genai-engine --features native-backend,cuda`: 575 + others, 0
failed. End-to-end on a 30B INT4 pipeline with `--device cuda:3`,
confirmed via `nvidia-smi` that both decoder and vision encoder land on
GPU 3.

CI runners are still down, so this was validated locally.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0190e2eb-abe4-451f-b36d-44a035a99b7e
@codecov

codecov Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.35498% with 50 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.25%. Comparing base (4a9f4ec) to head (07d947f).
⚠️ Report is 79 commits behind head on main.

Files with missing lines Patch % Lines
...ates/onnx-runtime-session/src/executor/dispatch.rs 18.75% 26 Missing ⚠️
crates/onnx-runtime-ep-cpu/src/kernels/sequence.rs 75.67% 3 Missing and 6 partials ⚠️
...ates/onnx-runtime-ep-cpu/src/kernels/reduce_ops.rs 90.90% 8 Missing ⚠️
.../onnx-runtime-session/src/executor/control_flow.rs 66.66% 2 Missing and 1 partial ⚠️
crates/onnx-runtime-session/src/lib.rs 0.00% 3 Missing ⚠️
...s/onnx-runtime-ep-cpu/src/kernels/contrib_fused.rs 98.38% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             main    #1498       +/-   ##
===========================================
- Coverage   82.10%   80.25%    -1.86%     
===========================================
  Files          12      377      +365     
  Lines        5471   166504   +161033     
  Branches     5471   166504   +161033     
===========================================
+ Hits         4492   133622   +129130     
- Misses        780    28041    +27261     
- Partials      199     4841     +4642     
Flag Coverage Δ
cli-ort-linux 82.60% <ø> (?)
cli-ort-windows 82.19% <ø> (+0.09%) ⬆️
offline 80.17% <78.35%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
crates/onnx-runtime-session/src/executor/mod.rs 56.11% <ø> (ø)
crates/onnx-runtime-session/src/executor/state.rs 83.33% <ø> (ø)
...s/onnx-runtime-ep-cpu/src/kernels/contrib_fused.rs 89.57% <98.38%> (ø)
.../onnx-runtime-session/src/executor/control_flow.rs 64.45% <66.66%> (ø)
crates/onnx-runtime-session/src/lib.rs 61.92% <0.00%> (ø)
...ates/onnx-runtime-ep-cpu/src/kernels/reduce_ops.rs 93.87% <90.90%> (ø)
crates/onnx-runtime-ep-cpu/src/kernels/sequence.rs 77.84% <75.67%> (ø)
...ates/onnx-runtime-session/src/executor/dispatch.rs 68.10% <18.75%> (ø)

... and 360 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

justinchuby added a commit that referenced this pull request Aug 20, 2026
Both are Tier 2 losses: git produced them without a conflict marker, so
nothing in the resolution flagged them and no test went red.

1. matmul_nbits.rs was 257 lines short of main, dropping the whole
   decode_gemv_achieved_bandwidth_by_projection_shape probe from #1574.
   The stack never touched this file -- its blob is identical to the fork
   point -- and the raw auto-merge took main's blob correctly. It was
   damaged afterwards, while reverting what looked like rustfmt drift.
   Restored to main's blob; fork-point/main/auto-merge/HEAD now agree.

2. load_with_cuda_memory never called set_release_dead_values(true).
   main added that in #1498 because holding all 2545 vision-encoder node
   outputs at once cost ~23 GB. The call survived in `load`, its executor
   implementation survived, and its tests survived -- but both CUDA
   component call sites (pipeline/mod.rs:538, routing.rs:337) take
   load_with_cuda_memory, so the fix had no activation left on the path
   that actually runs. Reference counting cannot see this shape: the
   count never dropped, only the reachable path changed.

   Also carried over main's execution-provider fallback warning, and
   recorded why adopt_memory_governor is deliberately absent here.

adopt_memory_governor is intentionally not called: the provider is
already governed, so charging the component holder would double-count.

Verified: cargo check --workspace --all-targets, plus the
cuda,native-backend and cuda,gpu-tests feature sets, all clean. Tests
over the 7 memory crates: 1105 passed / 2 failed / 88 ignored, identical
to before these changes; both failures are the known macOS statvfs FFI
bug in platform_capacity.rs. Neither fix is exercised here -- there is no
CUDA on this host and load_with_cuda_memory is cfg-gated.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c80f8522-983c-47f7-8241-2155a823aabe
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.

1 participant