rightkit_gpu/budget.rs
1//! Device memory shared by concurrent GPU jobs (upstream issue #1764). Adapted from Ember `crates/gpu`.
2//!
3//! Each job asks for room for what it is expected to hold on the device and waits while the
4//! renders in flight leave too little; a render always gets in when nothing else is in flight,
5//! however large it is (the device's own limits still apply). wgpu reports no memory size, so the
6//! budget starts from the largest buffer the device allows and adapts to the device it meets:
7//! it is halved when a render runs out of device memory and grows back a step with each render
8//! that succeeds (the additive-increase / multiplicative-decrease rule of TCP congestion control).
9//! A 2 GB card settles near what it holds instead of failing every render past its memory.
10//! Renders get in in the order they asked (a fair semaphore): a large preview is not passed over
11//! for ever by a stream of thumbnails that each fit.
12
13use std::collections::VecDeque;
14use std::sync::{Condvar, Mutex, PoisonError};
15use std::time::Duration;
16
17/// The budget never shrinks below this: one render always fits anyway, so this only bounds how
18/// far a run of failures can push it.
19const FLOOR: u64 = 256 << 20;
20
21/// Device memory a render is expected to hold: the source, two working copies and the output,
22/// as float RGBA, at the source's size.
23pub fn footprint(width: usize, height: usize) -> u64 {
24 (width as u64)
25 .saturating_mul(height as u64)
26 .saturating_mul(16 * 4)
27}
28
29/// Where the budget starts: 1.5 × the largest buffer the device allows (about the device's
30/// memory on small cards), at least 1 GiB.
31pub fn initial(max_buffer: u64) -> u64 {
32 (max_buffer / 2).saturating_mul(3).max(1 << 30)
33}
34
35struct State {
36 /// Bytes held by renders in flight.
37 used: u64,
38 /// Renders in flight.
39 running: u32,
40 /// A retry holds the device exclusively until its permit drops.
41 exclusive: bool,
42 /// Bytes renders may hold together now.
43 limit: u64,
44 /// What the budget grows back to.
45 ceiling: u64,
46 /// Tickets of the renders waiting, in the order they asked.
47 queue: VecDeque<u64>,
48 next_ticket: u64,
49}
50
51pub struct MemoryBudget {
52 state: Mutex<State>,
53 room: Condvar,
54}
55
56/// Room on the device for one render, released when dropped.
57pub struct Permit<'a> {
58 budget: &'a MemoryBudget,
59 bytes: u64,
60 alone: bool,
61}
62
63impl MemoryBudget {
64 pub fn new(ceiling: u64) -> MemoryBudget {
65 MemoryBudget {
66 state: Mutex::new(State {
67 used: 0,
68 running: 0,
69 exclusive: false,
70 limit: ceiling,
71 ceiling,
72 queue: VecDeque::new(),
73 next_ticket: 0,
74 }),
75 room: Condvar::new(),
76 }
77 }
78
79 /// Wait for room for `bytes`; `alone` holds the device exclusively (the retry of a
80 /// render that ran out of memory). `None` when `stop` turns true while waiting (the process is
81 /// ending): the caller renders on the CPU.
82 pub fn admit(&self, bytes: u64, alone: bool, stop: impl Fn() -> bool) -> Option<Permit<'_>> {
83 let mut st = self.state.lock().unwrap_or_else(PoisonError::into_inner);
84 let ticket = st.next_ticket;
85 st.next_ticket = st.next_ticket.wrapping_add(1);
86 st.queue.push_back(ticket);
87 while st.queue.front() != Some(&ticket)
88 || st.exclusive
89 || (st.running > 0 && (alone || st.used.saturating_add(bytes) > st.limit))
90 {
91 if stop() {
92 st.queue.retain(|t| *t != ticket);
93 // the render behind may be the next to fit
94 self.room.notify_all();
95 return None;
96 }
97 st = self
98 .room
99 .wait_timeout(st, Duration::from_millis(100))
100 .unwrap_or_else(PoisonError::into_inner)
101 .0;
102 }
103 st.queue.pop_front();
104 // the next in line may fit beside this one
105 self.room.notify_all();
106 st.used = st.used.saturating_add(bytes);
107 st.running += 1;
108 st.exclusive = alone;
109 Some(Permit {
110 budget: self,
111 bytes,
112 alone,
113 })
114 }
115
116 /// A render ran out of device memory: halve the budget (not below what one render needs).
117 pub fn out_of_memory(&self) {
118 let mut st = self.state.lock().unwrap_or_else(PoisonError::into_inner);
119 st.limit = (st.limit / 2).max(FLOOR);
120 }
121
122 /// A render succeeded: grow the budget back by a sixteenth of its ceiling.
123 pub fn succeeded(&self) {
124 let mut st = self.state.lock().unwrap_or_else(PoisonError::into_inner);
125 st.limit = st.limit.saturating_add(st.ceiling / 16).min(st.ceiling);
126 }
127}
128
129impl Drop for Permit<'_> {
130 fn drop(&mut self) {
131 let mut st = self
132 .budget
133 .state
134 .lock()
135 .unwrap_or_else(PoisonError::into_inner);
136 st.used = st.used.saturating_sub(self.bytes);
137 st.running = st.running.saturating_sub(1);
138 if self.alone {
139 st.exclusive = false;
140 }
141 self.budget.room.notify_all();
142 }
143}