Skip to main content

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}