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::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}