Skip to main content

tensor_wasm_jit/
deopt.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3//! Deoptimisation guard.
4//!
5//! When a GPU-offloaded kernel fails at runtime (CUDA error, numerical
6//! divergence beyond an acceptable tolerance), the executor falls back to
7//! the original CPU implementation. [`DeoptGuard`] tracks whether a given
8//! blueprint is currently deopted so subsequent calls go straight to the
9//! CPU path without retrying the GPU.
10
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::Arc;
13
14use dashmap::DashMap;
15
16/// Reason a particular kernel was deoptimised.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum DeoptReason {
19    /// CUDA driver returned an error during launch or sync.
20    CudaError(String),
21    /// Numerical result differed from the CPU reference beyond tolerance.
22    NumericalDivergence,
23    /// PTX assembly failed to validate.
24    AssemblyFailed(String),
25}
26
27impl DeoptReason {
28    /// Stable, machine-readable name (used in metrics labels).
29    pub fn name(&self) -> &'static str {
30        match self {
31            DeoptReason::CudaError(_) => "cuda_error",
32            DeoptReason::NumericalDivergence => "numerical_divergence",
33            DeoptReason::AssemblyFailed(_) => "assembly_failed",
34        }
35    }
36}
37
38/// Tracks deopted kernels by their blueprint fingerprint.
39pub struct DeoptGuard {
40    deopted: DashMap<u64, DeoptReason>,
41    success_total: AtomicU64,
42    fallback_total: AtomicU64,
43    metrics: Option<tensor_wasm_core::metrics::TensorWasmMetrics>,
44}
45
46impl DeoptGuard {
47    /// Construct an empty guard with no Prometheus metrics wiring.
48    pub fn new() -> Self {
49        Self {
50            deopted: DashMap::new(),
51            success_total: AtomicU64::new(0),
52            fallback_total: AtomicU64::new(0),
53            metrics: None,
54        }
55    }
56
57    /// Construct an empty guard that also publishes success / fallback events
58    /// to the supplied [`tensor_wasm_core::metrics::TensorWasmMetrics`] handle. The handle is
59    /// `Arc`-backed and cheap to clone — pass the same handle used by the rest
60    /// of the process so the `tensor_wasm_offload_success_total` and
61    /// `tensor_wasm_offload_fallback_total` counters reflect the kernels driven by
62    /// this guard.
63    pub fn with_metrics(metrics: tensor_wasm_core::metrics::TensorWasmMetrics) -> Self {
64        Self {
65            deopted: DashMap::new(),
66            success_total: AtomicU64::new(0),
67            fallback_total: AtomicU64::new(0),
68            metrics: Some(metrics),
69        }
70    }
71
72    /// Returns true if the blueprint is currently deopted.
73    pub fn is_deopted(&self, fingerprint: u64) -> bool {
74        self.deopted.contains_key(&fingerprint)
75    }
76
77    /// Record a deopt event. Increments the fallback counter (and, if metrics
78    /// are wired, the `tensor_wasm_offload_fallback_total` Prometheus counter).
79    pub fn record_deopt(&self, fingerprint: u64, reason: DeoptReason) {
80        self.fallback_total.fetch_add(1, Ordering::Relaxed);
81        if let Some(m) = &self.metrics {
82            m.offload_fallback_total().inc();
83        }
84        self.deopted.insert(fingerprint, reason);
85    }
86
87    /// Record a successful GPU execution. Increments the success counter (and,
88    /// if metrics are wired, the `tensor_wasm_offload_success_total` Prometheus
89    /// counter).
90    pub fn record_success(&self) {
91        self.success_total.fetch_add(1, Ordering::Relaxed);
92        if let Some(m) = &self.metrics {
93            m.offload_success_total().inc();
94        }
95    }
96
97    /// Reset deopt status for a fingerprint (e.g. after a kernel-cache
98    /// invalidation that recompiles the PTX).
99    pub fn clear_deopt(&self, fingerprint: u64) -> Option<DeoptReason> {
100        self.deopted.remove(&fingerprint).map(|(_, v)| v)
101    }
102
103    /// Reason for the most recent deopt of this kernel, if any.
104    pub fn reason(&self, fingerprint: u64) -> Option<DeoptReason> {
105        self.deopted.get(&fingerprint).map(|r| r.value().clone())
106    }
107
108    /// Cumulative successful GPU executions.
109    pub fn success_total(&self) -> u64 {
110        self.success_total.load(Ordering::Relaxed)
111    }
112
113    /// Cumulative fallback events.
114    pub fn fallback_total(&self) -> u64 {
115        self.fallback_total.load(Ordering::Relaxed)
116    }
117
118    /// Offload success rate as a fraction in [0, 1].
119    pub fn success_rate(&self) -> f64 {
120        let s = self.success_total() as f64;
121        let f = self.fallback_total() as f64;
122        let total = s + f;
123        if total == 0.0 {
124            0.0
125        } else {
126            s / total
127        }
128    }
129}
130
131impl Default for DeoptGuard {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137/// Shared handle alias for the executor.
138pub type SharedDeoptGuard = Arc<DeoptGuard>;
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn fresh_guard_is_empty() {
146        let g = DeoptGuard::new();
147        assert!(!g.is_deopted(42));
148        assert_eq!(g.success_total(), 0);
149        assert_eq!(g.fallback_total(), 0);
150        assert_eq!(g.success_rate(), 0.0);
151    }
152
153    #[test]
154    fn record_deopt_marks_kernel() {
155        let g = DeoptGuard::new();
156        g.record_deopt(42, DeoptReason::CudaError("ctx not current".into()));
157        assert!(g.is_deopted(42));
158        assert_eq!(g.fallback_total(), 1);
159        let reason = g.reason(42).unwrap();
160        assert!(matches!(reason, DeoptReason::CudaError(_)));
161    }
162
163    #[test]
164    fn clear_deopt_reverts() {
165        let g = DeoptGuard::new();
166        g.record_deopt(42, DeoptReason::NumericalDivergence);
167        let popped = g.clear_deopt(42).unwrap();
168        assert_eq!(popped, DeoptReason::NumericalDivergence);
169        assert!(!g.is_deopted(42));
170    }
171
172    #[test]
173    fn success_rate_accumulates() {
174        let g = DeoptGuard::new();
175        for _ in 0..7 {
176            g.record_success();
177        }
178        for _ in 0..3 {
179            g.record_deopt(0, DeoptReason::NumericalDivergence);
180        }
181        assert!((g.success_rate() - 0.7).abs() < 1e-9);
182    }
183
184    #[test]
185    fn deopt_reason_names() {
186        assert_eq!(
187            DeoptReason::NumericalDivergence.name(),
188            "numerical_divergence"
189        );
190        assert_eq!(DeoptReason::CudaError("x".into()).name(), "cuda_error");
191        assert_eq!(
192            DeoptReason::AssemblyFailed("x".into()).name(),
193            "assembly_failed"
194        );
195    }
196
197    #[test]
198    fn with_metrics_increments_offload_success_total() {
199        use tensor_wasm_core::metrics::TensorWasmMetrics;
200        let metrics = TensorWasmMetrics::new();
201        let g = DeoptGuard::with_metrics(metrics.clone());
202        g.record_success();
203        g.record_success();
204        g.record_success();
205        // Local counter
206        assert_eq!(g.success_total(), 3);
207        // TensorWasm metrics counter
208        let text = metrics.encode_text();
209        assert!(
210            text.contains("tensor_wasm_offload_success_total 3"),
211            "got:\n{text}"
212        );
213    }
214
215    #[test]
216    fn with_metrics_increments_offload_fallback_total() {
217        use tensor_wasm_core::metrics::TensorWasmMetrics;
218        let metrics = TensorWasmMetrics::new();
219        let g = DeoptGuard::with_metrics(metrics.clone());
220        g.record_deopt(1, DeoptReason::NumericalDivergence);
221        g.record_deopt(2, DeoptReason::CudaError("x".into()));
222        assert_eq!(g.fallback_total(), 2);
223        let text = metrics.encode_text();
224        assert!(
225            text.contains("tensor_wasm_offload_fallback_total 2"),
226            "got:\n{text}"
227        );
228    }
229
230    #[test]
231    fn without_metrics_still_works() {
232        // Default constructor — no metrics. The old behaviour is preserved.
233        let g = DeoptGuard::new();
234        g.record_success();
235        g.record_deopt(42, DeoptReason::NumericalDivergence);
236        assert_eq!(g.success_total(), 1);
237        assert_eq!(g.fallback_total(), 1);
238    }
239}