cubek_convolution/kernels/forward/depthwise.rs
1//! Depthwise convolution, as a direct client of the tile DSL.
2//!
3//! Every accelerated convolution routine in this crate refuses `groups != 1`, so a depthwise
4//! layer autotunes against a single surviving candidate and never reaches an accelerated path at
5//! all. This is the missing one, and it is deliberately *not* built on the blueprint/routine
6//! machinery the rest of `kernels::forward` uses: a depthwise convolution is not a contraction
7//! over channels, so a stage hierarchy sized for one has nothing to size here.
8//!
9//! What it is instead: a dense convolution contracts input channels into output channels, so the
10//! channel pair appears in the two operands and not in the accumulator. A depthwise one has no
11//! such pairing — each channel carries its own filter and reaches exactly one output channel — so
12//! a single channel axis appears in *all three* operands. That makes it a batch axis, and the
13//! contraction is over the window taps alone. Written that way it is a space and a projection,
14//! and `Tile::mma` is the whole body.
15//!
16//! Everything about the layout follows from that one axis. Channels stay innermost (NHWC) and are
17//! what the units are spent on, so consecutive units read consecutive channels of one pixel and
18//! the read coalesces; and they are what every operand is *lined* along, so one instruction moves
19//! a unit's whole cell. A depthwise pass has too little arithmetic per byte to be anything but
20//! bandwidth-bound, and both of those are what let it reach the bandwidth.
21
22use cubecl::{
23 prelude::*,
24 server::LaunchError,
25 zspace::{Shape, Strides},
26};
27use cubek_std::InputBinding;
28use cubek_tile::kind::Boundary;
29use cubek_tile::launch::Grid;
30use cubek_tile::layout::PhysicalAxisMap;
31use cubek_tile::*;
32
33use crate::{components::ConvSetupError, launch::ConvolutionArgs};
34
35/// The register block the leaf runs under.
36///
37/// 64 scalars is the register budget, which at four channels to a line is the same sixteen cells
38/// every tiling here blocks into. The edge split earns its second copy of the walk because a
39/// window this wide leaves most instances clear of the padded border, and they should not pay a
40/// guard for the few that straddle it. Unit fan-out does not: the lines run along the channel,
41/// not along `K`.
42const REGISTER_BLOCK: RegisterBlock = RegisterBlock::new(64).split_edge();
43
44// Output positions, the channel axis every operand shares, and the window taps.
45const B: Axis = Axis(0);
46const OH: Axis = Axis(1);
47const OW: Axis = Axis(2);
48const C: Axis = Axis(3);
49const RH: Axis = Axis(4);
50const RW: Axis = Axis(5);
51
52/// What a partitioning over these axes prints as ([`Partitioning::labelled`]): an [`Axis`] is an
53/// index, and only the kernel that assigned it knows what it stands for.
54///
55/// Only tests print one today. It widens when a caller does.
56#[cfg(test)]
57const LABELS: [(Axis, &str); 6] = [
58 (B, "b"),
59 (OH, "oh"),
60 (OW, "ow"),
61 (C, "c"),
62 (RH, "rh"),
63 (RW, "rw"),
64];
65
66/// The space one launch runs over, in the terms the kernel builds it from: the problem's
67/// extents and the tiling. The kernel's comptime argument, so the space the launch sizes its
68/// grid from is the space the kernel walks.
69#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
70pub struct DepthwiseSpace {
71 b: usize,
72 oh: usize,
73 ow: usize,
74 c: usize,
75 rh: usize,
76 rw: usize,
77 rows: usize,
78 cols: usize,
79 tile_c: usize,
80 width: usize,
81 plane_size: usize,
82}
83
84impl DepthwiseSpace {
85 /// The space's axes and their extents, every one static.
86 pub fn extents(&self) -> Vec<(Axis, usize)> {
87 vec![
88 (B, self.b),
89 (OH, self.oh),
90 (OW, self.ow),
91 (C, self.c),
92 (RH, self.rh),
93 (RW, self.rw),
94 ]
95 }
96
97 /// Three levels, outermost first. The first separates the output across the launch grid: an
98 /// all-`sequential` level would put the whole convolution in one instance, which is a
99 /// correct kernel and a useless one. The next two separate what one cube took across the
100 /// cube's own threads: rows go to planes, channels to units. The taps stay whole
101 /// throughout: they are the contraction, and every tap of one output position accumulates
102 /// into the same register.
103 /// The levels, stated **from the leaf up, in counts**: one unit's channel line by the
104 /// cube's columns by one row; the plane's units across channels, in turns; the channel lines
105 /// a unit holds past its first, where the tile is wider than the plane; the cube's rows
106 /// across its planes; and a cube per box, the taps whole. The channel axis takes `X` so
107 /// that the fastest-moving cube index is the one memory is contiguous along.
108 ///
109 /// Round-robin across the units, so a unit holding several channel lines takes every
110 /// `plane_size`-th rather than a contiguous run: a contiguous run puts a stride between
111 /// what neighbouring units read and breaks the coalescing the whole NHWC layout is for.
112 /// Columns stay whole: they are the register block, not a split. The walk over a unit's
113 /// further lines is stated only where there are any — the old units level was that walk
114 /// as well, its length found by dividing the tile.
115 pub fn levels(&self) -> Vec<Level> {
116 let Self {
117 rows,
118 cols,
119 tile_c,
120 width,
121 plane_size,
122 ..
123 } = *self;
124 let plane_c = width * plane_size;
125 assert!(
126 tile_c.is_multiple_of(plane_c),
127 "DepthwiseSpace: {plane_size} units of {width} channels do not divide a tile of {tile_c}"
128 );
129 let plane_units = Levels::leaf(&[(C, width), (OW, cols), (OH, 1)])
130 .units(&[(C, plane_size)])
131 .interleaved(C);
132 let lines = match tile_c / plane_c {
133 1 => plane_units,
134 further => plane_units.walk(&[(C, further)]),
135 };
136 lines
137 .planes(&[(OH, rows)])
138 .cubes(&[C, OW, OH])
139 .batches(&[B])
140 .build()
141 }
142
143 pub fn space(&self) -> Space {
144 Space::new(&self.extents())
145 }
146
147 /// The space with the levels that cut it: what the leaf and the overhangs are read off.
148 pub fn partitioning(&self) -> Partitioning {
149 Partitioning::new(self.space(), self.levels())
150 }
151
152 /// The grid this launch runs on: channels on `X`, columns on `Y`, rows and batches on `Z`,
153 /// a plane per row of the cube.
154 pub fn grid(&self) -> (CubeCount, CubeDim) {
155 (
156 CubeCount::Static(
157 self.c.div_ceil(self.tile_c) as u32,
158 self.ow.div_ceil(self.cols) as u32,
159 (self.oh.div_ceil(self.rows) * self.b) as u32,
160 ),
161 CubeDim::new_2d(self.plane_size as u32, self.rows as u32),
162 )
163 }
164}
165
166/// `out[b, oh, ow, c] = Σ_{rh, rw} w[rh, rw, c] · input[b, oh*sh + rh*dh - ph, ow*sw + rw*dw - pw,
167/// c]`
168///
169/// The same leaf the dense convolution runs. `C` being one of the accumulator's own axes is what
170/// makes it batched rather than contracted; the leaf reads that off the spaces.
171///
172/// The filter is the *lhs* and the map the rhs, which is not arbitrary: the leaf serves the rhs in
173/// lines along the accumulator's innermost axis, and that axis is the channel. The filter follows
174/// it there (one filter value per channel of the cell), which is what a batched contraction
175/// needs and what `V > 1` is.
176///
177/// Three levels: this cube's box of the output with the taps whole, this plane's row of it, then
178/// this unit's channel lines, whose column block the leaf walks with the whole tap window at
179/// each cell.
180#[cube(launch)]
181fn depthwise_kernel<E: Numeric, V: Size>(
182 weight: &TileArg<'_, E, V>,
183 input: &TileArg<'_, E, V>,
184 out: &TileArg<'_, E, V>,
185 space: Partitioning,
186 #[define(E)] _dtype: ElemType,
187) {
188 let weight = weight.tile(comptime!(space.clone()));
189 let input = input.tile(comptime!(space.clone()));
190 let out = out.tile(comptime!(space.clone()));
191
192 for cube in space {
193 let out = out.at(&cube);
194 let weight = weight.at(&cube);
195 let input = input.at(&cube);
196 for plane in cube {
197 // The plane's units, under its walk over channel lines where a unit holds several.
198 for unit in plane.leaves() {
199 let mut out = out
200 .at(&unit)
201 .accumulating(REGISTER_BLOCK, Semiring::SUM_PROD);
202 out.mm(&weight.at(&unit), &input.at(&unit));
203 }
204 }
205 }
206}
207
208/// How one cube's share of the output is shaped, and how the cube's threads divide it.
209///
210/// The three numbers are three different jobs, which is why they are not one "tile size":
211///
212/// - `rows` is the plane count. One plane per output row, so it is what fills the cube.
213/// - `cols` is the accumulator block one unit keeps in registers. Every column of it re-reads
214/// the same filter and overlapping input, so it is what amortises both.
215/// - `chans` is how many channel *lines* one unit owns. The lines are distributed to the units
216/// interleaved, so whatever this is, consecutive units still read consecutive channels and the
217/// read coalesces.
218/// - `lines` is how many channels one of those lines covers — the width every operand is served
219/// in. It is the one knob that trades the two things a depthwise pass is limited by against
220/// each other, which is why it is stated and not derived: a wider line is fewer instructions
221/// per channel, and also more registers per unit and a wider channel tile, so fewer units with
222/// anything to do when the block is narrow. It is a ceiling, not a demand — the launch drops to
223/// what the buffers can actually be served in.
224///
225/// The cube's channel tile is `plane_size · lines · chans`.
226#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
227pub struct DepthwiseTiling {
228 pub rows: usize,
229 pub cols: usize,
230 pub chans: usize,
231 pub lines: usize,
232}
233
234impl Default for DepthwiseTiling {
235 /// Four planes of one row each, four output columns per unit, scalar channels.
236 ///
237 /// Small on both spatial axes on purpose: the window overlaps, so a cube's halo is what it
238 /// re-reads, but a *wide* tile is also what pushes its far corner past the padded border and
239 /// costs every instance in it the guarded walk. Four each is where those two meet on the
240 /// shapes an encoder actually runs. The line width is the knob worth deciding per problem,
241 /// which [`for_problem`](Self::for_problem) is.
242 fn default() -> Self {
243 Self {
244 rows: 4,
245 cols: 4,
246 chans: 1,
247 lines: 1,
248 }
249 }
250}
251
252impl DepthwiseTiling {
253 /// A window with at least this many taps re-reads enough of its halo to run out of
254 /// instructions before it runs out of bandwidth, which is the only regime where a wider line
255 /// pays for the registers it costs. A 5x5 window is the first one an encoder runs that
256 /// reaches it.
257 const INSTRUCTION_BOUND_TAPS: usize = 25;
258
259 /// ...and the channel axis has to stay wide enough to fill the grid once a wide line has
260 /// divided its parallelism. Below this many cube-widths of channels, widening starves the
261 /// grid instead of the bus.
262 const WIDE_BLOCK_UNIT_MULTIPLE: usize = 8;
263
264 /// The tiling to run a problem of this shape under.
265 ///
266 /// Only [`lines`](Self::lines) is decided here, and it is close to a single question: is this
267 /// convolution short of instructions or short of bandwidth? A wide line is four times fewer
268 /// instructions per channel, and also four times the registers per unit and a four-times
269 /// wider channel tile. A deep window over a wide block is instruction-bound and takes the
270 /// trade; everything else is already reading memory as fast as the device will read it, and
271 /// pays the registers for nothing.
272 ///
273 /// Both thresholds are where that turnover was observed rather than where a model of the
274 /// hardware puts it, so this is a derivation a device is allowed to disagree with. The
275 /// `depthwise` benchmark catalogue is the instrument that settles it: running its `Fixed`
276 /// entries against `Routine` is what says whether this rule still picks the right line.
277 pub fn for_problem(channels: usize, taps: usize, plane_units: usize) -> Self {
278 let deep_window = taps >= Self::INSTRUCTION_BOUND_TAPS;
279 let wide_block = channels >= Self::WIDE_BLOCK_UNIT_MULTIPLE * plane_units;
280
281 Self {
282 lines: match deep_window && wide_block {
283 true => 4,
284 false => 1,
285 },
286 ..Default::default()
287 }
288 }
289
290 /// Reject a degenerate tiling before its dimensions reach division and space construction.
291 fn validate(self) -> Result<Self, ConvSetupError> {
292 if self.rows == 0 || self.cols == 0 || self.chans == 0 || self.lines == 0 {
293 return Err(ConvSetupError::InvalidConfig(Box::new(format!(
294 "depthwise tiling dimensions must be non-zero, got rows {}, cols {}, chans {}, \
295 lines {}",
296 self.rows, self.cols, self.chans, self.lines
297 ))));
298 }
299 Ok(self)
300 }
301
302 /// The channel edge one cube owns, checked because every factor is public configuration or
303 /// runtime hardware data.
304 fn channel_tile(self, plane_units: usize, width: usize) -> Result<usize, ConvSetupError> {
305 let tile = plane_units
306 .checked_mul(width)
307 .and_then(|tile| tile.checked_mul(self.chans))
308 .ok_or_else(|| {
309 ConvSetupError::InvalidConfig(Box::new(format!(
310 "depthwise channel tile overflows: {plane_units} units * {width} channels/line * {} \
311 lines/unit",
312 self.chans
313 )))
314 })?;
315 if tile == 0 {
316 return Err(ConvSetupError::InvalidConfig(Box::new(format!(
317 "depthwise channel tile must be non-zero, got {plane_units} units * {width} \
318 channels/line * {} lines/unit",
319 self.chans
320 ))));
321 }
322 Ok(tile)
323 }
324
325 /// The space this tiling implies for a problem of these extents, in the form the kernel
326 /// builds it from.
327 fn plan(
328 &self,
329 geometry: &Geometry,
330 plane_units: usize,
331 tile_c: usize,
332 width: usize,
333 ) -> DepthwiseSpace {
334 DepthwiseSpace {
335 b: geometry.b,
336 oh: geometry.oh,
337 ow: geometry.ow,
338 c: geometry.c,
339 rh: geometry.rh,
340 rw: geometry.rw,
341 rows: self.rows,
342 cols: self.cols,
343 tile_c,
344 width,
345 plane_size: plane_units,
346 }
347 }
348}
349
350/// The three tensors this routine moves, named because they are all `TensorBinding` and a
351/// positional triple lets two of them be swapped without a word from the compiler.
352///
353/// NHWC maps, and Burn's `[out_channels, kh, kw, in_channels / groups]` filter — whose trailing
354/// axis is 1 for a depthwise convolution.
355pub struct DepthwiseTensors {
356 pub input: TensorBinding,
357 pub weight: TensorBinding,
358 pub out: TensorBinding,
359}
360
361/// Which tiling to run a problem under.
362///
363/// [`Routine`](Self::Routine) is what ships; [`Fixed`](Self::Fixed) is what the benchmark
364/// catalogue sweeps, and what a test uses to reach a tiling the rule would not pick.
365#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
366pub enum DepthwiseStrategy {
367 /// Decided from the problem, by [`DepthwiseTiling::for_problem`].
368 Routine,
369 /// Stated by the caller.
370 Fixed(DepthwiseTiling),
371}
372
373/// Launch a depthwise convolution under `strategy`.
374///
375/// # Errors
376///
377/// [`ConvSetupError::NotDepthwise`] when the convolution is not one filter per channel — the
378/// tuner reads that as "this candidate does not apply here", which is exactly what it is — and
379/// [`ConvSetupError::InvalidConfig`] when a fixed tiling is degenerate. Returns
380/// [`ConvSetupError::Unknown`] when re-laying the filter cannot be launched.
381pub fn launch_depthwise(
382 client: &Client,
383 tensors: DepthwiseTensors,
384 args: ConvolutionArgs<2>,
385 groups: usize,
386 dtype: ElemType,
387 strategy: DepthwiseStrategy,
388) -> Result<(), ConvSetupError> {
389 let geometry = Geometry::new(&tensors, args, groups)?;
390 let plane_units = plane_units(client);
391 let tiling = match strategy {
392 DepthwiseStrategy::Routine => {
393 DepthwiseTiling::for_problem(geometry.c, geometry.taps(), plane_units)
394 }
395 DepthwiseStrategy::Fixed(tiling) => tiling,
396 }
397 .validate()?;
398 let DepthwiseTensors { input, weight, out } = tensors;
399
400 // The filter, re-laid so the channel is its innermost dim like every other operand's. It has
401 // to be: the leaf serves a cell in lines along the channel, and one filter value broadcast
402 // over a line would give every channel of it the first channel's filter. This is the one
403 // copy the routine makes, and it is the smallest tensor in the problem by three orders of
404 // magnitude — a 1632-channel 5x5 filter is 163 KB against 60 MB of map.
405 let weight = geometry
406 .channels_innermost(client, weight, dtype)
407 .map_err(|_| ConvSetupError::Unknown)?;
408
409 let width = line_width(
410 client,
411 geometry.c,
412 dtype,
413 tiling.lines,
414 &[&input, &weight, &out],
415 );
416 let tile_c = tiling.channel_tile(plane_units, width)?;
417 let plan = tiling.plan(&geometry, plane_units, tile_c, width);
418 let launch = {
419 let partitioning = plan.partitioning();
420 let concrete = partitioning.space().clone();
421 let (cube_count, cube_dim) = plan.grid();
422 Launcher::new(
423 client,
424 partitioning,
425 &concrete,
426 Grid::Stated {
427 cube_count,
428 cube_dim,
429 },
430 )?
431 };
432
433 // A tile that does not divide its axis leaves the last cube short, and a short cube's
434 // terminal tile is still the full comptime size — so the cells past the end are addressed and
435 // have to be guarded. Per axis, because the guard is real work per access and the axes that
436 // need one are rarely the same: a 48x48 map divides evenly by any tile here while a
437 // 24-channel block never fills one plane's width of channels.
438 let ragged_c = !geometry.c.is_multiple_of(tile_c);
439 let ragged_oh = !geometry.oh.is_multiple_of(tiling.rows);
440 let ragged_ow = !geometry.ow.is_multiple_of(tiling.cols);
441 let check_h = geometry.should_check_height_bounds();
442 let check_w = geometry.should_check_width_bounds();
443 let [ph, pw] = geometry.padding;
444 let [sh, sw] = geometry.stride;
445 let [dh, dw] = geometry.dilation;
446
447 // Two gathered physical axes, one per spatial pair, each carrying its padding as the
448 // projection's constant term. Batch and channel ride identity. The channel comes last in the
449 // logical order because that is the axis the operand lines along, and the innermost logical
450 // axis is the one a line covers.
451 let in_spec = TileSpec::new(Projection::new(
452 &[B, OH, OW, RH, RW, C],
453 &[
454 PhysicalAxisMap::of(B),
455 PhysicalAxisMap::affine(&[(OH, sh), (RH, dh)]).shifted(-(ph as isize)),
456 PhysicalAxisMap::affine(&[(OW, sw), (RW, dw)]).shifted(-(pw as isize)),
457 PhysicalAxisMap::of(C),
458 ],
459 ))
460 // A padded border can be represented by either the beginning padding in the projection or
461 // the output binding extending far enough for the final window to overhang the input. Guard
462 // from the actual first and last accessed coordinates so end-only padding is covered too.
463 .boundaries(&[
464 None,
465 guard(check_h || ragged_oh),
466 guard(check_w || ragged_ow),
467 guard(ragged_c),
468 ]);
469
470 // Read in place rather than staged. A shared-memory stage costs a cooperative fill and a
471 // sync per cube, and a deep block — many channels over few output positions — has too few
472 // output positions to amortise either; only the widest spatial shapes have enough.
473 let w_spec = TileSpec::direct(&[RH, RW, C]).boundaries(&[None, None, guard(ragged_c)]);
474 let out_spec = TileSpec::direct(&[B, OH, OW, C]).boundaries(&[
475 None,
476 guard(ragged_oh),
477 guard(ragged_ow),
478 guard(ragged_c),
479 ]);
480
481 depthwise_kernel::launch(
482 client,
483 launch.cube_count(),
484 launch.cube_dim(),
485 width,
486 TileArgLaunch::new(weight.into_tensor_arg(), w_spec),
487 TileArgLaunch::new(input.into_tensor_arg(), in_spec),
488 TileArgLaunch::new(out.into_tensor_arg(), out_spec),
489 launch.partitioning_arg(),
490 dtype,
491 );
492
493 Ok(())
494}
495
496/// The problem, in the terms the space is built from. NHWC throughout.
497struct Geometry {
498 b: usize,
499 ih: usize,
500 iw: usize,
501 oh: usize,
502 ow: usize,
503 c: usize,
504 rh: usize,
505 rw: usize,
506 stride: [usize; 2],
507 padding: [usize; 2],
508 dilation: [usize; 2],
509}
510
511impl Geometry {
512 /// Read the problem off the bindings themselves, so the shapes the space is built from are
513 /// the shapes the kernel will address rather than a second copy that can disagree with them.
514 ///
515 /// # Errors
516 ///
517 /// [`ConvSetupError::NotDepthwise`] when the convolution is not one filter per channel. The
518 /// tuner reads that as "this candidate does not apply here", which is exactly what it is.
519 fn new(
520 tensors: &DepthwiseTensors,
521 args: ConvolutionArgs<2>,
522 groups: usize,
523 ) -> Result<Self, ConvSetupError> {
524 // NHWC throughout: [batch, h, w, channels].
525 let input_channels = tensors.input.shape[3];
526 let output_channels = tensors.out.shape[3];
527 let weight_channels = tensors.weight.shape[0];
528 let weight_group_channels = tensors.weight.shape[3];
529 if groups != input_channels
530 || output_channels != input_channels
531 || weight_channels != input_channels
532 || weight_group_channels != 1
533 {
534 return Err(ConvSetupError::NotDepthwise {
535 groups,
536 input_channels,
537 output_channels,
538 weight_channels,
539 weight_group_channels,
540 });
541 }
542
543 Ok(Self {
544 b: tensors.out.shape[0],
545 ih: tensors.input.shape[1],
546 iw: tensors.input.shape[2],
547 oh: tensors.out.shape[1],
548 ow: tensors.out.shape[2],
549 c: input_channels,
550 // Burn hands weights as [out_channels, kh, kw, in_channels / groups]; depthwise makes
551 // that last axis 1, so the filter is [c, kh, kw] with the channel outermost.
552 rh: tensors.weight.shape[1],
553 rw: tensors.weight.shape[2],
554 stride: args.stride,
555 padding: args.padding,
556 dilation: args.dilation,
557 })
558 }
559
560 /// How many taps one filter has.
561 fn taps(&self) -> usize {
562 self.rh * self.rw
563 }
564
565 fn should_check_height_bounds(&self) -> bool {
566 spatial_bounds_required(
567 self.ih,
568 self.oh,
569 self.rh,
570 self.stride[0],
571 self.padding[0],
572 self.dilation[0],
573 )
574 }
575
576 fn should_check_width_bounds(&self) -> bool {
577 spatial_bounds_required(
578 self.iw,
579 self.ow,
580 self.rw,
581 self.stride[1],
582 self.padding[1],
583 self.dilation[1],
584 )
585 }
586
587 /// The filter as `[kh, kw, c]`, contiguous.
588 ///
589 /// Burn stores it `[c, kh, kw]`, which is the one layout this kernel cannot read: the channel
590 /// has to be the innermost dim for a line to cover a cell's worth of filter. Permuting the
591 /// binding's existing strides re-presents that logical tensor without assuming anything about
592 /// its storage; `into_contiguous` is what makes the new layout physical.
593 fn channels_innermost(
594 &self,
595 client: &Client,
596 weight: TensorBinding,
597 dtype: ElemType,
598 ) -> Result<TensorBinding, LaunchError> {
599 let mut permuted = weight;
600 let channel_stride = permuted.strides[0];
601 let row_stride = permuted.strides[1];
602 let col_stride = permuted.strides[2];
603 permuted.shape = Shape::from(vec![self.rh, self.rw, self.c]);
604 // `[C, kh, kw, 1] -> [kh, kw, C]`. The omitted axis is singleton, so it contributes no
605 // offset; every surviving axis must retain its actual stride for sliced/strided bindings.
606 permuted.strides = Strides::new(&[row_stride, col_stride, channel_stride]);
607
608 Ok(InputBinding::new(permuted, dtype)
609 .into_contiguous(client)?
610 .into_data())
611 }
612}
613
614fn spatial_bounds_required(
615 input_size: usize,
616 output_size: usize,
617 kernel_size: usize,
618 stride: usize,
619 padding: usize,
620 dilation: usize,
621) -> bool {
622 let first = -(padding as i64);
623 let last = (output_size as i64 - 1) * stride as i64
624 + (kernel_size as i64 - 1) * dilation as i64
625 - padding as i64;
626
627 first < 0 || last >= input_size as i64
628}
629
630/// The plane width the channel tile is sized to.
631///
632/// `plane_size_max` deliberately, and it is only safe because this kernel issues no plane
633/// instruction: the leaf is [`Instruction::Registers`], the taps contract into a register rather
634/// than across units, and `planes()`/[`Coverage::PlaneUnits`] here distribute work rather than
635/// cooperate. So the width is a coalescing decision, and a device honouring a narrower one still
636/// gets every unit of the tile from a real thread — `Space::cube_dim` sizes the launch from the
637/// same number.
638///
639/// The moment a plane reduction appears in this kernel that stops being true: wgpu reports a
640/// range on AMD RDNA (32/64) and Intel (8/32), and a reduction sized to the max would cover a
641/// fraction of its row on a device honouring the min.
642fn plane_units(client: &Client) -> usize {
643 client.properties().hardware.plane_size_max as usize
644}
645
646/// The boundary an axis needs, or `None` when every read along it is in bounds by construction.
647fn guard(ragged: bool) -> Option<Boundary> {
648 ragged.then_some(Boundary::Zero)
649}
650
651/// The widest line the channel axis can be served in across all three operands, up to what the
652/// tiling asked for.
653///
654/// Every gate below the request is a fact about the buffers rather than a preference: the channel
655/// must be the contiguous dim, and the width must divide the channel count, since a partial line
656/// has no cell to be.
657fn line_width(
658 client: &Client,
659 channels: usize,
660 dtype: ElemType,
661 requested: usize,
662 operands: &[&TensorBinding],
663) -> usize {
664 if !operands.iter().all(|b| b.strides.last() == Some(&1)) {
665 return 1;
666 }
667
668 client
669 .io_optimized_vector_sizes(dtype.size())
670 .filter(|&v| {
671 v <= requested
672 && channels.is_multiple_of(v)
673 && operands.iter().all(|b| {
674 b.strides[..b.strides.len() - 1]
675 .iter()
676 .all(|&s| s.is_multiple_of(v))
677 })
678 })
679 .max()
680 .unwrap_or(1)
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686
687 /// A `5x5` pass over a `56x56` map of 512 channels, four planes of one row, four output
688 /// columns a unit, on a 32-unit plane. The channel tile is what the caller varies: it is
689 /// `plane_size * width * chans`, and it alone decides whether a unit holds one channel line
690 /// or several.
691 fn plan(width: usize, chans: usize) -> DepthwiseSpace {
692 DepthwiseSpace {
693 b: 2,
694 oh: 56,
695 ow: 56,
696 c: 512,
697 rh: 5,
698 rw: 5,
699 rows: 4,
700 cols: 4,
701 tile_c: 32 * width * chans,
702 width,
703 plane_size: 32,
704 }
705 }
706
707 /// The three levels as a table, leaf up: each row's tile is the row below it times the count
708 /// beside it, so a tiling that stops dividing an axis where it meant to keep going shows up
709 /// as a row that no longer multiplies out. The taps never divide: they are the contraction,
710 /// and every one accumulates into the same register.
711 #[test]
712 fn the_depthwise_routine_states_three_levels() {
713 assert_eq!(
714 plan(1, 1).partitioning().table(&LABELS).to_string(),
715 [
716 " b × oh × ow × c × rh × rw b × oh × ow × c × rh × rw",
717 "",
718 " ◦ · × · × · × · × · × · 1 × 1 × 4 × 1 × 5 × 5",
719 " ▪ 32 units interleaved · × · × · × 32 × · × · 1 × 1 × 4 × 32 × 5 × 5",
720 " ▤ 4 planes a cube · × 4 × · × · × · × · 1 × 4 × 4 × 32 × 5 × 5",
721 " ▣ 6272 cubes 2 × 14 × 14 × 16 × · × · 2 × 56 × 56 × 512 × 5 × 5",
722 "",
723 " └─ count ────────────────┘ └─ tile ──────────────────┘",
724 ]
725 .join("\n")
726 );
727 }
728
729 /// A channel tile wider than one pass of the plane's lines adds a fourth level, which is the
730 /// walk the kernel's `lines_below_the_units` branch runs: the units sit under it, and a unit
731 /// takes every 32nd line rather than a contiguous run.
732 #[test]
733 fn a_unit_holding_several_channel_lines_walks_them() {
734 assert_eq!(
735 plan(4, 2).partitioning().table(&LABELS).to_string(),
736 [
737 " b × oh × ow × c × rh × rw b × oh × ow × c × rh × rw",
738 "",
739 " ◦ · × · × · × · × · × · 1 × 1 × 4 × 4 × 5 × 5",
740 " ▪ 32 units interleaved · × · × · × 32 × · × · 1 × 1 × 4 × 128 × 5 × 5",
741 " ↻ 2 steps · × · × · × 2 × · × · 1 × 1 × 4 × 256 × 5 × 5",
742 " ▤ 4 planes a cube · × 4 × · × · × · × · 1 × 4 × 4 × 256 × 5 × 5",
743 " ▣ 784 cubes 2 × 14 × 14 × 2 × · × · 2 × 56 × 56 × 512 × 5 × 5",
744 "",
745 " └─ count ────────────────┘ └─ tile ──────────────────┘",
746 ]
747 .join("\n")
748 );
749 }
750}