burn_fusion/backend.rs
1use crate::{
2 FusionTensor,
3 client::GlobalFusionClient,
4 stream::{Context, OrderedExecution},
5};
6use burn_backend::{
7 Backend, BackendGraph, BackendTypes, DType, DeviceOps, ExecutionError, MemoryPoolUsage,
8 ProfileDuration, ProfileOptions, ProfileToken, SlicedPoolReport, profile_with_tokens,
9 tensor::{BoolTensor, Device, FloatTensor, IntTensor, QuantizedTensor},
10};
11use burn_ir::{BackendIr, HandleContainer, OperationIr, TensorHandle, TensorIr};
12use burn_std::device_handle::CallError;
13use serde::{Serialize, de::DeserializeOwned};
14use std::marker::PhantomData;
15
16/// Get the client for the given device.
17pub fn get_client<B: FusionBackend>(device: &Device<B>) -> Client<B::FusionRuntime> {
18 GlobalFusionClient::load(device)
19}
20
21/// The fusion server could not run a task at all: it panicked, or is gone.
22pub(crate) fn server_error(err: CallError) -> ExecutionError {
23 ExecutionError::with_context(format!("the fusion server failed to run a task: {err:?}"))
24}
25
26/// Enable dynamic operation fusion on a backend that implements [fusion backend](crate::FusionBackend).
27#[derive(Clone, Debug, Default)]
28pub struct Fusion<B: FusionBackend> {
29 _backend: PhantomData<B>,
30}
31
32impl<B: FusionBackend> BackendTypes for Fusion<B> {
33 type Device = B::Device;
34
35 type FloatTensorPrimitive = FusionTensor<B::FusionRuntime>;
36 type IntTensorPrimitive = FusionTensor<B::FusionRuntime>;
37 type BoolTensorPrimitive = FusionTensor<B::FusionRuntime>;
38 type QuantizedTensorPrimitive = FusionTensor<B::FusionRuntime>;
39
40 // Fusion adds nothing to a captured graph: the fused kernels are recorded
41 // on the inner backend's stream, so the graph handle is the inner one.
42 type GraphPrimitive = B::GraphPrimitive;
43}
44
45impl<B: FusionBackend> Backend for Fusion<B> {
46 fn name(device: &Self::Device) -> String {
47 format!("fusion<{}>", B::name(device))
48 }
49
50 fn seed(device: &B::Device, seed: u64) {
51 let client = GlobalFusionClient::<B::FusionRuntime>::load(device);
52 let device = device.clone();
53 client.sync(move || B::seed(&device, seed));
54 }
55
56 fn sync(device: &Self::Device) -> Result<(), ExecutionError> {
57 let client = GlobalFusionClient::<B::FusionRuntime>::load(device);
58 let device = device.clone();
59 client
60 .try_sync(move || B::sync(&device))
61 .map_err(server_error)?
62 }
63
64 fn profile<O: Send + 'static>(
65 device: &Self::Device,
66 options: ProfileOptions,
67 func: impl FnOnce() -> O + Send,
68 ) -> Result<(O, ProfileDuration), ExecutionError> {
69 // The inner backend's closure-bracketed window would open on the
70 // *calling* thread's stream, but fused operations execute on the
71 // fusion server thread and launch on its stream — and only when the
72 // queue decides to. So the window is opened and closed from that
73 // thread instead, as two tasks in order with the registered
74 // operations. Neither touches the queue: what was still queued when
75 // the window opened may run inside it, and what is still queued when
76 // it closes stays out, unless the caller asked for a flush — the
77 // measurement never changes how the queue batches.
78 //
79 // An inner backend with no windows is measured the way it measures
80 // itself: between two syncs, which drain the queue as they go.
81 profile_with_tokens::<Self, O>(device, options, func)
82 }
83
84 fn profile_start(device: &Self::Device) -> Result<Option<ProfileToken>, ExecutionError> {
85 let client = GlobalFusionClient::<B::FusionRuntime>::load(device);
86 let device = device.clone();
87 client
88 .run(move || B::profile_start(&device))
89 .map_err(server_error)?
90 }
91
92 fn profile_end(
93 device: &Self::Device,
94 token: ProfileToken,
95 options: ProfileOptions,
96 ) -> Result<ProfileDuration, ExecutionError> {
97 let client = GlobalFusionClient::<B::FusionRuntime>::load(device);
98 let device = device.clone();
99 // The options go on down: a flush here drains this queue, and the
100 // inner backend's own queue (a remote server's) is its to drain.
101 match options.flushes() {
102 true => client.sync(move || B::profile_end(&device, token, options)),
103 false => client
104 .run(move || B::profile_end(&device, token, options))
105 .map_err(server_error)?,
106 }
107 }
108
109 /// Sent through the queue the window's own marks travelled, so it is
110 /// dropped in order with them — and never flushed: abandoning happens on
111 /// a panic, where draining the queue would run the very work that was
112 /// unwinding out from under it.
113 fn profile_abandon(device: &Self::Device, token: ProfileToken) {
114 let client = GlobalFusionClient::<B::FusionRuntime>::load(device);
115 let device = device.clone();
116 let _ = client.run(move || B::profile_abandon(&device, token));
117 }
118
119 fn ad_enabled(_device: &Self::Device) -> bool {
120 false
121 }
122
123 fn memory_persistent_allocations<
124 Output: Send,
125 Input: Send,
126 Func: Fn(Input) -> Output + Send,
127 >(
128 device: &Self::Device,
129 input: Input,
130 func: Func,
131 ) -> Output {
132 // The inner backend's closure-bracketed toggle would arm the *calling*
133 // thread's stream, but fused operations execute on the fusion server
134 // thread and allocate on its stream. Arm a standing mode from that
135 // thread instead — `sync` drains previously recorded operations
136 // first, keeping them out of the persistent window.
137 //
138 // Note that sync here doesn't call `B::sync`.
139 let client = GlobalFusionClient::<B::FusionRuntime>::load(device);
140 let armed = device.clone();
141 client.sync(move || B::memory_persistent(&armed, true));
142
143 // Record on this thread; the server drains the fused operations onto
144 // its (armed) stream as it goes.
145 let output = func(input);
146
147 // Drain the remaining recording inside the window (`sync` drains
148 // before running its closure), then disarm.
149 let disarmed = device.clone();
150 client.sync(move || B::memory_persistent(&disarmed, false));
151
152 output
153 }
154
155 fn memory_cleanup(device: &Self::Device) {
156 B::memory_cleanup(device)
157 }
158
159 fn memory_pool_report(device: &Self::Device) -> Option<Vec<SlicedPoolReport>> {
160 // Reads the *calling* stream, which is the one the layout was installed
161 // on and the one fused operations allocate from, so it hops exactly as
162 // the install does.
163 let client = GlobalFusionClient::<B::FusionRuntime>::load(device);
164 let device = device.clone();
165
166 client.sync(move || B::memory_pool_report(&device))
167 }
168
169 fn memory_pool_usage(device: &Self::Device) -> Option<MemoryPoolUsage> {
170 // Combined across the runtime's streams, so no hop is needed for the
171 // reading itself — but recorded operations have to be drained first or
172 // they are missing from it.
173 let client = GlobalFusionClient::<B::FusionRuntime>::load(device);
174 let device = device.clone();
175
176 client.sync(move || B::memory_pool_usage(&device))
177 }
178
179 fn staging<'a, Iter>(data: Iter, device: &Self::Device)
180 where
181 Iter: Iterator<Item = &'a mut burn_backend::TensorData>,
182 {
183 B::staging(data, device);
184 }
185
186 fn supports_dtype(device: &Self::Device, dtype: DType) -> bool {
187 B::supports_dtype(device, dtype)
188 }
189
190 fn dtype_usage(device: &Self::Device, dtype: DType) -> burn_backend::DTypeUsageSet {
191 B::dtype_usage(device, dtype)
192 }
193
194 fn device_count(type_id: u16) -> usize {
195 B::device_count(type_id)
196 }
197
198 fn flush(device: &Self::Device) -> Result<(), ExecutionError> {
199 let client = GlobalFusionClient::<B::FusionRuntime>::load(device);
200 let device = device.clone();
201 client
202 .try_sync(move || B::flush(&device))
203 .map_err(server_error)?
204 }
205
206 fn graph_prepare(device: &Self::Device) -> Result<(), ExecutionError> {
207 let client = GlobalFusionClient::<B::FusionRuntime>::load(device);
208 let device = device.clone();
209 // `client.sync` drains the lazy queue and runs the closure without a
210 // device sync — the inner backend then arms persistent allocation.
211 client.sync(move || B::graph_prepare(&device))
212 }
213
214 fn graph_start_capture(device: &Self::Device) -> Result<(), ExecutionError> {
215 let client = GlobalFusionClient::<B::FusionRuntime>::load(device);
216 let device = device.clone();
217 // Drain any pending fused operations so nothing leaks into the capture
218 // window before it opens, then begin capture on the same stream.
219 client.sync(move || B::graph_start_capture(&device))
220 }
221
222 fn graph_stop_capture(device: &Self::Device) -> Result<BackendGraph<Self>, ExecutionError> {
223 let client = GlobalFusionClient::<B::FusionRuntime>::load(device);
224 let device = device.clone();
225 // Drain the capture run's fused kernels onto the captured stream (this
226 // is where they actually launch), then close the capture.
227 client.sync(move || B::graph_stop_capture(&device))
228 }
229
230 unsafe fn graph_replay(
231 device: &Self::Device,
232 graph: &BackendGraph<Self>,
233 ) -> Result<(), ExecutionError> {
234 let client = GlobalFusionClient::<B::FusionRuntime>::load(device);
235 // Drain any queued fused operations onto the stream first (no device
236 // sync), then launch the captured graph after them.
237 client.sync(|| ());
238 // Safety: forwarded verbatim from this method's own contract.
239 unsafe { B::graph_replay(device, graph) }
240 }
241}
242
243/// The status of a [fuser](OperationFuser).
244#[derive(Clone, Debug, Copy, PartialEq, Eq)]
245pub enum FuserStatus {
246 /// No more operations can be fused.
247 Closed,
248 /// More operations can be fused.
249 Open,
250}
251
252/// The properties of a [fuser](OperationFuser).
253#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
254pub struct FuserProperties {
255 /// The score of the optimization, higher is better.
256 pub score: u64,
257 /// If the operation is ready to be executed.
258 pub ready: bool,
259}
260
261/// The fusion operation abstraction allows implementations to fuse many
262/// [tensor operations](OperationIr) into one, improving the performance of the backend.
263///
264///
265/// # Notes
266///
267/// The implementations are free to execute the registered operations the way they want to improve
268/// the speed and efficiency of the computational graph. It doesn't mean that all registered
269/// operations should be fused, but that another way of executing them is more efficient.
270///
271/// Also, it is important to return (FuserStatus::Closed) when no more registered operation can
272/// improve the performance.
273pub trait OperationFuser<O>: Send {
274 /// Register a new [tensor operation](OperationIr).
275 fn fuse(&mut self, operation: &OperationIr);
276 /// Finish the optimization and create a fusion operation.
277 fn finish(&mut self) -> O;
278 /// Reset the state.
279 fn reset(&mut self);
280 /// Return the builder [status](FuserStatus).
281 fn status(&self) -> FuserStatus;
282 /// Return the builder [properties](FuserProperties).
283 fn properties(&self) -> FuserProperties;
284 /// The number of operation fused.
285 fn len(&self) -> usize;
286 /// If no operations are fused.
287 fn is_empty(&self) -> bool {
288 self.len() == 0
289 }
290 /// Clone the optimization builder.
291 fn clone_dyn(&self) -> Box<dyn OperationFuser<O>>;
292}
293
294/// The number of operations contained in the data structure.
295pub trait NumOperations: core::fmt::Debug {
296 /// The number of registered operations.
297 fn len(&self) -> usize;
298 /// If the current optimization is empty.
299 fn is_empty(&self) -> bool {
300 self.len() == 0
301 }
302 /// The name of the optimization.
303 fn name(&self) -> &'static str;
304 /// The highest relative shape id this optimization names, if it names any.
305 /// `None` means the optimization names no relative shape, so it fits any stream.
306 fn max_relative_shape_id(&self) -> Option<usize> {
307 None
308 }
309}
310
311/// The optimization created from a [fuser](OperationFuser).
312pub trait Optimization<R: FusionRuntime>: Send + NumOperations {
313 /// Execute the optimization.
314 fn execute(&mut self, context: &mut Context<R::FusionHandle>, execution: &OrderedExecution<R>);
315
316 /// Returns the state that can be serialized.
317 fn to_state(&self) -> R::OptimizationState;
318 /// Create the optimization from the state.
319 fn from_state(device: &R::FusionDevice, state: R::OptimizationState) -> Self;
320}
321
322/// Type alias for `<R as FusionRuntime>::FusionDevice`.
323pub type FusionDevice<R> = <R as FusionRuntime>::FusionDevice;
324/// Type alias for `<R as FusionRuntime>::FusionHandle`.
325pub type FusionHandle<R> = <R as FusionRuntime>::FusionHandle;
326/// Client alias.
327pub type Client<R> = GlobalFusionClient<R>;
328
329/// Trait that defines a runtime that will benefits from fused operations.
330pub trait FusionRuntime: Send + Sync + Sized + core::fmt::Debug + 'static {
331 /// The state that can be serialized for an optimization.
332 type OptimizationState: Serialize + DeserializeOwned;
333 /// Optimization type for the backend.
334 type Optimization: Optimization<Self>;
335 /// Handle used to store tensor dynamically.
336 type FusionHandle: Clone + Send;
337 /// Device used by the runtime.
338 type FusionDevice: DeviceOps;
339
340 /// The list of fusers that will be used to optimize the computational graph.
341 fn fusers(device: Self::FusionDevice) -> Vec<Box<dyn OperationFuser<Self::Optimization>>>;
342
343 /// Create a cross-stream alias of `handle` to register under a fresh tensor id.
344 ///
345 /// Called by [`MultiStream::tag_shared_view`](crate::stream::MultiStream::tag_shared_view) when
346 /// a tensor is shared from one stream to another. The new handle must be an *independent*
347 /// container entry over the *same* backing buffer, so that consuming one alias (a `ReadWrite`
348 /// last-use that frees its handle) never frees the buffer out from under the other stream.
349 ///
350 /// The default just clones the handle, which is exactly right for local backends whose handle
351 /// is an `Arc`-style refcount over a device buffer — the clone is a new map entry sharing the
352 /// allocation. Backends whose handle is a *remote* resource (the router/remote backend, where
353 /// the handle is a thin id pointing at a server-side tensor) must override this: a bare clone
354 /// keeps the same server id, so all aliases collapse to one server handle and the first consume
355 /// frees it for everyone. Such backends allocate a fresh server id aliasing the same buffer.
356 fn alias_handle(handle: &Self::FusionHandle) -> Self::FusionHandle {
357 handle.clone()
358 }
359
360 /// Reclaim a `ReadWrite` (last-use) handle as the fusion engine drains a block.
361 ///
362 /// The default removes the container entry; the handle's own `Drop` releases the backend
363 /// resource. This is correct for local backends, whose handle is an `Arc`-style buffer refcount.
364 ///
365 /// A runtime whose handle is a *remote* resource must override this. The router/remote backend
366 /// frees a tensor by registering an `OperationIr::Drop`, but a block that ran **already** freed
367 /// it server-side — a replayed op (including any `Drop`) pops its `ReadWrite` inputs there. So
368 /// letting the handle's `Drop` run registers a *second*, redundant `Drop` for the same id (the
369 /// "unfused drop" traffic), and the override suppresses that re-registration.
370 ///
371 /// `ran` is what says whether that holds for *this* operation: a block is drained whether or
372 /// not its operations ran, and one that did not was never replayed, so the server still holds
373 /// its inputs and only the handle's own `Drop` will free them.
374 fn free_handle(
375 handles: &mut HandleContainer<Self::FusionHandle>,
376 tensor: &TensorIr,
377 ran: OperationRan,
378 ) {
379 let _ = ran;
380 handles.free(tensor);
381 }
382}
383
384/// Whether the operation that consumed a tensor ran.
385///
386/// A drained block has usually run everything in it, but an operation whose input held a
387/// [`TensorError`](burn_ir::TensorError) is skipped, and one that panicked stopped part way. Both
388/// are consumed by the queue all the same — the difference is only visible to a backend whose
389/// handles live somewhere the operation never reached.
390#[derive(Clone, Copy, Debug, PartialEq, Eq)]
391pub enum OperationRan {
392 /// It ran to completion.
393 Yes,
394 /// It was skipped, or it failed part way through.
395 No,
396}
397
398/// Trait that allows an existing [backend](Backend) to specify graph optimizations using
399/// [operation fuser](crate::OperationFuser).
400///
401/// Collective operations on the remote/router interpreter path are driven through the
402/// [`DistributedOps`](burn_backend::distributed::DistributedOps) supertrait of [`Backend`], which
403/// every fusion backend satisfies unconditionally.
404pub trait FusionBackend:
405 BackendIr<Handle = FusionHandle<Self::FusionRuntime>, Device = FusionDevice<Self::FusionRuntime>>
406{
407 /// The runtime used for this backend.
408 type FusionRuntime: FusionRuntime;
409
410 /// Cast a float tensor and returns the resulting handle.
411 fn cast_float(tensor: FloatTensor<Self>, dtype: DType) -> Self::Handle;
412
413 /// Pointer to the full precision fusion backend.
414 type FullPrecisionBackend: FusionBackend<FusionRuntime = Self::FusionRuntime>;
415
416 /// Set the standing persistent-allocation mode of the device stream that
417 /// executes fused operations.
418 ///
419 /// `Fusion`'s `memory_persistent_allocations` cannot bracket the inner
420 /// allocations with a closure: fused operations are recorded on the
421 /// calling thread but execute later, on the fusion execution thread and
422 /// its own device stream. The execution thread arms/disarms this standing
423 /// mode instead (see the `Backend` impl). Defaults to a no-op for
424 /// backends without allocation modes.
425 fn memory_persistent(_device: &Self::Device, _enabled: bool) {}
426}
427
428// Fusion implements `BackendIr` to enable router backend usage.
429impl<B: FusionBackend> BackendIr for Fusion<B> {
430 type Handle = FusionTensor<B::FusionRuntime>;
431
432 fn float_tensor(handle: TensorHandle<Self::Handle>) -> FloatTensor<Self> {
433 handle.handle
434 }
435
436 fn int_tensor(handle: TensorHandle<Self::Handle>) -> IntTensor<Self> {
437 handle.handle
438 }
439
440 fn bool_tensor(handle: TensorHandle<Self::Handle>) -> BoolTensor<Self> {
441 handle.handle
442 }
443
444 fn quantized_tensor(handle: TensorHandle<Self::Handle>) -> QuantizedTensor<Self> {
445 handle.handle
446 }
447
448 fn float_tensor_handle(tensor: FloatTensor<Self>) -> Self::Handle {
449 tensor
450 }
451
452 fn int_tensor_handle(tensor: IntTensor<Self>) -> Self::Handle {
453 tensor
454 }
455
456 fn bool_tensor_handle(tensor: BoolTensor<Self>) -> Self::Handle {
457 tensor
458 }
459
460 fn quantized_tensor_handle(tensor: QuantizedTensor<Self>) -> Self::Handle {
461 tensor
462 }
463}