Skip to main content

alpha2_render/
submission_planner.rs

1//! Splits one frame's GPU work into submissions that each stay under the watchdog budget.
2//!
3//! A GPU that cannot preempt a long draw resets the device when one submission exceeds the OS
4//! timeout. Each pass's time is predicted from its cost ([`crate::effect_cost`]), covered pixels
5//! and the device rate ([`alpha2_gpu::GpuRate`]); a pass that would push the command buffer past
6//! [`alpha2_gpu::submission_budget_ms`] starts a new submission, and a single pass over budget
7//! is drawn in scissored horizontal bands, one submission each.
8//!
9//! Frames below [`PLANNING_THRESHOLD_UNIT_PIXELS`] stay one command buffer and need no rate.
10//! Splitting never changes pixels, only when the GPU starts each part.
11
12use crate::effect_cost::{ARITHMETIC_UNITS, EffectCost, FETCH_UNITS, TRANSCENDENTAL_UNITS};
13
14/// Frames at most this cost (64 units per pixel over a megapixel) are not planned; well under a
15/// millisecond on the slowest GPU measured.
16pub(crate) const PLANNING_THRESHOLD_UNIT_PIXELS: f64 = 64.0 * 1_048_576.0;
17
18/// The most bands a pass is drawn in.
19pub(crate) const MAX_BANDS: u32 = 64;
20
21/// Per covered pixel of a composite layer: texture read, minification taps and blend.
22pub(crate) const COMPOSITE_LAYER_COST: EffectCost = EffectCost::fixed(4, 0);
23/// Per covered pixel of a vector scene: coverage and paint reads.
24pub(crate) const VECTOR_COST: EffectCost = EffectCost::fixed(2, 0);
25
26/// A frame's predicted cost in unit pixels per kind of work.
27#[derive(Copy, Clone, Debug, Default, PartialEq)]
28pub struct FrameCost {
29    /// Texture reads.
30    pub fetches: f64,
31    /// Transcendental calls.
32    pub transcendentals: f64,
33    /// Other operators and calls.
34    pub arithmetic: f64,
35}
36
37impl FrameCost {
38    /// Weighted sum the rate applies to.
39    #[must_use]
40    #[allow(clippy::cast_precision_loss)]
41    pub fn unit_pixels(&self) -> f64 {
42        self.fetches * FETCH_UNITS as f64
43            + self.transcendentals * TRANSCENDENTAL_UNITS as f64
44            + self.arithmetic * ARITHMETIC_UNITS as f64
45    }
46
47    #[allow(clippy::cast_precision_loss)]
48    pub(crate) fn add(&mut self, cost: EffectCost, pixels: u64) {
49        let pixels = pixels as f64;
50        self.fetches += cost.fetches as f64 * pixels;
51        self.transcendentals += cost.transcendentals as f64 * pixels;
52        self.arithmetic += cost.arithmetic as f64 * pixels;
53    }
54}
55
56/// How one frame was submitted, as the renderer planned it.
57#[derive(Copy, Clone, Debug, Default, PartialEq)]
58pub struct FrameSubmissions {
59    /// Queue submissions the frame went in.
60    pub submissions: u32,
61    /// Predicted GPU time in milliseconds, when planned.
62    pub predicted_ms: Option<f64>,
63    /// The frame's predicted cost.
64    pub cost: FrameCost,
65}
66
67#[derive(Copy, Clone, Debug, PartialEq, Eq)]
68pub(crate) struct PassPlacement {
69    /// The current command buffer is finished before the pass.
70    pub(crate) cut_before: bool,
71    /// Bands the pass is drawn in, each in a command buffer of its own.
72    pub(crate) bands: u32,
73}
74
75#[derive(Debug, Default)]
76pub(crate) struct SubmissionPlanner {
77    /// Milliseconds per unit pixel and the budget, once planned.
78    plan: Option<(f64, f64)>,
79    /// Predicted milliseconds of the command buffer being recorded.
80    current_ms: f64,
81    /// Predicted milliseconds of each command buffer finished before it.
82    finished_ms: Vec<f64>,
83    /// Time of each remaining band of a banded pass.
84    band_ms: f64,
85    cost: FrameCost,
86    /// Set once a graph was encoded.
87    frame: bool,
88}
89
90impl SubmissionPlanner {
91    pub(crate) fn plan(&mut self, ms_per_unit_pixel: f64, budget_ms: f64) {
92        self.plan = Some((ms_per_unit_pixel, budget_ms.max(f64::MIN_POSITIVE)));
93    }
94
95    pub(crate) const fn mark_frame(&mut self) {
96        self.frame = true;
97    }
98
99    /// Charges a pass of `cost` over `pixels` and places it. A pass that is not `bandable` only
100    /// gets its own command buffer.
101    pub(crate) fn pass(&mut self, cost: EffectCost, pixels: u64, bandable: bool) -> PassPlacement {
102        self.cost.add(cost, pixels);
103        let Some((rate, budget)) = self.plan else {
104            return PassPlacement {
105                cut_before: false,
106                bands: 1,
107            };
108        };
109        #[allow(clippy::cast_precision_loss)]
110        let ms = cost.units() as f64 * pixels as f64 * rate;
111        if ms > budget {
112            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
113            let bands = if bandable {
114                ((ms / budget).ceil() as u32).clamp(1, MAX_BANDS)
115            } else {
116                1
117            };
118            let cut_before = self.current_ms > 0.0;
119            if cut_before {
120                self.finished_ms.push(self.current_ms);
121            }
122            self.band_ms = ms / f64::from(bands);
123            self.current_ms = self.band_ms;
124            return PassPlacement { cut_before, bands };
125        }
126        let cut_before = self.current_ms > 0.0 && self.current_ms + ms > budget;
127        if cut_before {
128            self.finished_ms.push(self.current_ms);
129            self.current_ms = 0.0;
130        }
131        self.current_ms += ms;
132        PassPlacement {
133            cut_before,
134            bands: 1,
135        }
136    }
137
138    pub(crate) fn next_band(&mut self) {
139        self.finished_ms.push(self.current_ms);
140        self.current_ms = self.band_ms;
141    }
142
143    pub(crate) const fn is_planned(&self) -> bool {
144        self.plan.is_some()
145    }
146
147    pub(crate) const fn is_frame(&self) -> bool {
148        self.frame
149    }
150
151    pub(crate) const fn cost(&self) -> FrameCost {
152        self.cost
153    }
154
155    /// Each command buffer's predicted milliseconds, when planned.
156    pub(crate) fn buffer_ms(&self) -> Option<Vec<f64>> {
157        self.plan.map(|_| {
158            let mut buffers = self.finished_ms.clone();
159            buffers.push(self.current_ms);
160            buffers
161        })
162    }
163}
164
165const CALIBRATION_SIDE: u32 = 512;
166
167/// 64 iterations of an offset texture read and a sine, close to heavy effects' per-unit cost.
168const CALIBRATION_BODY: &str = "\
169fn effect(uv: vec2f, src: vec4f) -> vec4f {
170    var acc = src;
171    for (var i = 0u; i < 64u; i++) {
172        let s = sample_src(fract(uv + vec2f(f32(i) * 0.0019, f32(i) * 0.0007)));
173        acc = vec4f(sin(acc.r * 1.37 + s.g), acc.g, acc.b, acc.a);
174    }
175    return acc;
176}
177";
178
179fn calibration_definition() -> crate::EffectDef {
180    crate::EffectDef {
181        id: "alpha2.internal.calibrate".into(),
182        version: 1,
183        kind: crate::EffectKind::Effect,
184        inputs: Vec::new(),
185        params: Vec::new(),
186        resources: Vec::new(),
187        wgsl_body: CALIBRATION_BODY.into(),
188        tier_min: alpha2_gpu::CapabilityTier::TierB,
189        license: None,
190    }
191}
192
193impl crate::Renderer {
194    /// Plans the frame when `cost` exceeds [`PLANNING_THRESHOLD_UNIT_PIXELS`], at the device rate
195    /// calibrated by the first such frame. A browser cannot wait for calibration, so it assumes
196    /// [`alpha2_gpu::UNCALIBRATED_MS_PER_UNIT_PIXEL`] meanwhile.
197    pub(crate) fn plan_submissions(
198        &self,
199        encoder: &mut crate::renderer::RenderEncoder,
200        cost: FrameCost,
201    ) {
202        encoder.planner().mark_frame();
203        if cost.unit_pixels() <= PLANNING_THRESHOLD_UNIT_PIXELS {
204            return;
205        }
206        let rate = self.gpu.rate();
207        if rate.ms_per_unit_pixel().is_none()
208            && rate.begin_calibration()
209            && self.calibrate().is_err()
210        {
211            rate.abandon_calibration();
212        }
213        let ms_per_unit_pixel = rate
214            .ms_per_unit_pixel()
215            .unwrap_or(alpha2_gpu::UNCALIBRATED_MS_PER_UNIT_PIXEL);
216        encoder.planner().plan(
217            ms_per_unit_pixel,
218            f64::from(alpha2_gpu::submission_budget_ms()),
219        );
220    }
221
222    /// Draws the calibration effect twice at 512x512, one submission each, and seeds the device
223    /// rate on completion.
224    ///
225    /// Natively only the second (warm) draw is timed, since completions in one poll arrive
226    /// together. A browser cannot wait, so it uses half the summed span, which is conservative.
227    fn calibrate(&self) -> Result<(), crate::RenderError> {
228        let effect = self
229            .calibration
230            .get_or_init(|| crate::registry::registered_effect(calibration_definition()));
231        let id = crate::EffectId::new(effect.definition.id.clone(), effect.definition.version);
232        let size = crate::RenderSize::new(CALIBRATION_SIDE, CALIBRATION_SIDE)?;
233        let format = self.intermediate_format();
234        let params = crate::EffectParams::new();
235        let resources = crate::EffectResources::new();
236        let mut first = None;
237        let mut last = None;
238        for _ in 0..2 {
239            let mut encoder = self.command_encoder("alpha2 calibration encoder");
240            let source =
241                self.textures
242                    .acquire(self.gpu.device(), size, format, "alpha2 calibration source");
243            let target =
244                self.textures
245                    .acquire(self.gpu.device(), size, format, "alpha2 calibration target");
246            let mut uniforms = self.uniform_buffers.acquire(self.gpu.device(), 4096);
247            self.encode_registered_pass(
248                &mut encoder,
249                effect,
250                crate::runtime_effect::EffectPass {
251                    id: &id,
252                    kind: crate::RenderNodeKind::Effect,
253                    source_a: source.view(),
254                    source_b: None,
255                    inputs: &[],
256                    size,
257                    params: &params,
258                    resources: &resources,
259                    runtime: crate::EffectRuntime::default(),
260                    output: target.view(),
261                },
262                &mut uniforms,
263            )?;
264            uniforms.flush(self.gpu.queue());
265            let submitted = self.submit_measured([encoder.finish()], false)?;
266            uniforms.recycle();
267            #[cfg(not(target_arch = "wasm32"))]
268            self.gpu
269                .device()
270                .poll(wgpu::PollType::Wait {
271                    submission_index: Some(submitted.index.clone()),
272                    timeout: Some(self.gpu_wait_timeout),
273                })
274                .map_err(crate::error::poll_error)?;
275            first.get_or_insert(submitted.sequences.start);
276            last = Some(submitted);
277        }
278        let submitted = last.expect("two calibration draws");
279        #[cfg(not(target_arch = "wasm32"))]
280        let (sequences, draws) = (submitted.sequences.clone(), 1.0);
281        #[cfg(target_arch = "wasm32")]
282        let (sequences, draws) = (
283            first.unwrap_or(submitted.sequences.start)..submitted.sequences.end,
284            2.0,
285        );
286        #[allow(clippy::cast_precision_loss)]
287        let unit_pixels =
288            effect.cost.units() as f64 * f64::from(CALIBRATION_SIDE) * f64::from(CALIBRATION_SIDE);
289        let clock = std::sync::Arc::clone(&self.submission_clock);
290        let rate = std::sync::Arc::clone(self.gpu.rate());
291        let seed = move || match clock.gpu_ms(sequences) {
292            Some(ms) => rate.seed(ms / draws / unit_pixels),
293            None => rate.abandon_calibration(),
294        };
295        // Natively both draws were waited for, so their completions have run.
296        #[cfg(not(target_arch = "wasm32"))]
297        seed();
298        #[cfg(target_arch = "wasm32")]
299        self.gpu.on_submitted_work_done(seed);
300        Ok(())
301    }
302}
303
304/// Scissor rows of band `band` of `bands` over `height` rows. Edges are on even rows so no 2x2
305/// derivative quad straddles two bands.
306pub(crate) fn band_rows(band: u32, bands: u32, height: u32) -> (u32, u32) {
307    let edge = |index: u32| -> u32 {
308        if index >= bands {
309            return height;
310        }
311        let row = u64::from(height) * u64::from(index) / u64::from(bands);
312        u32::try_from(row).unwrap_or(height) & !1
313    };
314    let top = edge(band);
315    (top, edge(band + 1).saturating_sub(top))
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn passes_fill_a_buffer_up_to_the_budget_and_a_heavy_one_is_banded() {
324        let mut planner = SubmissionPlanner::default();
325        let pass = EffectCost {
326            fetches: 0,
327            transcendentals: 0,
328            arithmetic: 10,
329        };
330        assert_eq!(planner.pass(pass, 100, true).bands, 1);
331        assert_eq!(planner.buffer_ms(), None);
332        planner.plan(1.0, 2_500.0);
333        // 1000 ms each: two fit, the third starts a new buffer.
334        assert!(!planner.pass(pass, 100, true).cut_before);
335        assert!(!planner.pass(pass, 100, true).cut_before);
336        assert!(planner.pass(pass, 100, true).cut_before);
337        // 10,000 ms: four bands of 2,500 after a cut.
338        let placed = planner.pass(pass, 1_000, true);
339        assert_eq!(
340            placed,
341            PassPlacement {
342                cut_before: true,
343                bands: 4
344            }
345        );
346        for _ in 1..placed.bands {
347            planner.next_band();
348        }
349        assert_eq!(
350            planner.pass(pass, 1_000, false),
351            PassPlacement {
352                cut_before: true,
353                bands: 1
354            }
355        );
356        assert_eq!(
357            planner.buffer_ms(),
358            Some(vec![
359                2_000.0, 1_000.0, 2_500.0, 2_500.0, 2_500.0, 2_500.0, 10_000.0
360            ])
361        );
362        assert!((planner.cost().unit_pixels() - 24_000.0).abs() < 1e-9);
363    }
364
365    #[test]
366    fn bands_cover_every_row_once_on_even_edges() {
367        for (bands, height) in [(1, 7), (3, 288), (7, 101), (64, 4320)] {
368            let mut next = 0;
369            for band in 0..bands {
370                let (top, rows) = band_rows(band, bands, height);
371                assert_eq!(top, next);
372                assert_eq!(top % 2, 0);
373                next = top + rows;
374            }
375            assert_eq!(next, height);
376        }
377    }
378}