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