fix!: address fusion and autodiff edge cases (#5400)

* Add common `GraphIr` and refactor burn-router to use it instead

* Add core ONNX export from existing `GraphIr`

* Add graph capture backend

* Add the capture device to burn-dispatch

* Add module ONNX export (static)

* Add dynamic module export and validated parameter bindings

* Cleanup dynamic axes & validation

* Add resnet18 fixture w/ test

* Lower batch norm to ONNX BatchNormalization

* Add interpolate

* Add FullOpIr -> ConstantOfShape support

* Add Full + SliceAssign -> ConstantPad and Concat op

* Add Neg op support

* Update dep to git

* Add OnnxModel w/ save

* boxblur to_device

* make it easier to pass a custom renderer

* add some funcs for grad checkpt

* replace inner() calls with no_grad()

* fix fusion panic

* fusion nhwc relayout fix + unit tests

* Remove stale onnx-export leftovers inherited from base branch

The branch was originally based on the onnx-export branch, whose crate
was since reworked and removed on main. Restore Cargo.toml/Cargo.lock
from main and drop crates/burn-onnx-export so the PR diff only contains
this branch's own changes.

* Remove leftover capture-device wiring from base branch

The CaptureDevice re-export and the Flex<->Capture backend_matrix rows
came from the pre-merge version of the capture backend on the old base
branch; the version merged on main (#5377) does not include them.

* de-claudify

* de-claudify

* renderer signature

* fix: address code review findings

Bound a cached plan's relative shape ids by what the plan's own operations
assigned rather than by the converter's live count. Plans usually fire from
`ExecutionTrigger::OnOperations`, i.e. exactly when later operations are
already queued behind them, and those inflated the count enough to let an
unfitting plan reach `OutputPlanner` and panic. `OperationQueue` now tracks
`shapes_assigned` alongside `relative`.

Also:
- Replace the leftover `[probe]` panic in `OutputPlanner` with a plain `expect`.
- Give `AutodiffDevice` a hand-written `PartialEq` comparing hardware identity
  only, so it agrees with `DispatchDevice`'s own `PartialEq` instead of
  contradicting it on the checkpointing strategy.
- Keep `default_renderer`'s parameters from warning as unused without `tui`.
- Move `BoxBlur`'s kernel on-device instead of round-tripping through the host.
- Drop the mnist example's local debugging `burn.toml`.

* Clarify gradient checkpointing API

---------

Co-authored-by: Guillaume Lagrange <lagrange.guillaume.1@gmail.com>
Co-authored-by: nathaniel <nathaniel.simard.42@gmail.com>
This commit is contained in:
Charles23R
2026-08-21 08:54:41 -04:00
committed by GitHub
parent 498710ba13
commit c2f280a6b8
37 changed files with 438 additions and 79 deletions
+2 -2
View File
@@ -773,7 +773,7 @@ fn gen_tensor_input_dispatch_body(ir: &Extension, op: &Operation) -> TokenStream
// Compute the checkpointing strategy and backend tag in a scoped block so the representative's
// borrow of the inputs ends before the dispatch arms below move them.
let (checkpointing, __burn_backend_tag): (
Option<burn::backend::CheckpointingStrategy>,
Option<burn::backend::GradientCheckpointingStrategy>,
(bool, usize),
) = {
let __repr: &burn::backend::DispatchTensor = (#float_chain)
@@ -1317,7 +1317,7 @@ pub fn derive_extension_type(input: TokenStream) -> TokenStream {
fn map_to_dispatch<F>(
self,
map_kind: F,
checkpointing: Option<burn::backend::CheckpointingStrategy>,
checkpointing: Option<burn::backend::GradientCheckpointingStrategy>,
) -> Self::Target
where
F: Fn(burn::backend::BackendTensor<B>) -> burn::backend::DispatchTensorKind,
@@ -1,7 +1,7 @@
use super::*;
use burn_tensor::{
Device, TensorData,
module::{interpolate, max_pool2d},
module::{adaptive_avg_pool2d, interpolate, max_pool2d},
ops::{InterpolateMode, InterpolateOptions},
};
@@ -173,6 +173,34 @@ fn fusion_test_nhwc_relayout_broadcast_different_shapes() {
);
}
/// A tensor read by both the pool and a later operation must keep its NCHW layout.
///
/// The relayout rewrites where the pooled tensor's elements live so the pool can read it
/// without a copy. That is only sound when the pool is that tensor's last reader.
#[test]
fn fusion_test_nhwc_relayout_shared_input_keeps_nchw_layout() {
let dev: Device = Default::default();
let input = TestTensor::<4>::from_data(
TensorData::from([[[[1.0f32, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]]]),
&dev,
);
dev.sync().unwrap();
// An elementwise op the fuser can absorb, whose result has two readers.
let filtered = input * 2.0;
let pooled = adaptive_avg_pool2d(filtered.clone(), [1, 1]);
let output = filtered * pooled;
// Doubled, the channel means are 5 and 13, so every product is exact in f32.
let expected = TensorData::from([[
[[10.0f32, 20.0], [30.0, 40.0]],
[[130.0, 156.0], [182.0, 208.0]],
]]);
output.into_data().assert_eq(&expected, false);
}
fn seq_input(dev: &Device) -> TestTensor<4> {
let data: Vec<f32> = (0..16).map(|i| i as f32).collect();
TestTensor::from_data(TensorData::new(data, [2, 2, 2, 2]), dev)
+1 -1
View File
@@ -39,7 +39,7 @@ pub trait ExtensionType<B: Backend> {
fn map_to_dispatch<F>(
self,
map_kind: F,
checkpointing: Option<CheckpointingStrategy>,
checkpointing: Option<GradientCheckpointingStrategy>,
) -> Self::Target
where
F: Fn(BackendTensor<B>) -> DispatchTensorKind;
+22
View File
@@ -585,6 +585,28 @@ mod tests {
assert!(!module.weight.require_grad); // stateful
}
/// `valid` on a module already on the inner backend returns it unchanged rather than
/// panicking.
#[test]
fn valid_is_idempotent() {
let device = test_device().autodiff();
let module = SimpleLinear::new(4, 4, &device).valid();
let module = module.valid();
assert!(!module.weight.is_require_grad());
assert!(!module.weight.val().device().is_autodiff());
}
#[test]
fn valid_on_a_plain_device_module_is_no_op() {
let module = SimpleLinear::new(4, 4, &test_device());
let module = module.valid();
assert!(!module.weight.val().device().is_autodiff());
}
#[test]
fn freeze_group_freezes_only_selected_params() {
let device = test_device().autodiff();
@@ -135,7 +135,7 @@ impl<const D: usize, K: Basic> ModuleDisplay for Tensor<D, K> {}
impl<const D: usize, K: Autodiff> AutodiffModule for Tensor<D, K> {
fn valid(&self) -> Self {
self.clone().inner()
self.clone().no_grad()
}
fn from_inner(tensor: Self) -> Self {
+1 -1
View File
@@ -221,7 +221,7 @@ impl<const D: usize> AutodiffModule for RunningState<Tensor<D>> {
self.sync();
let value = self.value();
RunningState::with_id(self.id, value.inner())
RunningState::with_id(self.id, value.no_grad())
}
fn from_inner(module: Self) -> Self {
+3 -3
View File
@@ -317,7 +317,7 @@ impl<const D: usize> AutodiffModule for Param<Tensor<D>> {
// Preserve initialized param `require_grad` state, but reset the inner value's.
// `val()` folds any reparameterization into the base for inference.
let require_grad = self.require_grad;
let mut param = Param::initialized(self.id, self.val().inner().set_require_grad(false));
let mut param = Param::initialized(self.id, self.val().no_grad().set_require_grad(false));
param.require_grad = require_grad;
param
}
@@ -339,7 +339,7 @@ impl<const D: usize> AutodiffModule for Param<Tensor<D>> {
impl<const D: usize> AutodiffModule for Param<Tensor<D, Int>> {
fn valid(&self) -> Self {
Param::initialized(self.id, self.val().inner())
Param::initialized(self.id, self.val().no_grad())
}
fn from_inner(module: Self) -> Self {
@@ -349,7 +349,7 @@ impl<const D: usize> AutodiffModule for Param<Tensor<D, Int>> {
impl<const D: usize> AutodiffModule for Param<Tensor<D, Bool>> {
fn valid(&self) -> Self {
Param::initialized(self.id, self.val().inner())
Param::initialized(self.id, self.val().no_grad())
}
fn from_inner(module: Self) -> Self {
@@ -204,7 +204,9 @@ impl<'a, R: Runtime> OutputPlanner<'a, R> {
let pos = plan.runtime_layouts.len();
let mut shape_global = shape.clone();
for (i, s) in shape.iter().enumerate() {
shape_global[i] = *context.shapes_relative2global.get(s).unwrap();
shape_global[i] = *context.shapes_relative2global.get(s).expect(
"reference shape ids to be assigned by the running stream",
);
}
let strides = strides_dyn_rank(&shape_global);
@@ -28,6 +28,16 @@ pub struct FuseTrace {
pub resources: FuseResources,
}
impl FuseTrace {
/// The highest relative shape id any of this trace's blocks names as its reference shape.
pub fn max_relative_shape_id(&self) -> Option<usize> {
self.blocks
.iter()
.flat_map(|block| block.shape_ref.iter().copied())
.max()
}
}
impl core::fmt::Display for FuseTrace {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "FuseTrace")?;
@@ -22,6 +22,12 @@ pub trait FusedOperation<R: Runtime>: Send + 'static {
/// The number of operations fused.
fn num_ops_fused(&self) -> usize;
/// The highest relative shape id this operation's traces name, if any. See
/// [`NumOperations::max_relative_shape_id`](burn_fusion::NumOperations::max_relative_shape_id).
fn max_relative_shape_id(&self) -> Option<usize> {
None
}
/// Run the fused operation. `fallback` builds the unfused operation at
/// the given index within the segment, for implementations that need to
/// run part of it unfused (autotune fallbacks).
@@ -90,6 +96,10 @@ impl<R: Runtime> burn_fusion::NumOperations for CubeOptimization<R> {
self.num_ops_fused()
}
fn max_relative_shape_id(&self) -> Option<usize> {
self.optimization.max_relative_shape_id()
}
fn name(&self) -> &'static str {
Self::name(self)
}
@@ -101,6 +111,7 @@ impl<R: Runtime> burn_fusion::NumOperations for CubeOptimization<R> {
trait DynFusedOperation<R: Runtime>: Send {
fn name(&self) -> &'static str;
fn num_ops_fused(&self) -> usize;
fn max_relative_shape_id(&self) -> Option<usize>;
fn run(
&mut self,
context: &mut Context<CubeFusionHandle<R>>,
@@ -118,6 +129,10 @@ impl<R: Runtime, O: FusedOperation<R>> DynFusedOperation<R> for O {
FusedOperation::num_ops_fused(self)
}
fn max_relative_shape_id(&self) -> Option<usize> {
FusedOperation::max_relative_shape_id(self)
}
fn run(
&mut self,
context: &mut Context<CubeFusionHandle<R>>,
@@ -145,6 +145,10 @@ fn elemwise_fuse(
pub const NAME: &str = "ElementWise";
impl<R: Runtime> FusedOperation<R> for ElemwiseOptimization<R> {
fn max_relative_shape_id(&self) -> Option<usize> {
self.trace.max_relative_shape_id()
}
const NAME: &'static str = self::NAME;
type State = ElemwiseOptimizationState;
@@ -712,6 +712,16 @@ fn launch_inner_fix_dtype<R: Runtime, A: BatchMatmulRoutine<()>>(
pub const NAME: &str = "Matmul";
impl<R: Runtime> FusedOperation<R> for MatmulOptimization<R> {
fn max_relative_shape_id(&self) -> Option<usize> {
[
self.info.trace.max_relative_shape_id(),
self.info.trace_fallback.max_relative_shape_id(),
]
.into_iter()
.flatten()
.max()
}
const NAME: &'static str = self::NAME;
type State = MatmulOptimizationState;
@@ -6,7 +6,7 @@ use crate::{
optim::{CubeOptimization, nhwc_relayout::optimization::NHWCRelayoutOptimization},
};
use burn_fusion::{FuserProperties, FuserStatus, OperationFuser};
use burn_ir::{ModuleOperationIr, OperationIr, TensorIr};
use burn_ir::{ModuleOperationIr, OperationIr, TensorIr, TensorStatus};
use burn_std::Shape;
use cubecl::Runtime;
@@ -88,7 +88,12 @@ impl<R: Runtime> OperationFuser<CubeOptimization<R>> for NHWCRelayoutFuser<R> {
}
match operation {
OperationIr::Module(ir) if let Some(tensor) = nhwc_relayout_tensor(ir) => {
// A `ReadOnly` tensor cannot be a relayout candidate: another consumer
// is still expecting NCHW.
OperationIr::Module(ir)
if let Some(tensor) = nhwc_relayout_tensor(ir)
&& tensor.status == TensorStatus::ReadWrite =>
{
self.op = Some(operation.clone());
self.fuser
.output_nhwc_layout(tensor, permutation(tensor.shape.num_dims()));
@@ -67,6 +67,10 @@ impl<R: Runtime> NHWCRelayoutOptimization<R> {
pub const NAME: &str = "NHWCRelayout";
impl<R: Runtime> FusedOperation<R> for NHWCRelayoutOptimization<R> {
fn max_relative_shape_id(&self) -> Option<usize> {
self.trace.max_relative_shape_id()
}
const NAME: &'static str = self::NAME;
type State = RelayoutOptimizationState;
@@ -530,7 +530,25 @@ pub fn reduce_kernel_fused<In: Numeric, SizeIn: Size, Out: Numeric, SizeOut: Siz
/// Name of the reduce fusion optimization.
pub const NAME: &str = "Reduce";
impl<R: Runtime> ReduceOptimizationInfo<R> {
/// The highest relative shape id across this reduce's trace and both of its fallbacks.
pub(crate) fn max_relative_shape_id(&self) -> Option<usize> {
[
self.trace.max_relative_shape_id(),
self.trace_read_fallback.max_relative_shape_id(),
self.trace_write_fallback.max_relative_shape_id(),
]
.into_iter()
.flatten()
.max()
}
}
impl<R: Runtime> FusedOperation<R> for ReduceOptimization<R> {
fn max_relative_shape_id(&self) -> Option<usize> {
self.info.max_relative_shape_id()
}
const NAME: &'static str = self::NAME;
type State = ReduceOptimizationState;
@@ -223,6 +223,27 @@ impl<R: Runtime> ReduceBroadcastedOptimization<R> {
pub const NAME: &str = "ReduceBroadcasted";
impl<R: Runtime> FusedOperation<R> for ReduceBroadcastedOptimization<R> {
fn max_relative_shape_id(&self) -> Option<usize> {
let fallbacks = self
.info
.fallbacks
.iter()
.filter_map(|fallback| match fallback {
ReduceBlockOptimInfo::Reduce(info) => info.max_relative_shape_id(),
ReduceBlockOptimInfo::Elemwise(opt) => {
FusedOperation::<R>::max_relative_shape_id(opt.as_ref())
}
});
self.info
.broadcasted
.trace
.max_relative_shape_id()
.into_iter()
.chain(fallbacks)
.max()
}
const NAME: &'static str = self::NAME;
type State = ReduceBroadcastedOptimizationState;
+2 -2
View File
@@ -708,7 +708,7 @@ impl AutodiffBackend for Dispatch {
let checkpointing = if let Some(strategy) = checkpointing {
Some(strategy)
} else {
Some(crate::CheckpointingStrategy::None)
Some(crate::GradientCheckpointingStrategy::Disabled)
};
DispatchTensor {
kind,
@@ -773,7 +773,7 @@ impl AutodiffBackend for Dispatch {
let checkpointing = if let Some(strategy) = checkpointing {
Some(strategy)
} else {
Some(crate::CheckpointingStrategy::None)
Some(crate::GradientCheckpointingStrategy::Disabled)
};
DispatchTensor {
kind,
+51 -16
View File
@@ -137,15 +137,36 @@ impl DispatchDevice {
/// A wrapper that enables automatic differentiation for a [`DispatchDevice`].
///
/// Use [`DispatchDevice::autodiff`] to construct this type.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone)]
pub struct AutodiffDevice {
pub(crate) inner: Box<DispatchDevice>,
pub(crate) checkpointing: CheckpointingStrategy,
pub(crate) checkpointing: GradientCheckpointingStrategy,
}
/// Compares on hardware identity only, ignoring the checkpointing strategy, so that this agrees
/// with [`DispatchDevice`]'s own [`PartialEq`] — which has to ignore it, since comparing an
/// `Autodiff` device against a raw one has no strategy to compare against. A derived impl would
/// make `Autodiff(a) == Autodiff(b)` disagree with `DispatchDevice::Autodiff(a) ==
/// DispatchDevice::Autodiff(b)`.
///
/// Use [`gradient_checkpointing_strategy`](Self::gradient_checkpointing_strategy) when the
/// strategy is what you actually need to compare.
#[cfg(feature = "autodiff")]
impl PartialEq for AutodiffDevice {
fn eq(&self, other: &Self) -> bool {
self.inner == other.inner
}
}
#[cfg(feature = "autodiff")]
impl Eq for AutodiffDevice {}
#[cfg(feature = "autodiff")]
impl AutodiffDevice {
pub(crate) fn new(device: DispatchDevice, checkpointing: CheckpointingStrategy) -> Self {
pub(crate) fn new(
device: DispatchDevice,
checkpointing: GradientCheckpointingStrategy,
) -> Self {
Self {
inner: Box::new(device),
checkpointing,
@@ -156,6 +177,11 @@ impl AutodiffDevice {
pub fn inner(self) -> DispatchDevice {
*self.inner
}
/// Returns the gradient checkpointing strategy.
pub fn gradient_checkpointing_strategy(&self) -> GradientCheckpointingStrategy {
self.checkpointing
}
}
#[cfg(feature = "autodiff")]
@@ -170,24 +196,26 @@ impl core::ops::Deref for AutodiffDevice {
#[allow(missing_docs)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
/// Checkpointing strategy for autodiff.
/// Gradient checkpointing strategy for autodiff.
#[repr(u8)]
pub enum CheckpointingStrategy {
pub enum GradientCheckpointingStrategy {
/// Recompute selected activations during backpropagation to reduce peak memory usage.
Balanced,
/// Disable gradient checkpointing while retaining autodiff tracking.
#[default]
None,
Disabled,
}
#[cfg(feature = "autodiff")]
pub(crate) fn validate_checkpointing(
lhs: Option<crate::CheckpointingStrategy>,
rhs: Option<crate::CheckpointingStrategy>,
) -> Option<crate::CheckpointingStrategy> {
lhs: Option<crate::GradientCheckpointingStrategy>,
rhs: Option<crate::GradientCheckpointingStrategy>,
) -> Option<crate::GradientCheckpointingStrategy> {
match (lhs, rhs) {
(Some(lhs), Some(rhs)) => {
assert_eq!(
lhs, rhs,
"Autodiff strategy mismatch: {lhs:?} vs {rhs:?}. Tensors in the same operation must share a strategy."
"Gradient checkpointing strategy mismatch: {lhs:?} vs {rhs:?}. Tensors in the same operation must share a strategy."
);
Some(lhs)
}
@@ -229,7 +257,11 @@ impl core::fmt::Debug for DispatchDevice {
Self::Capture(device) => f.debug_tuple("Capture").field(device).finish(),
#[cfg(feature = "autodiff")]
// Format without `AutodiffDevice` wrapper
Self::Autodiff(device) => f.debug_tuple("Autodiff").field(&device.inner).finish(),
Self::Autodiff(device) => f
.debug_struct("Autodiff")
.field("device", &device.inner)
.field("checkpointing", &device.checkpointing)
.finish(),
}
}
}
@@ -371,7 +403,9 @@ impl PartialEq for DispatchDevice {
match (self, other) {
// If both are Autodiff, compare the inner devices
#[cfg(feature = "autodiff")]
(DispatchDevice::Autodiff(a), DispatchDevice::Autodiff(b)) => a == b,
(DispatchDevice::Autodiff(a), DispatchDevice::Autodiff(b)) => {
a.inner.as_ref() == b.inner.as_ref()
}
// If one is Autodiff, compare it to the raw device
#[cfg(feature = "autodiff")]
(DispatchDevice::Autodiff(a), b) => a.inner.as_ref() == b,
@@ -421,13 +455,14 @@ impl DispatchDevice {
#[cfg(feature = "autodiff")]
/// Creates a new [`DispatchDevice`] with [automatic differentiation](Autodiff) enabled.
pub fn autodiff(device: impl Into<DispatchDevice>) -> DispatchDevice {
Self::autodiff_checkpointed(device, CheckpointingStrategy::None)
Self::autodiff_with_gradient_checkpointing(device, GradientCheckpointingStrategy::Disabled)
}
#[cfg(feature = "autodiff")]
/// Creates a new [`DispatchDevice`] with [automatic differentiation](Autodiff) enabled.
pub fn autodiff_checkpointed(
/// Creates a new [`DispatchDevice`] with automatic differentiation and the provided gradient
/// checkpointing strategy enabled.
pub fn autodiff_with_gradient_checkpointing(
device: impl Into<DispatchDevice>,
checkpointing: CheckpointingStrategy,
checkpointing: GradientCheckpointingStrategy,
) -> DispatchDevice {
let device = device.into();
DispatchDevice::Autodiff(AutodiffDevice::new(device, checkpointing))
+2 -2
View File
@@ -60,14 +60,14 @@ macro_rules! backend_matrix {
macro_rules! with_autodiff_backend {
($Backend:ident, $checkpointing:expr, |$B:ident| $body:expr) => {
match $checkpointing {
Some($crate::CheckpointingStrategy::Balanced) => {
Some($crate::GradientCheckpointingStrategy::Balanced) => {
type $B = $crate::backends::Autodiff<
$crate::backends::$Backend,
burn_autodiff::checkpoint::strategy::BalancedCheckpointing,
>;
$body
}
Some($crate::CheckpointingStrategy::None) => {
Some($crate::GradientCheckpointingStrategy::Disabled) => {
type $B = $crate::backends::Autodiff<
$crate::backends::$Backend,
burn_autodiff::checkpoint::strategy::NoCheckpointing,
+9 -9
View File
@@ -6,7 +6,7 @@ use burn_autodiff::checkpoint::strategy::{
};
use burn_backend::{Backend, BackendTypes, DType, Shape, TensorMetadata};
use crate::CheckpointingStrategy;
use crate::GradientCheckpointingStrategy;
#[cfg(feature = "autodiff")]
use alloc::boxed::Box;
#[cfg(feature = "autodiff")]
@@ -211,7 +211,7 @@ pub struct DispatchTensor {
/// Holds the autodiff checkpointing strategy.
/// - `None`: tensor is not tracked by autodiff
/// - `Some(strategy)`: tensor is tracked by autodiff, and uses the checkpointing `strategy`
pub checkpointing: Option<CheckpointingStrategy>,
pub checkpointing: Option<GradientCheckpointingStrategy>,
}
/// Internal representation of a [`DispatchTensor`].
@@ -477,18 +477,18 @@ impl DispatchTensorKind {
}
#[cfg(feature = "autodiff")]
trait IntoCheckpointingStrategy {
const STRATEGY: CheckpointingStrategy;
trait IntoGradientCheckpointingStrategy {
const STRATEGY: GradientCheckpointingStrategy;
}
#[cfg(feature = "autodiff")]
impl IntoCheckpointingStrategy for NoCheckpointing {
const STRATEGY: CheckpointingStrategy = CheckpointingStrategy::None;
impl IntoGradientCheckpointingStrategy for NoCheckpointing {
const STRATEGY: GradientCheckpointingStrategy = GradientCheckpointingStrategy::Disabled;
}
#[cfg(feature = "autodiff")]
impl IntoCheckpointingStrategy for BalancedCheckpointing {
const STRATEGY: CheckpointingStrategy = CheckpointingStrategy::Balanced;
impl IntoGradientCheckpointingStrategy for BalancedCheckpointing {
const STRATEGY: GradientCheckpointingStrategy = GradientCheckpointingStrategy::Balanced;
}
/// Trait to execute runtime routing conversions between the dynamic dispatch layer and specific backends.
@@ -531,7 +531,7 @@ macro_rules! impl_dispatch_conversion {
}
#[cfg(all($cfg, feature = "autodiff"))]
impl<C: CheckpointStrategy + IntoCheckpointingStrategy>
impl<C: CheckpointStrategy + IntoGradientCheckpointingStrategy>
DispatchKindConversion<Autodiff<$backend, C>> for DispatchTensor
{
fn try_into_backend(
+5
View File
@@ -215,6 +215,11 @@ pub trait NumOperations: core::fmt::Debug {
}
/// The name of the optimization.
fn name(&self) -> &'static str;
/// The highest relative shape id this optimization names, if it names any.
/// `None` means the optimization names no relative shape, so it fits any stream.
fn max_relative_shape_id(&self) -> Option<usize> {
None
}
}
/// The optimization created from a [fuser](OperationFuser).
+9
View File
@@ -147,6 +147,15 @@ pub(crate) trait RelativeOps {
}
impl OperationConverter {
/// How many relative shape ids have been handed out so far.
///
/// Ids are dense from zero, so this is also one past the highest valid id, and therefore
/// what a cached plan's
/// [`max_relative_shape_id`](crate::NumOperations::max_relative_shape_id) has to fit under.
pub(crate) fn num_relative_shapes(&self) -> usize {
self.shapes_relative2global.len()
}
pub(crate) fn clear(&mut self) {
self.tensors_relative2global.clear();
self.tensors_global2relative.clear();
@@ -81,8 +81,12 @@ impl<O: core::fmt::Debug> Policy<O> {
);
}
if let Some((id, _length)) = self.found {
return Action::Execute(id);
if let Some((id, length)) = self.found {
// A plan covering `length` operations cannot be applied to a shorter
// segment.
if length <= operations.len() {
return Action::Execute(id);
}
}
match mode {
@@ -272,6 +276,22 @@ mod tests {
stream::store::{ExecutionPlan, ExecutionStrategy, ExecutionTrigger},
};
use std::ops::Range;
use std::sync::Arc;
#[derive(Debug)]
struct ShapeIds(Option<usize>);
impl crate::NumOperations for ShapeIds {
fn len(&self) -> usize {
1
}
fn name(&self) -> &'static str {
"shape-ids"
}
fn max_relative_shape_id(&self) -> Option<usize> {
self.0
}
}
#[test]
fn given_no_optimization_should_explore() {
@@ -315,6 +335,58 @@ mod tests {
assert_eq!(action, Action::Execute(id_1));
}
#[test]
fn should_not_execute_a_plan_longer_than_the_segment() {
let mut store = ExecutionPlanStore::<()>::default();
let mut policy = Policy::<()>::new();
let stream = TestStream::new(3);
let id = store.add(ExecutionPlan {
operations: stream.operations[0..3].to_vec(),
triggers: vec![ExecutionTrigger::Always],
optimization: BlockOptimization::new(ExecutionStrategy::operations(3), Vec::new()),
});
for operation in stream.operations[0..3].iter() {
policy.update(&store, operation);
}
assert_eq!(
policy.action(&store, &stream.operations[0..3], ExecutionMode::Lazy),
Action::Execute(id),
);
assert_ne!(
policy.action(&store, &stream.operations[0..1], ExecutionMode::Lazy),
Action::Execute(id),
);
}
#[test]
fn strategy_reports_the_highest_relative_shape_id_it_holds() {
let strategy = ExecutionStrategy::Composed(vec![
Box::new(ExecutionStrategy::operations(2)),
Box::new(ExecutionStrategy::Optimization {
opt: ShapeIds(Some(4)),
ordering: Arc::new(vec![0, 1]),
score: 0,
}),
Box::new(ExecutionStrategy::Optimization {
opt: ShapeIds(Some(7)),
ordering: Arc::new(vec![2]),
score: 0,
}),
]);
assert_eq!(strategy.max_relative_shape_id(), Some(7));
}
#[test]
fn operations_only_strategy_names_no_relative_shape_id() {
let strategy = ExecutionStrategy::<ShapeIds>::operations(3);
assert_eq!(strategy.max_relative_shape_id(), None);
}
#[test]
fn given_existing_plan_when_found_trigger_should_execute_plan() {
let mut store = ExecutionPlanStore::default();
@@ -20,6 +20,14 @@ pub struct OperationQueue<R: FusionRuntime> {
/// because we don't need to know the exact values, but they are sufficient to
/// determine which operations can be fused.
pub(crate) relative: Vec<OperationIr>,
/// `shapes_assigned[i]` is [`OperationConverter::num_relative_shapes`] right after
/// `relative[i]` was relativized, so a plan covering the first `n` operations may only
/// name relative shape ids below `shapes_assigned[n - 1]`.
///
/// The converter's live count answers a different question — it covers the *whole* queue,
/// including operations queued after the ones a plan replays — so it cannot be used to
/// bound a plan (see [`execute`](Self::execute)).
pub(crate) shapes_assigned: Vec<usize>,
pub(crate) converter: OperationConverter,
pub(crate) operations: Vec<UnfusedOp<R>>,
pub(crate) variables: HashMap<TensorId, TensorStatus>,
@@ -41,6 +49,7 @@ impl<R: FusionRuntime> OperationQueue<R> {
Self {
global: Vec::new(),
relative: Vec::new(),
shapes_assigned: Vec::new(),
converter: OperationConverter::default(),
operations: Vec::new(),
variables: HashMap::new(),
@@ -88,6 +97,8 @@ impl<R: FusionRuntime> OperationQueue<R> {
}
let relative = global.to_relative(&mut self.converter);
self.relative.push(relative);
self.shapes_assigned
.push(self.converter.num_relative_shapes());
self.global.push(global);
self.operations.push(operation);
}
@@ -1,4 +1,6 @@
use burn_ir::{HandleContainer, TensorStatus};
use burn_std::config::{fusion::FusionLogLevel, log_fusion};
use std::sync::Arc;
use crate::{
FusionRuntime, UnfusedOp,
@@ -22,6 +24,42 @@ impl<R: FusionRuntime> OperationQueue<R> {
stream_id: StreamId,
) {
let plan = store.get_mut_unchecked(id);
// A cached plan may name relative shape ids this stream never assigned. Matching
// on operations therefore does not imply the plan fits. When it does not, run the very
// same operations in submission order instead: always a legal order, just unfused.
//
// The bound is what the plan's own operations assigned, not what the whole queue did:
// plans usually fire from `ExecutionTrigger::OnOperations`, i.e. exactly when later
// operations are already queued behind them, and those would otherwise inflate the
// count enough to let an unfitting plan through.
let len = plan.optimization.ordering.len();
let assigned = match len.checked_sub(1).and_then(|i| self.shapes_assigned.get(i)) {
Some(assigned) => *assigned,
// No operation to bound against: nothing but shape id 0 can be legal.
None => 1,
};
if let Some(max_id) = plan.optimization.strategy.max_relative_shape_id()
&& max_id >= assigned
{
log_fusion(FusionLogLevel::Medium, || {
format!(
"[plan] #{id} needs relative shape id {max_id} but the stream assigned \
{assigned}; running its {len} operations unfused"
)
});
let ordering: Vec<usize> = (0..len).collect();
let mut fallback = BlockOptimization::new(
ExecutionStrategy::Operations {
ordering: Arc::new(ordering.clone()),
},
ordering,
);
self.execute_block_optimization(&mut fallback, handles, stream_id);
return;
}
self.execute_block_optimization(&mut plan.optimization, handles, stream_id);
}
@@ -77,11 +115,14 @@ impl<R: FusionRuntime> OperationQueue<R> {
fn reset_relative(&mut self) {
self.relative.clear();
self.shapes_assigned.clear();
self.converter.clear();
for node in self.global.iter() {
let relative = node.to_relative(&mut self.converter);
self.relative.push(relative);
self.shapes_assigned
.push(self.converter.num_relative_shapes());
}
}
}
@@ -28,6 +28,19 @@ pub(crate) enum ExecutionStrategy<O> {
Composed(Vec<Box<Self>>),
}
impl<O: crate::NumOperations> ExecutionStrategy<O> {
pub(crate) fn max_relative_shape_id(&self) -> Option<usize> {
match self {
Self::Optimization { opt, .. } => opt.max_relative_shape_id(),
Self::Operations { .. } => None,
Self::Composed(items) => items
.iter()
.filter_map(|item| item.max_relative_shape_id())
.max(),
}
}
}
/// The trigger that indicates when to stop exploring.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub(crate) enum ExecutionTrigger {
+5
View File
@@ -34,6 +34,11 @@ where
return;
}
// Prevents `grad_remove` from panicking if one of the tensor is not autodiff.
if !param.is_require_grad() {
return;
}
let Some(grad) = param.val().grad_remove(self.grads) else {
return;
};
+17 -4
View File
@@ -7,6 +7,8 @@ pub use burn_backend::cubecl::{ThroughputKey, ThroughputMode, ThroughputValue};
use burn_backend::{Backend, DeviceOps};
#[allow(unused)]
use burn_dispatch::DispatchDeviceId;
#[cfg(feature = "autodiff")]
use burn_dispatch::GradientCheckpointingStrategy;
use burn_dispatch::{Dispatch, DispatchDevice};
use burn_std::{BoolDType, FloatDType, IntDType, TensorData};
@@ -538,6 +540,19 @@ impl Device {
}
}
/// Returns an autodiff device's gradient checkpointing strategy.
///
/// # Panics
///
/// Panics if autodiff is not enabled on this device.
#[cfg(feature = "autodiff")]
pub fn gradient_checkpointing_strategy(&self) -> GradientCheckpointingStrategy {
match self.as_dispatch() {
DispatchDevice::Autodiff(device) => device.gradient_checkpointing_strategy(),
_ => panic!("Autodiff is not enabled on this device"),
}
}
/// Enables gradient checkpointing on the autodiff device.
///
/// Gradient checkpointing recomputes activations during backpropagation for operations
@@ -558,11 +573,9 @@ impl Device {
pub fn gradient_checkpointing(self) -> Self {
match self.into_dispatch() {
DispatchDevice::Autodiff(device) => {
use burn_dispatch::CheckpointingStrategy;
Self::new(DispatchDevice::autodiff_checkpointed(
Self::new(DispatchDevice::autodiff_with_gradient_checkpointing(
device.inner(),
CheckpointingStrategy::Balanced,
GradientCheckpointingStrategy::Balanced,
))
}
_ => panic!("Autodiff is not enabled on this device"),
+2
View File
@@ -44,6 +44,8 @@ pub(crate) use tensor::check::macros::check;
pub use tensor::*;
// Re-exported types
#[cfg(feature = "autodiff")]
pub use burn_dispatch::GradientCheckpointingStrategy;
pub use burn_std::{
AllocationProperty, Bytes, bf16, f16,
reader::{read_sync, try_read_sync},
+29 -1
View File
@@ -1,11 +1,13 @@
use crate::{Tensor, kind::Autodiff};
#[cfg(feature = "autodiff")]
use crate::ops::BridgeTensor;
use crate::ops::{BridgeKind, BridgeTensor};
#[cfg(feature = "autodiff")]
use burn_backend::AutodiffBackend;
#[cfg(feature = "autodiff")]
use burn_dispatch::Dispatch;
#[cfg(feature = "autodiff")]
use burn_dispatch::GradientCheckpointingStrategy;
#[cfg(feature = "autodiff")]
type AutodiffGradients = <Dispatch as AutodiffBackend>::Gradients;
@@ -132,6 +134,32 @@ impl<const D: usize, K: Autodiff> Tensor<D, K> {
pub fn from_inner(inner: Tensor<D, K>) -> Self {
Self::new(K::from_inner(inner.primitive))
}
/// Sets the autodiff checkpointing strategy carried by this tensor.
///
/// The strategy is normally derived from the device the tensor was created on (see
/// [`Device::gradient_checkpointing`](crate::Device::gradient_checkpointing)); this
/// method overrides it for a single tensor. A tensor carrying a strategy is treated
/// as tracked by autodiff, so this also marks an inner-backend tensor for tracking.
///
/// # Panics
///
/// Operations combining tensors that carry different strategies panic; make sure all
/// operands share the same one.
#[cfg(feature = "autodiff")]
pub fn with_gradient_checkpointing_strategy(
self,
strategy: GradientCheckpointingStrategy,
) -> Self {
let (kind, mut tensor) = self.primitive.into_parts();
tensor.checkpointing = Some(strategy);
Self::new(match kind {
BridgeKind::Bool => BridgeTensor::bool(tensor),
BridgeKind::Int => BridgeTensor::int(tensor),
BridgeKind::Float => BridgeTensor::float(tensor),
BridgeKind::QFloat => BridgeTensor::qfloat(tensor),
})
}
}
// TODO: a lot of the `tensor.inner` / `Tensor::from_inner(...)` are actually scoped to perform some operations
+2 -5
View File
@@ -150,11 +150,8 @@ impl<RLC: RLComponentsTypes + 'static> RLTraining<RLC> {
/// # Arguments
///
/// * `renderer` - The custom renderer.
pub fn renderer<MR>(mut self, renderer: MR) -> Self
where
MR: MetricsRenderer + 'static,
{
self.renderer = Some(Box::new(renderer));
pub fn renderer(mut self, renderer: Box<dyn MetricsRenderer + 'static>) -> Self {
self.renderer = Some(renderer);
self
}
@@ -176,11 +176,8 @@ impl<M: LearnerModel> SupervisedTraining<M> {
/// # Arguments
///
/// * `renderer` - The custom renderer.
pub fn renderer<MR>(mut self, renderer: MR) -> Self
where
MR: MetricsRenderer + 'static,
{
self.renderer = Some(Box::new(renderer));
pub fn renderer(mut self, renderer: Box<dyn MetricsRenderer + 'static>) -> Self {
self.renderer = Some(renderer);
self
}
+3 -4
View File
@@ -20,10 +20,9 @@ use crate::Interrupter;
/// a terminal, or
/// - `CliMetricsRenderer`, when the `tui` feature is not enabled, or `stdout`
/// is not a terminal.
#[allow(unused_variables)]
pub(crate) fn default_renderer(
interuptor: Interrupter,
checkpoint: Option<usize>,
pub fn default_renderer(
#[cfg_attr(not(feature = "tui"), allow(unused_variables))] interuptor: Interrupter,
#[cfg_attr(not(feature = "tui"), allow(unused_variables))] checkpoint: Option<usize>,
) -> Box<dyn MetricsRenderer> {
#[cfg(feature = "tui")]
if std::io::stdout().is_terminal() {
+4 -1
View File
@@ -33,9 +33,12 @@ impl MetricsView<'_> {
let size_other = chunks[0];
let size_metric_numeric = chunks[1];
// TODO: constraints are still hardcoded, but could be computed dynamically. For example,
// the constraints on `size_status` could depend on the number of different progress events
// logged with [`TrainingProgressLogger::log_event_training`].
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Max(5), Constraint::Min(6), Constraint::Max(6)].as_ref())
.constraints([Constraint::Max(5), Constraint::Min(6), Constraint::Max(7)].as_ref())
.split(size_other);
let size_controls = chunks[0];
let size_metric_text = chunks[1];
+5
View File
@@ -105,6 +105,11 @@ impl BoxBlur {
self
}
/// Rebuilds the precomputed kernel on `device`.
pub fn to_device(&mut self, device: &Device) {
self.kernel = self.kernel.clone().to_device(device);
}
fn sample(&self) -> bool {
let mut rng = self.rng.lock();
+1 -1
View File
@@ -121,7 +121,7 @@ pub fn run(device: Device) {
// artifact dir does not need to be provided when log_to_file is false
let training = SupervisedTraining::new("", dataloader_train, dataloader_test)
.num_epochs(config.num_epochs)
.renderer(CustomRenderer {})
.renderer(Box::new(CustomRenderer {}))
.with_application_logger(None);
// can be used to interrupt training
let _interrupter = training.interrupter();
-15
View File
@@ -1,15 +0,0 @@
[profiling]
logger = { log = "info", level = "disabled" }
[autotune]
level = "balanced"
cache = "target"
logger = { file = "/tmp/autotune.log", level = "disabled" }
[compilation]
logger = { level = "disabled" }
cache = "target"
[memory]
logger = { level = "disabled", file = "/tmp/memory.log" }
persistent_memory = "enabled"