Skip to main content

solana_program_runtime/
program_metrics.rs

1#[cfg(feature = "metrics")]
2use solana_svm_timings::ExecuteDetailsTimings;
3use {
4    crate::loaded_programs::ForkGraph,
5    log::{debug, log_enabled, trace},
6    solana_pubkey::Pubkey,
7    std::{
8        collections::HashMap,
9        sync::atomic::{AtomicU64, Ordering},
10    },
11};
12
13#[derive(Debug, Default)]
14pub struct ProgramStatistics {
15    pub uses: AtomicU64,
16
17    pub compilations: AtomicU64,
18    pub total_compilation_time_us: AtomicU64,
19    /// Exponential moving average of the compilation time.
20    pub compilation_time_ema: AtomicU64,
21
22    pub jit_invocations: AtomicU64,
23    pub total_jit_execution_time_us: AtomicU64,
24    /// Exponential moving average of the JIT execution time.
25    pub jit_execution_time_ema: AtomicU64,
26
27    pub interpreted_invocations: AtomicU64,
28    pub total_interpretation_time_us: AtomicU64,
29    /// Exponential moving average of the interpreted execution time.
30    pub interpretation_time_ema: AtomicU64,
31}
32
33/// Number of compilation observations contributing to the the [`Self::compilation_time_ema`].
34const COMPILATION_EMA_WINDOW_SIZE: u64 = 10;
35/// Number of execution observations contributing to the execution EMA stats.
36const EXECUTION_EMA_WINDOW_SIZE: u64 = 500;
37/// Track exponential moving average in scaled-up units.
38///
39/// Doing so allows to mitigate error from rounding-towards-zero we get when using integer math.
40pub(crate) const EMA_SCALE: u64 = 1_000;
41
42impl ProgramStatistics {
43    fn observe_ema<const WINDOW_SIZE: u64>(counter: &AtomicU64, duration_us: u64) {
44        let duration_ema = duration_us.saturating_mul(EMA_SCALE);
45        counter
46            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |ema| {
47                // Exponential moving average iteratively is computed as $ema' = alpha *
48                // observation + (1 - alpha) * ema$. This works great for floating point, but we
49                // want integers. For purposes of convenience we also want to really think in terms
50                // of simple moving average window sizes as that is easier to reason about.
51                //
52                // Exponential moving average and simple moving average of window N has a rough
53                // equivalence of `alpha ≈ 2 / (N + 1)`. Slotting this into our original iterative
54                // formula:
55                //
56                // $$ ema' = 2 / (N+1) * observation + (1 - 2/(N+1)) * ema $$
57                //
58                // we get
59                //
60                // $$ ema' = (2*observation)/(N+1) + (N+1-2)*ema/(N+1) $$
61                let (numer, denom) = const { (2, 1 + WINDOW_SIZE) };
62                Some(if ema == 0 {
63                    duration_ema
64                } else {
65                    let weighted_observation = duration_ema.saturating_mul(numer);
66                    let previous_observations = ema.saturating_mul(denom.saturating_sub(numer));
67                    weighted_observation
68                        .saturating_add(previous_observations)
69                        .checked_div(denom)
70                        .expect("unreachable: denom is >= 1")
71                })
72            })
73            .expect("unreachable: closure always returns a Some");
74    }
75
76    /// Record information about JIT compilation.
77    pub fn jit_compiled(&self, duration_us: u64) {
78        let ord = Ordering::Relaxed;
79        self.compilations.fetch_add(1, ord);
80        self.total_compilation_time_us.fetch_add(duration_us, ord);
81        Self::observe_ema::<COMPILATION_EMA_WINDOW_SIZE>(&self.compilation_time_ema, duration_us);
82    }
83
84    /// Record information about JIT-compiled program having been executed.
85    pub fn jit_executed(&self, duration_us: u64) {
86        let ord = Ordering::Relaxed;
87        self.jit_invocations.fetch_add(1, ord);
88        self.total_jit_execution_time_us.fetch_add(duration_us, ord);
89        Self::observe_ema::<EXECUTION_EMA_WINDOW_SIZE>(&self.jit_execution_time_ema, duration_us);
90    }
91
92    /// Record information about program executed with the interpreter.
93    pub fn interpreter_executed(&self, duration_us: u64) {
94        let ord = Ordering::Relaxed;
95        self.interpreted_invocations.fetch_add(1, ord);
96        self.total_interpretation_time_us
97            .fetch_add(duration_us, ord);
98        Self::observe_ema::<EXECUTION_EMA_WINDOW_SIZE>(&self.interpretation_time_ema, duration_us);
99    }
100
101    pub fn merge_from(&self, other: &ProgramStatistics) {
102        let ord = Ordering::Relaxed;
103        self.uses.fetch_add(other.uses.load(ord), ord);
104        let other_compilations = other.compilations.load(ord);
105        let this_compilations = self.compilations.fetch_add(other_compilations, ord);
106        self.total_compilation_time_us
107            .fetch_add(other.total_compilation_time_us.load(ord), ord);
108        let other_jit_invocations = other.jit_invocations.load(ord);
109        let this_jit_invocations = self.jit_invocations.fetch_add(other_jit_invocations, ord);
110        self.total_jit_execution_time_us
111            .fetch_add(other.total_jit_execution_time_us.load(ord), ord);
112        let other_interpretations = other.interpreted_invocations.load(ord);
113        let this_interpretations = self
114            .interpreted_invocations
115            .fetch_add(other_interpretations, ord);
116        self.total_interpretation_time_us
117            .fetch_add(other.total_interpretation_time_us.load(ord), ord);
118        if let Some(comp_ema) = ProgramCacheStats::combined_ema::<
119            COMPILATION_EMA_WINDOW_SIZE,
120            COMPILATION_EMA_WINDOW_SIZE,
121        >(
122            &self.compilation_time_ema,
123            &other.compilation_time_ema,
124            this_compilations,
125            other_compilations,
126        ) {
127            self.compilation_time_ema.store(comp_ema, ord);
128        }
129        if let Some(exec_ema) =
130            ProgramCacheStats::combined_ema::<EXECUTION_EMA_WINDOW_SIZE, EXECUTION_EMA_WINDOW_SIZE>(
131                &self.jit_execution_time_ema,
132                &other.jit_execution_time_ema,
133                this_jit_invocations,
134                other_jit_invocations,
135            )
136        {
137            self.jit_execution_time_ema.store(exec_ema, ord);
138        }
139        if let Some(interp_ema) =
140            ProgramCacheStats::combined_ema::<EXECUTION_EMA_WINDOW_SIZE, EXECUTION_EMA_WINDOW_SIZE>(
141                &self.interpretation_time_ema,
142                &other.interpretation_time_ema,
143                this_interpretations,
144                other_interpretations,
145            )
146        {
147            self.interpretation_time_ema.store(interp_ema, ord);
148        }
149    }
150}
151
152/// Global cache statistics for [ProgramCache].
153#[derive(Debug, Default)]
154pub struct ProgramCacheStats {
155    /// a program was already in the cache
156    pub hits: AtomicU64,
157    /// a program was not found and loaded instead
158    pub misses: AtomicU64,
159    /// a compiled executable was unloaded
160    pub evictions: HashMap<Pubkey, u64>,
161    /// an unloaded program was loaded again (opposite of eviction)
162    pub reloads: AtomicU64,
163    /// a program was loaded or un/re/deployed
164    pub insertions: AtomicU64,
165    /// a program was loaded but can not be extracted on its own fork anymore
166    pub lost_insertions: AtomicU64,
167    /// a program which was already in the cache was reloaded by mistake
168    pub replacements: AtomicU64,
169    /// a program was only used once before being unloaded
170    pub one_hit_wonders: AtomicU64,
171    /// a program got pruned because it was unloaded for too long or a tombstone
172    pub prunes_stale: AtomicU64,
173    /// a program became unreachable in the fork graph because of rerooting
174    pub prunes_orphan: AtomicU64,
175    /// a program got pruned because it was not recompiled for the next epoch
176    pub prunes_environment: AtomicU64,
177    /// a program had no entries because all slot versions got pruned
178    pub empty_entries: AtomicU64,
179    /// water level of loaded entries currently cached
180    pub water_level: AtomicU64,
181}
182
183impl ProgramCacheStats {
184    pub fn reset(&mut self) {
185        *self = ProgramCacheStats::default();
186    }
187    pub fn log(&self) {
188        let hits = self.hits.load(Ordering::Relaxed);
189        let misses = self.misses.load(Ordering::Relaxed);
190        let evictions: u64 = self.evictions.values().sum();
191        let reloads = self.reloads.load(Ordering::Relaxed);
192        let insertions = self.insertions.load(Ordering::Relaxed);
193        let lost_insertions = self.lost_insertions.load(Ordering::Relaxed);
194        let replacements = self.replacements.load(Ordering::Relaxed);
195        let one_hit_wonders = self.one_hit_wonders.load(Ordering::Relaxed);
196        let prunes_stale = self.prunes_stale.load(Ordering::Relaxed);
197        let prunes_orphan = self.prunes_orphan.load(Ordering::Relaxed);
198        let prunes_environment = self.prunes_environment.load(Ordering::Relaxed);
199        let empty_entries = self.empty_entries.load(Ordering::Relaxed);
200        let water_level = self.water_level.load(Ordering::Relaxed);
201        debug!(
202            "Loaded Programs Cache Stats -- Hits: {hits}, Misses: {misses}, Evictions: \
203             {evictions}, Reloads: {reloads}, Insertions: {insertions}, Lost-Insertions: \
204             {lost_insertions}, Replacements: {replacements}, One-Hit-Wonders: {one_hit_wonders}, \
205             Prunes-Stale: {prunes_stale}, Prunes-Orphan: {prunes_orphan}, Prunes-Environment: \
206             {prunes_environment}, Empty: {empty_entries}, Water-Level: {water_level}"
207        );
208
209        if log_enabled!(log::Level::Trace) && !self.evictions.is_empty() {
210            let mut evictions = self.evictions.iter().collect::<Vec<_>>();
211            evictions.sort_by_key(|e| e.1);
212            let evictions = evictions
213                .into_iter()
214                .rev()
215                .map(|(program_id, evictions)| {
216                    format!("  {:<44}  {}", program_id.to_string(), evictions)
217                })
218                .collect::<Vec<_>>();
219            let evictions = evictions.join("\n");
220            trace!(
221                "Eviction Details:\n  {:<44}  {}\n{}",
222                "Program", "Count", evictions
223            );
224        }
225    }
226
227    fn combined_ema<const WINDOW1: u64, const WINDOW2: u64>(
228        into_ema: &AtomicU64,
229        from_ema: &AtomicU64,
230        into_observations: u64,
231        from_observations: u64,
232    ) -> Option<u64> {
233        // This is a mild non-sense, but there is no good mathematically rigorous way to merge
234        // two independent EMA trackers AFAICT and this is the best I (nagisa) could come up
235        // with…
236        let other_ema_val = from_ema.load(Ordering::Relaxed);
237        let other_ema_weight = std::cmp::max(WINDOW1, from_observations);
238        let this_ema_val = into_ema.load(Ordering::Relaxed);
239        let this_ema_weight = std::cmp::max(WINDOW2, into_observations);
240        other_ema_val
241            .wrapping_mul(other_ema_weight)
242            .wrapping_add(this_ema_val.wrapping_mul(this_ema_weight))
243            .checked_div(other_ema_weight.wrapping_add(this_ema_weight))
244    }
245}
246
247#[cfg(feature = "metrics")]
248/// Time measurements for loading a single [ProgramCacheEntry].
249#[derive(Debug, Default)]
250pub struct LoadProgramMetrics {
251    /// Program address, but as text
252    pub program_id: String,
253    /// Microseconds it took to `create_program_runtime_environment`
254    pub register_syscalls_us: u64,
255    /// Microseconds it took to `Executable::<InvokeContext>::load`
256    pub load_elf_us: u64,
257    /// Microseconds it took to `executable.verify::<RequisiteVerifier>`
258    pub verify_code_us: u64,
259    /// Microseconds it took to `executable.jit_compile`
260    pub jit_compile_us: u64,
261}
262
263#[cfg(feature = "metrics")]
264impl LoadProgramMetrics {
265    pub fn submit_datapoint(&self, timings: &mut ExecuteDetailsTimings) {
266        timings.create_executor_register_syscalls_us += self.register_syscalls_us;
267        timings.create_executor_load_elf_us += self.load_elf_us;
268        timings.create_executor_verify_code_us += self.verify_code_us;
269        timings.create_executor_jit_compile_us += self.jit_compile_us;
270    }
271}
272
273impl<FG: ForkGraph> crate::loaded_programs::ProgramCache<FG> {
274    /// Log per-entry statistics for each entry in the global cache.
275    #[cfg(feature = "dev-context-only-utils")]
276    pub fn output_entry_stats(&self) {
277        use {crate::program_cache_entry::ProgramCacheEntryType, std::fmt::Write};
278        // The entry stats can become very verbose after some runtime. Rather than dumping them
279        // to the log, we'd rather maintain a continuously updated file instead...
280        static ENTRY_STAT_PATH: std::sync::LazyLock<Option<std::ffi::OsString>> =
281            std::sync::LazyLock::new(|| std::env::var_os("AGAVE_PROGRAM_CACHE_ENTRY_STATS_PATH"));
282        let Some(stat_path) = &*ENTRY_STAT_PATH else {
283            log::trace!("Set AGAVE_PROGRAM_CACHE_ENTRY_STATS_PATH to write per-entry stats");
284            return;
285        };
286        let mut output = String::new();
287        let entries = self.get_flattened_entries_for_tests();
288        for (addr, entry) in entries {
289            let entry_ty = match &entry.program {
290                ProgramCacheEntryType::FailedVerification(_) => "FailedVerification",
291                ProgramCacheEntryType::Closed => "Closed",
292                ProgramCacheEntryType::DelayVisibility => "DelayVisibility",
293                ProgramCacheEntryType::Unloaded(_) => "Unloaded",
294                ProgramCacheEntryType::Builtin(_) => "Builtin",
295                #[cfg(not(all(not(target_os = "windows"), target_arch = "x86_64")))]
296                ProgramCacheEntryType::Loaded(_) => "Loaded",
297                #[cfg(all(not(target_os = "windows"), target_arch = "x86_64"))]
298                ProgramCacheEntryType::Loaded(executable) => {
299                    if executable.get_compiled_program().is_some() {
300                        "JitCompiled"
301                    } else {
302                        "Loaded"
303                    }
304                }
305            };
306            let stats = &entry.stats;
307            let uses = stats.uses.load(Ordering::Relaxed);
308            let compiles = stats.compilations.load(Ordering::Relaxed);
309            let comptime = stats.total_compilation_time_us.load(Ordering::Relaxed);
310            let comptime_ema = stats.compilation_time_ema.load(Ordering::Relaxed) / EMA_SCALE;
311            let invokes = stats.jit_invocations.load(Ordering::Relaxed);
312            let jittime = stats.total_jit_execution_time_us.load(Ordering::Relaxed);
313            let jittime_ema = stats.jit_execution_time_ema.load(Ordering::Relaxed) / EMA_SCALE;
314            let interps = stats.interpreted_invocations.load(Ordering::Relaxed);
315            let interptime = stats.total_interpretation_time_us.load(Ordering::Relaxed);
316            let interpema = stats.interpretation_time_ema.load(Ordering::Relaxed) / EMA_SCALE;
317            let _ = writeln!(
318                &mut output,
319                "{addr},{entry_ty},{uses},{compiles},{comptime},{comptime_ema},{invokes},\
320                 {jittime},{jittime_ema},{interps},{interptime},{interpema}"
321            );
322        }
323        if let Err(e) = std::fs::write(stat_path, output) {
324            log::info!("Writing entry stats to {stat_path:?} failed: {e:?}");
325        } else {
326            log::debug!("Entry stats written to {stat_path:?}");
327        }
328    }
329}