cubecl_server/device_events/profiler.rs
1use alloc::boxed::Box;
2
3use cubecl_common::profile::{Duration, Instant, ProfileDuration, ProfileTicks};
4use cubecl_environment::backtrace::BackTrace;
5use cubecl_environment::collections::HashMap;
6use cubecl_environment::sync::Arc;
7
8use crate::device_events::{Event, EventApi, EventPool, Pooled};
9use crate::driver::DriverError;
10use crate::server::{ProfileError, ProfilingToken, ServerError};
11
12/// How long an [anchor](Anchor) is trusted before the next window takes a fresh
13/// one.
14///
15/// The device APIs answer an elapsed time in `f32` milliseconds, so the further
16/// an event sits from the anchor the coarser its placement on the host clock: a
17/// second out, the representable step is about 60 ns; an hour out, a quarter of
18/// a millisecond. A window's own duration is measured between its own two
19/// events and is unaffected — this only bounds the error on *where* the window
20/// lands, which is what a tracing profiler lines its spans up with.
21const ANCHOR_MAX_AGE: Duration = Duration::from_secs(1);
22
23/// What a window reports when its events could not be read back.
24///
25/// The read only fails when the device is already in trouble, and a resolved
26/// profile is a duration rather than a result — there is no error to hand back
27/// at that point. Reporting zero would give an autotune sweep a candidate that
28/// won by failing, so an unreadable window reports a time no real one reaches
29/// and says why in the log.
30const UNREADABLE: Duration = Duration::from_secs(3600);
31
32/// Device profiling on the GPU's own clock.
33///
34/// A profiling window is two events recorded into the stream where the window
35/// opened and closed. Nothing on the host waits for them: the device stamps
36/// each one as the queue reaches it, and the span between the two is read back
37/// later, by whoever awaits the measurement.
38///
39/// That deferral is what makes a measurement mean something when profiles nest.
40/// The [system-time profiler](crate::timestamp_profiler::TimestampProfiler) has
41/// to drain the stream at both ends of every window to have anything to time,
42/// so an inner window's two drains happen *inside* the outer one and are
43/// charged to it: the outer measurement grows with the number of inner ones,
44/// and every window reads back the launch latency the drain exposed rather than
45/// the work. Events are recorded in-queue and cost the host nothing, so an
46/// inner window measures its own kernels and the outer one measures the work
47/// rather than the profiling.
48///
49/// The state is the windows currently open, and the anchor that places them on
50/// the host clock.
51pub struct EventProfiler<A: EventApi> {
52 open: HashMap<ProfilingToken, Result<Open<A>, ProfileError>>,
53 counter: u64,
54 pool: EventPool<A>,
55 /// Created with the first window rather than with the profiler: a process
56 /// that never profiles should not own a stream and an event for it.
57 anchoring: Option<Anchoring<A>>,
58}
59
60/// A window that has opened and not yet closed.
61struct Open<A: EventApi> {
62 start: Pooled<A>,
63 /// The stream whose next launch records `start`, until one does.
64 waits_for_launch: Option<A::Stream>,
65 /// The anchor this window opened under, kept so that its start and its end
66 /// are placed against the same one however many refreshes happen in
67 /// between.
68 anchor: Anchor<A>,
69}
70
71impl<A: EventApi> EventProfiler<A> {
72 /// Open a window at the current position of `stream`.
73 ///
74 /// # Errors
75 ///
76 /// [`ServerError`] when the device refuses an event or the stream will not
77 /// record one. No window is opened and no token is issued, so the caller
78 /// owes nothing; the events involved return to the pool.
79 pub fn start(&mut self, stream: A::Stream) -> Result<ProfilingToken, ServerError> {
80 let anchor = self.anchor()?;
81 let start = self.pool.acquire()?;
82 start.record(stream)?;
83
84 Ok(self.opened(Open {
85 start,
86 waits_for_launch: None,
87 anchor,
88 }))
89 }
90
91 /// Open a window at the next kernel launched on `stream`, rather than at
92 /// where the stream is now.
93 ///
94 /// What lies between the two is the host preparing that launch. A device
95 /// with nothing queued stamps an event the moment it is recorded, so a
96 /// window opened before the preparation charges it to the kernel: a launch
97 /// timed alone on an idle device read its own host latency as device time.
98 /// The backend records the start right before it launches
99 /// ([`launching`](Self::launching)); a window closed with no launch in it
100 /// records its start as it closes, and reads empty.
101 ///
102 /// # Errors
103 ///
104 /// [`ServerError`] when the device refuses an event. No window is opened.
105 pub fn start_at_launch(&mut self, stream: A::Stream) -> Result<ProfilingToken, ServerError> {
106 let anchor = self.anchor()?;
107 let start = self.pool.acquire()?;
108
109 Ok(self.opened(Open {
110 start,
111 waits_for_launch: Some(stream),
112 anchor,
113 }))
114 }
115
116 /// A kernel is about to be launched on `stream`: every window waiting for
117 /// that launch starts here. Free when no window is open.
118 ///
119 /// A start that cannot be recorded fails its window, not the launch.
120 pub fn launching(&mut self, stream: A::Stream) {
121 for state in self.open.values_mut() {
122 let Ok(open) = state else { continue };
123 if open.waits_for_launch != Some(stream) {
124 continue;
125 }
126 open.waits_for_launch = None;
127 if let Err(err) = open.start.record(stream) {
128 *state = Err(profile_error(err));
129 }
130 }
131 }
132
133 fn opened(&mut self, open: Open<A>) -> ProfilingToken {
134 let token = ProfilingToken { id: self.counter };
135 self.counter += 1;
136 self.open.insert(token, Ok(open));
137 token
138 }
139
140 /// Close the window `token` opened at the current position of `stream`.
141 ///
142 /// Returns at enqueue time. The duration is read from the device by the
143 /// returned future, which is where the wait for the window's work lives.
144 ///
145 /// That future blocks the thread that polls it: the device APIs offer no
146 /// way to be woken when an event is reached, only a synchronize that parks
147 /// the caller, so the first poll returns once the device has reached both
148 /// events. Await it from a thread that can afford to wait — the same place
149 /// a synchronize would have gone.
150 ///
151 /// # Errors
152 ///
153 /// [`ProfileError::NotRegistered`] for a token this profiler never issued
154 /// or has already closed, the error registered against the window by
155 /// [`failure`](Self::failure) when device work failed inside it, and
156 /// [`ProfileError::Server`] when the closing event cannot be recorded.
157 /// There is nothing to measure in any of those cases and the window is
158 /// gone either way.
159 pub fn stop(
160 &mut self,
161 stream: A::Stream,
162 token: ProfilingToken,
163 ) -> Result<ProfileDuration, ProfileError> {
164 let Open {
165 start,
166 waits_for_launch,
167 anchor,
168 } = match self.open.remove(&token) {
169 Some(state) => state?,
170 None => {
171 return Err(ProfileError::NotRegistered {
172 backtrace: BackTrace::capture(),
173 });
174 }
175 };
176 if waits_for_launch.is_some() {
177 // No launch came: the window is empty, and starts where it ends.
178 start.record(stream).map_err(profile_error)?;
179 }
180
181 let end = self.pool.acquire().map_err(profile_error)?;
182 end.record(stream).map_err(profile_error)?;
183
184 Ok(ProfileDuration::new_device_time(async move {
185 read::<A>(&start, &end, &anchor).unwrap_or_else(|err| {
186 log::error!(
187 "Could not read back a {} profiling window ({err}); reporting {UNREADABLE:?} \
188 so nothing mistakes it for a fast one",
189 A::BACKEND
190 );
191 let now = Instant::now();
192 ProfileTicks::from_start_end(now, now + UNREADABLE)
193 })
194 // `start` and `end` are dropped here, which is what returns them
195 // to the pool.
196 }))
197 }
198
199 /// Drop the window `token` opened without measuring it, for a caller that
200 /// has no way to record its end.
201 pub fn abandon(&mut self, token: ProfilingToken) {
202 self.open.remove(&token);
203 }
204
205 /// Mark every open window invalid because device work failed.
206 ///
207 /// This is what keeps a tuning candidate that failed from benchmarking at
208 /// close to zero and winning the tune. A no-op with no window open, so a
209 /// failure path calls it unconditionally and pays nothing for the common
210 /// case of no measurement in flight.
211 pub fn failure(&mut self, error: &ServerError) {
212 if self.open.is_empty() {
213 return;
214 }
215
216 let error = ProfileError::from(error);
217 self.open
218 .values_mut()
219 .for_each(|state| *state = Err(error.clone()));
220 }
221
222 /// The anchor a window opening now measures against, refreshed when the
223 /// current one has aged past [`ANCHOR_MAX_AGE`].
224 fn anchor(&mut self) -> Result<Anchor<A>, DriverError> {
225 if self.anchoring.is_none() {
226 self.anchoring = Some(Anchoring::new(&self.pool)?);
227 }
228 let anchoring = self.anchoring.as_mut().expect("filled right above");
229
230 if anchoring.current.instant.elapsed() > ANCHOR_MAX_AGE {
231 anchoring.current = Anchor::take(anchoring.stream, &self.pool)?;
232 }
233
234 Ok(anchoring.current.clone())
235 }
236}
237
238impl<A: EventApi> Default for EventProfiler<A> {
239 fn default() -> Self {
240 Self {
241 open: HashMap::default(),
242 counter: 0,
243 pool: EventPool::default(),
244 anchoring: None,
245 }
246 }
247}
248
249impl<A: EventApi> core::fmt::Debug for EventProfiler<A> {
250 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
251 f.debug_struct("EventProfiler")
252 .field("backend", &A::BACKEND)
253 .field("open", &self.open.len())
254 .field("anchored", &self.anchoring.is_some())
255 .finish()
256 }
257}
258
259/// Read a closed window back from the device.
260fn read<A: EventApi>(
261 start: &Event<A>,
262 end: &Event<A>,
263 anchor: &Anchor<A>,
264) -> Result<ProfileTicks, DriverError> {
265 // Both, rather than the end alone. The end implies the start only because
266 // the two are recorded on the same stream, and waiting on an event the
267 // device has already passed returns immediately — a free way to stop
268 // depending on that.
269 start.wait()?;
270 end.wait()?;
271
272 // The span is measured between the window's own two events rather than as
273 // the difference of two anchor offsets. A second out from the anchor, two
274 // `f32` millisecond readings cancel down to some 60 ns of noise — a percent
275 // of a small kernel; between the two events themselves the reading is as
276 // exact as the device clock.
277 let offset = anchor.event.elapsed(start)?;
278 let span = start.elapsed(end)?;
279
280 let start_instant = anchor.instant + offset;
281 Ok(ProfileTicks::from_start_end(
282 start_instant,
283 start_instant + span,
284 ))
285}
286
287/// A point where the device clock and the host clock were read together.
288///
289/// An elapsed-time call measures from one event to another and knows nothing of
290/// the host clock, so placing a window on the host timeline takes a third event
291/// whose host time is known.
292struct Anchor<A: EventApi> {
293 /// Shared: a window that has not been read back yet still measures against
294 /// the anchor it opened under, which the profiler may have replaced since.
295 event: Arc<Pooled<A>>,
296 instant: Instant,
297}
298
299impl<A: EventApi> Anchor<A> {
300 /// Record an event on `stream` and wait for it, so the host time taken
301 /// right afterwards is the time the device stamped it.
302 fn take(stream: A::Stream, pool: &EventPool<A>) -> Result<Self, DriverError> {
303 let event = pool.acquire()?;
304 event.record(stream)?;
305 event.wait()?;
306
307 Ok(Self {
308 event: Arc::new(event),
309 instant: Instant::now(),
310 })
311 }
312}
313
314impl<A: EventApi> Clone for Anchor<A> {
315 fn clone(&self) -> Self {
316 Self {
317 event: self.event.clone(),
318 instant: self.instant,
319 }
320 }
321}
322
323/// The current [`Anchor`] and the stream it is recorded on.
324struct Anchoring<A: EventApi> {
325 /// A stream of its own, used for nothing but anchoring. Anchoring waits for
326 /// its event, and an anchor recorded into a working stream would wait for
327 /// everything queued ahead of it — exactly the drain this profiler exists
328 /// to avoid.
329 stream: A::Stream,
330 current: Anchor<A>,
331}
332
333impl<A: EventApi> Anchoring<A> {
334 fn new(pool: &EventPool<A>) -> Result<Self, DriverError> {
335 let stream = A::stream_create_non_blocking()?;
336 let current = Anchor::take(stream, pool)?;
337
338 Ok(Self { stream, current })
339 }
340}
341
342impl<A: EventApi> Drop for Anchoring<A> {
343 fn drop(&mut self) {
344 if let Err(err) = A::stream_destroy(self.stream) {
345 log::warn!(
346 "Failed to release the {} profiling anchor stream: {err}",
347 A::BACKEND
348 );
349 }
350 }
351}
352
353/// A driver failure while setting a window up, as the error a profile reports.
354fn profile_error(error: DriverError) -> ProfileError {
355 ProfileError::Server(Box::new(ServerError::from(error)))
356}