Skip to main content

xgpui_gpu_interop/
heavy.rs

1//! Heavy-engine mode (#197): keeps the UI responsive when an engine's GPU
2//! work is large compared with a display frame.
3//!
4//! Measured on Intel Iris Xe (Linux and Windows) and Apple M-series, a
5//! GPU-saturating engine starved the UI until three things were combined:
6//!
7//! 1. **Observe-only presentation.** The compositor never waits on the GPU
8//!    for an engine frame; it shows frames once complete
9//!    ([`WgpuSurfaceTarget::set_gpu_wait`](crate::WgpuSurfaceTarget)).
10//! 2. **Short submissions.** One frame's work is split into chunks of a few
11//!    milliseconds of GPU time, so the GPU can schedule the compositor
12//!    between them. [`HeavyEngine::chunk_ms`] sets the budget;
13//!    `suggested_chunks` turns the measured frame time into a chunk count.
14//! 3. **Throttling on compositor lag.** The engine slows down while
15//!    frames take longer than [`HeavyEngine::lag_budget_ms`] from present
16//!    to first composition, and recovers when they don't; `pace` returns the
17//!    delay before the next frame.
18//!
19//! On Linux, engine queues are also created at low priority where supported
20//! (`WgpuSurfaceTarget::request_device`).
21use crate::SurfaceStats;
22use std::time::{Duration, Instant};
23
24/// Configuration for heavy-engine mode.
25#[derive(Clone, Copy, Debug, PartialEq)]
26pub struct HeavyEngine {
27    /// Highest frame rate the engine paces to.
28    pub max_hz: f32,
29    /// Lowest frame rate while the compositor lags.
30    pub min_hz: f32,
31    /// Present-to-composition delay above which the engine slows down.
32    pub lag_budget_ms: f32,
33    /// GPU time per submission chunk to aim for.
34    pub chunk_ms: f32,
35    /// Most submissions to split one frame into (default 64). Measured
36    /// with 4 heavy 4K engines on Iris Xe and M-series, 64 matched or beat
37    /// 16 on Linux, Windows and macOS.
38    pub max_chunks: u32,
39}
40
41impl Default for HeavyEngine {
42    fn default() -> Self {
43        Self {
44            max_hz: 60.,
45            min_hz: 0.25,
46            lag_budget_ms: 33.,
47            chunk_ms: 4.,
48            max_chunks: 64,
49        }
50    }
51}
52
53#[cfg_attr(not(feature = "wgpu"), allow(dead_code))] // used only by the wgpu targets
54impl HeavyEngine {
55    fn validated(self) -> Self {
56        let max_hz = if self.max_hz.is_finite() {
57            self.max_hz.clamp(0.01, 1000.)
58        } else {
59            60.
60        };
61        let min_hz = if self.min_hz.is_finite() {
62            self.min_hz.clamp(0.01, max_hz)
63        } else {
64            0.25f32.min(max_hz)
65        };
66        Self {
67            max_hz,
68            min_hz,
69            lag_budget_ms: if self.lag_budget_ms.is_finite() && self.lag_budget_ms > 0. {
70                self.lag_budget_ms
71            } else {
72                33.
73            },
74            chunk_ms: if self.chunk_ms.is_finite() && self.chunk_ms > 0. {
75                self.chunk_ms
76            } else {
77                4.
78            },
79            max_chunks: self.max_chunks.clamp(1, 64),
80        }
81    }
82}
83
84/// Pacing and frame-cost state kept by a target in heavy-engine mode.
85#[cfg_attr(not(feature = "wgpu"), allow(dead_code))] // used only by the wgpu targets
86#[derive(Debug)]
87pub(crate) struct Heavy {
88    config: HeavyEngine,
89    rate: f32,
90    /// Smoothed GPU time per frame (submission to observed completion; an
91    /// upper bound, refined each frame).
92    frame_ms: Option<f32>,
93    pending: Option<Instant>,
94}
95
96#[cfg_attr(not(feature = "wgpu"), allow(dead_code))] // used only by the wgpu targets
97impl Heavy {
98    pub(crate) fn new(config: HeavyEngine) -> Self {
99        let config = config.validated();
100        Self {
101            rate: config.max_hz,
102            config,
103            frame_ms: None,
104            pending: None,
105        }
106    }
107
108    #[cfg(test)]
109    pub(crate) fn config(&self) -> HeavyEngine {
110        self.config
111    }
112
113    /// The engine submitted a frame's work now.
114    pub(crate) fn on_present(&mut self, submitted: Instant) {
115        self.pending.get_or_insert(submitted);
116    }
117
118    /// Record completion of the pending frame if `reached`.
119    pub(crate) fn observe(&mut self, reached: bool) {
120        if reached && let Some(submitted) = self.pending.take() {
121            let ms = submitted.elapsed().as_secs_f32() * 1000.;
122            self.frame_ms = Some(match self.frame_ms {
123                Some(previous) => previous * 0.7 + ms * 0.3,
124                None => ms,
125            });
126        }
127    }
128
129    /// Delay before the next frame: halve the rate (down to `min_hz`) while
130    /// composition lags past the budget, recover by 10% per frame below
131    /// two thirds of it.
132    pub(crate) fn pace(&mut self, stats: &SurfaceStats) -> Duration {
133        let lag = stats.composite_latency_ms;
134        let budget = self.config.lag_budget_ms;
135        if lag > budget {
136            self.rate = (self.rate * 0.5).max(self.config.min_hz);
137        } else if lag < budget * (2. / 3.) {
138            self.rate = (self.rate * 1.1).min(self.config.max_hz);
139        }
140        Duration::from_secs_f32(1. / self.rate)
141    }
142
143    #[cfg(test)]
144    pub(crate) fn rate_hz(&self) -> f32 {
145        self.rate
146    }
147
148    #[cfg(test)]
149    pub(crate) fn frame_ms(&self) -> Option<f32> {
150        self.frame_ms
151    }
152
153    /// Chunks that keep each submission near `chunk_ms` of GPU time.
154    pub(crate) fn suggested_chunks(&self) -> u32 {
155        self.frame_ms
156            .map(|ms| {
157                (ms / self.config.chunk_ms)
158                    .ceil()
159                    .clamp(1., self.config.max_chunks as f32) as u32
160            })
161            .unwrap_or(1)
162    }
163}
164
165/// Snapshot of a target's heavy-engine state.
166#[derive(Clone, Copy, Debug, PartialEq)]
167pub struct HeavyStatus {
168    pub config: HeavyEngine,
169    /// Current paced frame rate.
170    pub rate_hz: f32,
171    /// Smoothed GPU time per frame, once measured.
172    pub frame_ms: Option<f32>,
173    /// Chunks per frame that meet `chunk_ms`.
174    pub suggested_chunks: u32,
175}
176
177#[cfg_attr(not(feature = "wgpu"), allow(dead_code))] // used only by the wgpu targets
178impl Heavy {
179    pub(crate) fn status(&self) -> HeavyStatus {
180        HeavyStatus {
181            config: self.config,
182            rate_hz: self.rate,
183            frame_ms: self.frame_ms,
184            suggested_chunks: self.suggested_chunks(),
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    fn lagging(ms: f32) -> SurfaceStats {
194        SurfaceStats {
195            composite_latency_ms: ms,
196            ..Default::default()
197        }
198    }
199
200    #[test]
201    fn pace_backs_off_while_lagging_and_recovers() {
202        let mut heavy = Heavy::new(HeavyEngine::default());
203        assert_eq!(heavy.pace(&lagging(5.)), Duration::from_secs_f32(1. / 60.));
204        for _ in 0..20 {
205            heavy.pace(&lagging(200.));
206        }
207        assert_eq!(heavy.rate_hz(), 0.25, "floors at min_hz");
208        // Within budget but above two thirds of it: hold.
209        heavy.pace(&lagging(30.));
210        assert_eq!(heavy.rate_hz(), 0.25);
211        for _ in 0..200 {
212            heavy.pace(&lagging(5.));
213        }
214        assert_eq!(heavy.rate_hz(), 60., "recovers to max_hz");
215    }
216
217    #[test]
218    fn chunks_follow_measured_frame_time() {
219        let mut heavy = Heavy::new(HeavyEngine::default());
220        assert_eq!(heavy.suggested_chunks(), 1, "unmeasured");
221        heavy.on_present(Instant::now() - Duration::from_millis(40));
222        heavy.observe(false);
223        assert_eq!(heavy.frame_ms(), None, "not reached yet");
224        heavy.observe(true);
225        let ms = heavy.frame_ms().unwrap();
226        assert!(ms >= 40., "{ms}");
227        let cap = HeavyEngine::default().max_chunks;
228        assert_eq!(heavy.suggested_chunks(), ((ms / 4.).ceil() as u32).min(cap));
229        // A frame far over budget is capped at max_chunks.
230        heavy.on_present(Instant::now() - Duration::from_secs(2));
231        heavy.observe(true);
232        assert_eq!(heavy.suggested_chunks(), cap);
233    }
234
235    #[test]
236    fn invalid_config_is_sanitised() {
237        let heavy = Heavy::new(HeavyEngine {
238            max_hz: f32::NAN,
239            min_hz: 500.,
240            lag_budget_ms: -1.,
241            chunk_ms: 0.,
242            max_chunks: 0,
243        });
244        let config = heavy.config();
245        assert_eq!(config.max_hz, 60.);
246        assert_eq!(config.min_hz, 60.);
247        assert_eq!(config.lag_budget_ms, 33.);
248        assert_eq!(config.chunk_ms, 4.);
249        assert_eq!(config.max_chunks, 1);
250    }
251}