atlas_core/
scope.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Model teardown — ordered, fallible release of state that owns device memory.
4//!
5//! # Why there are no caches here
6//!
7//! An earlier version of this module offered generation-checked statics
8//! (`Scoped`, `ScopedFlag`, `ScopedMap`) as a safe home for state derived from
9//! the loaded model. They are gone, and the reasoning is worth keeping:
10//!
11//! **A checked static is still a static.** It is a dependency the signature
12//! does not declare, it cannot be varied in a test without mutating the
13//! process, and a site that forgets it fails at *runtime* — if it is ever read
14//! at all. Propagating the value instead makes the same question a
15//! *compile-time* one: add a field, and every construction site that forgot it
16//! stops building.
17//!
18//! In practice a carrier almost always already exists and the static was
19//! bypassing it — `ForwardContext` reaches every dispatch site in the model,
20//! `&dyn Model` reaches the scheduler, and a backend owns its own device
21//! handles. Where no carrier exists, the answer is to add one, not to reach for
22//! a guarded global.
23//!
24//! # The statics that legitimately remain
25//!
26//! What stays is state derived from the *process* or the *device* rather than
27//! from the checkpoint. Every such site carries a comment arguing its case;
28//! these are the categories, so a reader can tell at a glance whether a static
29//! they have found is accounted for or is a straggler.
30//!
31//! 1. **The CUDA host** ([`crate::cuda_host`]). One primary context per device
32//!    per process, enforced by the driver; outliving every model is the entire
33//!    point, since not recreating it is what in-process swapping buys. The full
34//!    argument is on the declaration.
35//!
36//! 2. **Log-once latches** (`std::sync::Once`, `*_LOGGED`, `*_WARNED`). These
37//!    hold no value — a `Once` is a latch, not data — so they cannot produce a
38//!    wrong answer. Their only cross-model effect is suppressing a duplicate
39//!    log line for a route the previous model also took. Threading a logging
40//!    concern through every kernel-dispatch signature to restore one INFO line
41//!    is not a trade worth making.
42//!
43//! 3. **One-shot diagnostic latches** (`*_DUMP_DONE`, `*_DIAG_DONE`). Same
44//!    shape, and live only when an `ATLAS_DUMP_*` variable is set: they gate a
45//!    debug capture whose intent is "one sample per process", not "one per
46//!    model". A stale latch suppresses a duplicate dump; it cannot corrupt one.
47//!
48//! 4. **Compile-time tables and descriptors.** Immutable data with no runtime
49//!    state — lookup tables are `const` where the language allows it, and the
50//!    plugin/benchmark descriptors are `static` only because they are reached
51//!    as `&'static` and need a stable address.
52//!
53//! 5. **Process lifecycle** (the TUI's terminal guard, shutdown flags, log
54//!    ring). These describe the *process's* relationship to its terminal and
55//!    its exit, which no model has any bearing on.
56//!
57//! Anything not in one of those five is a straggler and should be scoped.
58//!
59//! # What this module does provide
60//!
61//! [`ModelResource`] and [`Teardown`] give an ordered, fallible release path,
62//! which `Drop` cannot: it is neither ordered across independent
63//! values nor able to report a failure, and on GB10 unified memory frees must
64//! happen at a quiescent point in a controlled order.
65
66// The `Generation` epoch counter that lived here is gone. It existed to
67// invalidate the generation-checked statics described above; with those
68// deleted its only reader was its own test, and a monotonic counter kept alive
69// for a hypothetical future user is the same process global this module argues
70// against. Teardown ordering — the real problem it was reaching for — is
71// expressed by the traits below, which need no epoch.
72
73/// State that owns device memory and must be released in a defined order.
74///
75/// `Drop` is the wrong contract for this and the reason is specific: on GB10
76/// unified memory a device free posts in-band TLB invalidations that corrupt
77/// *neighbouring* allocations when interleaved with other allocation traffic.
78/// That constrains **when** frees happen, not whether — teardown, where nothing
79/// else is allocating and the streams are synchronised, is the safe case, and
80/// the loader's scratch-buffer workaround exists precisely because loading is
81/// not. `Drop` can express neither that ordering nor a failure.
82///
83/// `Cx` is whatever releasing needs — for GPU state that is the allocator.
84/// Making it a type parameter keeps `atlas-core` free of a dependency on the
85/// backend crate while still letting a resource be handed the thing that owns
86/// its memory, rather than making every resource carry its own handle.
87pub trait ModelResource<Cx: ?Sized>: Send + Sync {
88    /// Human name, for the teardown report and for attributing a failure.
89    fn label(&self) -> &'static str;
90
91    /// Release everything this owns. Must be idempotent: the host calls it,
92    /// and a `Drop` backstop may call it again.
93    fn release(&mut self, cx: &Cx) -> anyhow::Result<()>;
94}
95
96/// Releases a set of resources in reverse registration order — the inverse of
97/// how they were built, which is the only order that is safe when later
98/// resources borrow earlier ones.
99///
100/// One failure does not abandon the rest: every resource is released, and the
101/// first error is returned afterwards. A half-torn-down GPU is worse than a
102/// reported error.
103pub struct Teardown<Cx: ?Sized> {
104    resources: Vec<Box<dyn ModelResource<Cx>>>,
105}
106
107impl<Cx: ?Sized> Default for Teardown<Cx> {
108    fn default() -> Self {
109        Self {
110            resources: Vec::new(),
111        }
112    }
113}
114
115impl<Cx: ?Sized> Teardown<Cx> {
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// Register a resource. Registration order is construction order.
121    pub fn push(&mut self, resource: Box<dyn ModelResource<Cx>>) {
122        self.resources.push(resource);
123    }
124
125    pub fn len(&self) -> usize {
126        self.resources.len()
127    }
128
129    pub fn is_empty(&self) -> bool {
130        self.resources.is_empty()
131    }
132
133    /// Release everything, newest first. Returns the first failure, after
134    /// having attempted them all.
135    pub fn release_all(&mut self, cx: &Cx) -> anyhow::Result<()> {
136        let mut failures = Vec::new();
137        while let Some(mut resource) = self.resources.pop() {
138            if let Err(e) = resource.release(cx) {
139                // Every failure is reported, not just the first: after a
140                // partial teardown the operator needs the whole picture to
141                // decide whether the GPU is still usable.
142                failures.push(format!("{}: {e:#}", resource.label()));
143            }
144        }
145        if failures.is_empty() {
146            return Ok(());
147        }
148        Err(anyhow::anyhow!(
149            "{} resource(s) failed to release: {}",
150            failures.len(),
151            failures.join("; ")
152        ))
153    }
154}
155
156#[cfg(test)]
157#[path = "scope_tests.rs"]
158mod tests;