onnx_runtime_memory/lib.rs
1//! # `onnx-runtime-memory`
2//!
3//! Liveness-based **activation memory planning** for the ORT 2.0 runtime.
4//!
5//! ## Why this crate exists
6//!
7//! The user's north-star goal is to *run any-size model even when VRAM/RAM is
8//! insufficient, as long as the whole system has enough storage*. Zero-copy
9//! weight streaming already removes weight copies from the budget. The next big
10//! lever is **activation memory**.
11//!
12//! Today the executor allocates **one buffer per graph value for its whole
13//! lifetime**, so peak activation memory is `SUM(every intermediate tensor)`
14//! rather than the *concurrent* peak. Most intermediates are dead long before
15//! the run ends. A liveness-based planner shares one physical buffer among
16//! values whose lifetimes do not overlap, cutting peak activation memory from
17//! `O(N nodes)` to `O(max concurrent live set)` — often a multiple-× reduction
18//! and the key to fitting big models.
19//!
20//! This crate is the **pure, deterministic planning algorithm**, deliberately
21//! decoupled from the risky executor surgery (which lands later). It depends
22//! only on [`onnx_runtime_ir`], contains no `unsafe`, and is free of PyO3 / EP /
23//! session dependencies so it is trivially testable in isolation.
24//!
25//! ## What it computes
26//!
27//! Given a [`Graph`](onnx_runtime_ir::Graph), a [`ViewMap`] of zero-copy view
28//! aliases, and a **size oracle** (`Fn(ValueId) -> Option<usize>`), the planner
29//! produces an [`ActivationPlan`]:
30//!
31//! * `assignments: ValueId -> SlotId` — which reusable slot backs each value.
32//! * `slots: Vec<SlotInfo>` — each slot's byte capacity.
33//! * `peak_bytes` — the arena size the executor must allocate (sum of slot
34//! capacities) — the shared, concurrent-peak footprint.
35//! * `naive_bytes` + `savings_ratio` — the one-buffer-per-value baseline and the
36//! proven reduction.
37//!
38//! The three-step algorithm is: **(1)** compute each buffer owner's live
39//! interval `[def, use_end]` in topological order, folding view consumers and
40//! graph-output liveness into the root owner; **(2)** size every owner via the
41//! oracle, returning [`PlanStatus::Deferred`] if any size is symbolic; **(3)**
42//! greedily walk nodes, allocating each output's slot (best-fit reuse of a
43//! retired slot, else a new one) and retiring inputs *after* the node so a
44//! node's own inputs and any graph output are never clobbered.
45//!
46//! ## Static vs. dynamic shapes
47//!
48//! The same algorithm serves **build-time** and **run-time** planning through
49//! the size oracle. [`plan_activations_static`] plans from fully-static shapes;
50//! any symbolic-shaped activation makes the whole plan [`PlanStatus::Deferred`]
51//! so the executor can re-plan once shapes resolve for a run. A run-time caller
52//! passes its own oracle backed by resolved shapes.
53//!
54//! ## Zero-copy view aliasing
55//!
56//! The executor treats layout/movement-op outputs (`Slice`, `Reshape`,
57//! `Transpose`, …) as zero-copy views that own no buffer and alias a *source*
58//! buffer, pinning the source so it outlives every alias. The planner mirrors
59//! this: a view gets **no slot**; instead it extends its source's live interval
60//! (transitively — a view of a view folds to the root buffer owner). Correct
61//! view-liveness folding is exactly what stops a reused buffer from clobbering a
62//! still-live alias. The caller supplies the `view -> source` edges via
63//! [`ViewMap`]; op names are never hardcoded here.
64//!
65//! ## Intended executor integration (deferred follow-up — out of scope now)
66//!
67//! The follow-up PR wires this into `onnx-runtime-session`'s executor:
68//!
69//! 1. **Build the [`ViewMap`]** from the executor's existing view plan (the
70//! `views`/`pinned` machinery in `executor.rs`), mapping each view value to
71//! its source (root) buffer owner.
72//! 2. **Call the planner** with a size oracle backed by the run's *resolved*
73//! shapes (build-time static shapes where available; a per-run oracle
74//! otherwise). On [`PlanStatus::Deferred`], re-plan once shapes resolve.
75//! 3. **Allocate `peak_bytes`** as one arena (or `num_slots` `DeviceBuffer`s),
76//! then map each [`SlotId`] to an offset/allocation.
77//! 4. **Hand each value a `TensorMut` window** into its assigned slot instead of
78//! the current per-value `buffers: HashMap<ValueId, DeviceBuffer>`.
79//!
80//! Two concerns are explicitly **out of scope** for both this crate and the
81//! current planning contract, and must be handled by the integration PR:
82//!
83//! * **In-place ops** — an op that may safely overwrite an input (e.g. an
84//! elementwise unary) can share its input's slot for its output. Detecting
85//! this requires per-op semantics the planner does not model; until then the
86//! planner conservatively gives each output its own (reused) slot.
87//! * **Fragmentation** — `peak_bytes` is the sum of slot capacities. Packing
88//! slots into a single arena with alignment/offset assignment (and any
89//! resulting internal fragmentation) is the executor's responsibility.
90//!
91//! ## Correctness invariants (enforced by [`validate`])
92//!
93//! * No two values with overlapping live intervals share a slot.
94//! * Every graph output has a slot that is never reused after its def.
95//! * A pinned source outlives every view aliasing it (fold correctness).
96
97#![forbid(unsafe_code)]
98
99mod error;
100mod liveness;
101mod options;
102mod oracle;
103mod plan;
104mod validate;
105mod view_map;
106
107pub use error::{PlanError, ValidateError};
108pub use liveness::{compute_liveness, Interval, Liveness};
109pub use options::PlanOptions;
110pub use oracle::{static_size, static_size_oracle};
111pub use plan::{
112 plan_activations, plan_activations_static, ActivationPlan, PlanStatus, SlotId, SlotInfo,
113};
114pub use validate::{validate, validate_static};
115pub use view_map::ViewMap;