Skip to main content

cubek_convolution/kernels/forward/
direct.rs

1use cubecl::{
2    calculate_cube_count_elemwise,
3    client::Client,
4    ir::{FloatKind, VectorRegisters},
5    num_traits::Zero,
6    prelude::*,
7    std::tensor::layout::linear::{LinearViewMut, linear_view},
8    std::{FastDivmod, FastDivmodInt},
9    tensor_vector_size_parallel,
10};
11
12use crate::{components::ConvSetupError, launch::ConvolutionArgs};
13
14#[cube]
15fn decompose_linear<I: FastDivmodInt>(pos: I, shape: &Sequence<FastDivmod<I>>) -> (I, Sequence<I>) {
16    let rank = comptime![shape.len()];
17    let mut offs = pos;
18    let mut out = Sequence::new();
19
20    #[unroll]
21    for i in 0..rank {
22        let dim = comptime![rank - i - 1];
23        let (rem, offs_local) = shape.index(dim).div_mod(offs);
24        out.push(offs_local);
25        offs = rem;
26    }
27
28    (offs, out.reversed())
29}
30
31#[derive(CubeLaunch, CubeType, Clone)]
32pub(crate) struct ConvParam {
33    pub stride: u32,
34    pub dilation: u32,
35    pub padding: i32,
36}
37
38#[derive(CubeLaunch, CubeType)]
39struct Conv2dArgs {
40    conv_params: Sequence<ConvParam>,
41    channels_per_group: u32,
42}
43
44#[cube(launch_unchecked, address_type = "dynamic")]
45#[allow(clippy::redundant_closure)]
46fn direct_conv2d_kernel<E: Numeric, NIn: Size, NOut: Size>(
47    input: &Tensor<Vector<E, NIn>>,
48    weight: &Tensor<Vector<E, NIn>>,
49    bias: ComptimeOption<&[Vector<E, NOut>]>,
50    mut output: LinearViewMut<'_, Vector<E, NOut>>,
51    args: Conv2dArgs,
52    shape_out: Sequence<FastDivmod<u32>>,
53    shape_out_c: FastDivmod<u32>,
54    #[comptime] has_padding: bool,
55    #[comptime] accumulate_components: bool,
56    #[comptime] channel_block: usize,
57    #[define(E)] _dtype: ElemType,
58) {
59    if !output.is_in_bounds(ABSOLUTE_POS) {
60        terminate!();
61    }
62
63    let n_spatial = comptime![shape_out.len()];
64
65    let vector_size_out = output.vector_size();
66    let pos = ABSOLUTE_POS * vector_size_out;
67
68    let in_c_per_group = weight.shape(weight.rank() - 1) as u32;
69
70    let (rem, out_c) = shape_out_c.div_mod(pos as u32);
71    let (b, spatial_pos) = decompose_linear(rem, &shape_out);
72
73    let g = out_c / args.channels_per_group;
74    let ic_start = in_c_per_group * g;
75
76    let bias: ComptimeOption<Vector<E, NOut>> =
77        bias.map(|bias| bias[out_c as usize / vector_size_out]);
78    let mut sum = bias.unwrap_or_else(|| Vector::zero());
79
80    let in_offs = b as usize * input.stride(0) + ic_start as usize;
81
82    let stride_oc = weight.stride(0);
83
84    let mut in_shape = Sequence::new();
85    let mut in_strides = Sequence::new();
86    let mut kernel_shape = Sequence::new();
87    let mut kernel_strides = Sequence::new();
88
89    #[unroll]
90    for i in 0..n_spatial {
91        in_shape.push(input.shape(i + 1) as u32);
92        in_strides.push(input.stride(i + 1));
93        kernel_shape.push(weight.shape(i + 1) as u32);
94        kernel_strides.push(weight.stride(i + 1));
95    }
96
97    let weight_offs = out_c as usize * stride_oc;
98
99    let loop_params = LoopParams {
100        out_pos: spatial_pos,
101        in_shape,
102        in_strides,
103        kernel_shape,
104        kernel_strides,
105        conv_params: args.conv_params,
106        in_c_per_group,
107        stride_oc,
108    };
109
110    let vector_size_in = input.vector_size();
111
112    if accumulate_components {
113        comptime!(assert!(
114            vector_size_out.is_multiple_of(channel_block),
115            "a block of {channel_block} channels does not divide the output vector of {vector_size_out}"
116        ));
117
118        #[unroll]
119        for bi in 0..comptime![vector_size_out / channel_block] {
120            let base_v = bi * channel_block;
121            let mut partials = Array::<Vector<E, NIn>>::new(channel_block);
122
123            kernel_loop(
124                input,
125                weight,
126                &mut sum,
127                &mut partials,
128                in_offs,
129                true,
130                weight_offs,
131                &loop_params,
132                0usize,
133                has_padding,
134                accumulate_components,
135                base_v,
136            );
137
138            #[unroll]
139            for j in 0..channel_block {
140                let mut channel = sum.extract(base_v + j);
141
142                #[unroll]
143                for i in 0..vector_size_in {
144                    channel += partials[j].extract(i);
145                }
146
147                sum.insert(base_v + j, channel);
148            }
149        }
150    } else {
151        // Unread: `accumulate_per_step` sums into `sum`, and the loop still takes an accumulator.
152        let mut partials = Array::<Vector<E, NIn>>::new(1usize);
153
154        kernel_loop(
155            input,
156            weight,
157            &mut sum,
158            &mut partials,
159            in_offs,
160            true,
161            weight_offs,
162            &loop_params,
163            0usize,
164            has_padding,
165            accumulate_components,
166            0usize,
167        );
168    }
169
170    output.write(ABSOLUTE_POS, sum);
171}
172
173#[derive(CubeType, Clone)]
174struct LoopParams {
175    out_pos: Sequence<u32>,
176    in_shape: Sequence<u32>,
177    in_strides: Sequence<usize>,
178    kernel_shape: Sequence<u32>,
179    kernel_strides: Sequence<usize>,
180    conv_params: Sequence<ConvParam>,
181
182    in_c_per_group: u32,
183    stride_oc: usize,
184}
185
186#[cube]
187fn kernel_loop<E: Numeric, NIn: Size, NOut: Size>(
188    input: &Tensor<Vector<E, NIn>>,
189    weight: &Tensor<Vector<E, NIn>>,
190    sum: &mut Vector<E, NOut>,
191    partials: &mut Array<Vector<E, NIn>>,
192    in_offs: usize,
193    in_bounds: bool,
194    weight_offs: usize,
195    params: &LoopParams,
196    #[comptime] kernel_dim: usize,
197    #[comptime] has_padding: bool,
198    #[comptime] accumulate_components: bool,
199    #[comptime] base_v: usize,
200) {
201    if comptime![kernel_dim < params.kernel_shape.len()] {
202        let out_idx = *params.out_pos.index(kernel_dim);
203        let conv = params.conv_params.index(kernel_dim);
204        let shape = *params.in_shape.index(kernel_dim);
205        let stride = *params.in_strides.index(kernel_dim);
206        let k_stride = *params.kernel_strides.index(kernel_dim);
207
208        for pos in 0..*params.kernel_shape.index(kernel_dim) {
209            let in_pos = (out_idx * conv.stride + pos * conv.dilation) as i32 - conv.padding;
210            let in_offs = in_offs + in_pos as usize * stride;
211            let weight_offs = weight_offs + pos as usize * k_stride;
212            let mut in_bounds = in_bounds;
213
214            if has_padding {
215                in_bounds &= in_pos >= 0 && (in_pos as u32) < shape;
216            }
217
218            kernel_loop(
219                input,
220                weight,
221                sum,
222                partials,
223                in_offs,
224                in_bounds,
225                weight_offs,
226                params,
227                comptime![kernel_dim + 1],
228                has_padding,
229                accumulate_components,
230                base_v,
231            );
232        }
233    } else {
234        kernel_loop_inner(
235            input,
236            weight,
237            sum,
238            partials,
239            in_offs,
240            in_bounds,
241            weight_offs,
242            params.in_c_per_group,
243            params.stride_oc,
244            accumulate_components,
245            base_v,
246        );
247    }
248}
249
250#[cube]
251fn kernel_loop_inner<E: Numeric, NIn: Size, NOut: Size>(
252    input: &Tensor<Vector<E, NIn>>,
253    weight: &Tensor<Vector<E, NIn>>,
254    sum: &mut Vector<E, NOut>,
255    partials: &mut Array<Vector<E, NIn>>,
256    in_offs: usize,
257    in_bounds: bool,
258    weight_offs: usize,
259    in_c_per_group: u32,
260    stride_oc: usize,
261    #[comptime] accumulate_components: bool,
262    #[comptime] base_v: usize,
263) {
264    if in_bounds {
265        if accumulate_components {
266            accumulate_in_components(
267                input,
268                weight,
269                partials,
270                in_offs,
271                weight_offs,
272                in_c_per_group,
273                stride_oc,
274                base_v,
275            );
276        } else {
277            accumulate_per_step(
278                input,
279                weight,
280                sum,
281                in_offs,
282                weight_offs,
283                in_c_per_group,
284                stride_oc,
285            );
286        }
287    }
288}
289
290/// One input read serves a whole block of output channels, and the block's accumulator outlives
291/// the kernel window, so a channel step costs one input load per block and never a fold.
292#[cube]
293fn accumulate_in_components<E: Numeric, NIn: Size>(
294    input: &Tensor<Vector<E, NIn>>,
295    weight: &Tensor<Vector<E, NIn>>,
296    partials: &mut Array<Vector<E, NIn>>,
297    in_offs: usize,
298    weight_offs: usize,
299    in_c_per_group: u32,
300    stride_oc: usize,
301    #[comptime] base_v: usize,
302) {
303    let vector_size_in = input.vector_size();
304    let block = partials.len();
305
306    for in_c in range_stepped(0, in_c_per_group, vector_size_in as u32) {
307        let val = input[(in_offs + in_c as usize) / vector_size_in];
308
309        #[unroll]
310        for j in 0..block {
311            let weight_offs = weight_offs + (base_v + j) * stride_oc + in_c as usize;
312
313            partials[j] += val * weight[weight_offs / vector_size_in];
314        }
315    }
316}
317
318/// One input read serves every output channel, which is all a one-step channel loop can win.
319#[cube]
320fn accumulate_per_step<E: Numeric, NIn: Size, NOut: Size>(
321    input: &Tensor<Vector<E, NIn>>,
322    weight: &Tensor<Vector<E, NIn>>,
323    sum: &mut Vector<E, NOut>,
324    in_offs: usize,
325    weight_offs: usize,
326    in_c_per_group: u32,
327    stride_oc: usize,
328) {
329    let vector_size_in = input.vector_size();
330    let vector_size_out = sum.vector_size();
331
332    for in_c in range_stepped(0, in_c_per_group, vector_size_in as u32) {
333        let in_pos = in_offs + in_c as usize;
334        let mut weight_pos = weight_offs + in_c as usize;
335
336        let val = input[in_pos / vector_size_in];
337
338        #[unroll]
339        for v in 0..vector_size_out {
340            let weight = weight[weight_pos / vector_size_in];
341            let val = val * weight;
342
343            #[unroll]
344            for i in 0..vector_size_in {
345                sum.insert(v, sum.extract(v) + val.extract(i));
346            }
347            weight_pos += stride_oc;
348        }
349    }
350}
351
352pub struct DirectTensors {
353    pub input: TensorBinding,
354    pub weight: TensorBinding,
355    pub bias: Option<TensorBinding>,
356    pub out: TensorBinding,
357}
358
359/// Runs a direct convolution over `N` spatial dimensions.
360pub fn launch_direct<const N: usize>(
361    client: &Client,
362    tensors: DirectTensors,
363    args: ConvolutionArgs<N>,
364    groups: usize,
365    dtype: ElemType,
366) -> Result<(), ConvSetupError> {
367    let DirectTensors {
368        input,
369        weight,
370        bias,
371        out,
372    } = tensors;
373
374    let rank = input.shape.len();
375    let dim_c = rank - 1;
376
377    let in_shape = &input.shape[1..dim_c];
378    let out_channels = weight.shape[0];
379    let kernel_shape = &weight.shape[1..dim_c];
380    let out_size = &out.shape[1..dim_c];
381
382    let channels_per_group = out_channels / groups;
383    let check_spatial_bounds = should_check_spatial_bounds(in_shape, kernel_shape, out_size, &args);
384
385    // Need custom vector size calculation here to account for the groups division. Need to
386    // vectorize over `channels_per_group` instead.
387    let mut grouped_out_shape = out.shape.clone();
388    grouped_out_shape[dim_c] = channels_per_group;
389    let vector_size_out = tensor_vector_size_parallel(
390        client.io_optimized_vector_sizes(dtype.size()),
391        &grouped_out_shape,
392        &out.strides,
393        dim_c,
394    );
395    // Use channels_per_group instead of in_channels to avoid issues here
396    let vector_size_in = tensor_vector_size_parallel(
397        client.io_optimized_vector_sizes(dtype.size()),
398        &weight.shape,
399        &weight.strides,
400        weight.shape.len() - 1,
401    );
402
403    // Only a single-unit plane pays the dependency chain in full; a wide plane hides it and is
404    // left with the extra input read per output channel. One component is exactly as serial as `sum`.
405    let accumulate_components =
406        client.properties().hardware.plane_size_max == 1 && vector_size_in > 1;
407    let channel_block =
408        VectorRegisters::new(&client.properties().hardware, register_elem_size(dtype))
409            .map_or(1, |registers| {
410                channel_block(registers, vector_size_in, vector_size_out)
411            });
412
413    let shape_out = out.shape[1..dim_c].iter().map(|s| *s as u32).collect();
414    let shape_out_c = out_channels as u32;
415
416    let mut conv_params = SequenceArg::new();
417
418    for i in 0..kernel_shape.len() {
419        conv_params.push(ConvParamLaunch::new(
420            args.stride[i] as u32,
421            args.dilation[i] as u32,
422            args.padding[i] as i32,
423        ));
424    }
425
426    let working_units = out.shape.iter().product::<usize>() / vector_size_out as usize;
427    let cube_dim = CubeDim::new(client, working_units);
428    let cube_count = calculate_cube_count_elemwise(client, working_units, cube_dim);
429
430    let address_type = input
431        .required_address_type(dtype.size())
432        .max(weight.required_address_type(dtype.size()))
433        .max(out.required_address_type(dtype.size()));
434
435    unsafe {
436        direct_conv2d_kernel::launch_unchecked(
437            client,
438            cube_count,
439            cube_dim,
440            address_type,
441            vector_size_in,
442            vector_size_out,
443            input.into_tensor_arg(),
444            weight.into_tensor_arg(),
445            bias.map(|b| b.into_buffer_arg()).into(),
446            linear_view(out),
447            Conv2dArgsLaunch::new(conv_params, channels_per_group as u32),
448            shape_out,
449            shape_out_c,
450            check_spatial_bounds,
451            accumulate_components,
452            channel_block,
453            dtype,
454        )
455    };
456
457    Ok(())
458}
459
460/// How many output channels share one input read. A power of two, so it divides the output vector.
461fn channel_block(
462    registers: VectorRegisters,
463    vector_size_in: usize,
464    vector_size_out: usize,
465) -> usize {
466    // An accumulator that spills turns every add into a load and a store.
467    let operands = registers.count() / 2;
468    let block = registers.vectors_fitting(vector_size_in, operands).max(1);
469
470    (1 << block.ilog2()).min(vector_size_out)
471}
472
473/// Half floats are sized as f32 on every host: the device does not state whether it promotes them.
474fn register_elem_size(dtype: ElemType) -> usize {
475    match dtype {
476        ElemType::Float(FloatKind::F16 | FloatKind::BF16) => size_of::<f32>(),
477        dtype => dtype.size(),
478    }
479}
480
481fn should_check_spatial_bounds<const N: usize>(
482    in_shape: &[usize],
483    kernel_shape: &[usize],
484    out_shape: &[usize],
485    args: &ConvolutionArgs<N>,
486) -> bool {
487    (0..N).any(|dim| {
488        let begin = args.padding[dim] as i64;
489        let first = -begin;
490        let last = (out_shape[dim] as i64 - 1) * args.stride[dim] as i64
491            + (kernel_shape[dim] as i64 - 1) * args.dilation[dim] as i64
492            - begin;
493        first < 0 || last >= in_shape[dim] as i64
494    })
495}
496
497#[cfg(test)]
498mod tests {
499    use cubek_test_utils::hardware::{AVX2, AVX512, NEON};
500
501    use super::*;
502
503    fn avx2(elem_size: usize) -> VectorRegisters {
504        VectorRegisters::new(&AVX2, elem_size).unwrap()
505    }
506
507    fn avx512(elem_size: usize) -> VectorRegisters {
508        VectorRegisters::new(&AVX512, elem_size).unwrap()
509    }
510
511    fn neon(elem_size: usize) -> VectorRegisters {
512        VectorRegisters::new(&NEON, elem_size).unwrap()
513    }
514
515    #[test]
516    fn a_two_register_accumulator_halves_the_block() {
517        assert_eq!(channel_block(avx2(4), 16, 16), 4);
518    }
519
520    #[test]
521    fn a_four_register_accumulator_quarters_the_block() {
522        assert_eq!(channel_block(neon(4), 16, 16), 4);
523    }
524
525    #[test]
526    fn a_register_wide_accumulator_spends_one() {
527        assert_eq!(channel_block(avx2(4), 8, 16), 8);
528        assert_eq!(channel_block(avx512(4), 16, 16), 16);
529    }
530
531    #[test]
532    fn a_narrow_accumulator_still_spends_a_whole_register() {
533        assert_eq!(channel_block(avx2(4), 2, 16), 8);
534    }
535
536    #[test]
537    fn a_half_float_accumulator_spends_f32_registers() {
538        let f16 = register_elem_size(ElemType::Float(FloatKind::F16));
539        assert_eq!(channel_block(avx2(f16), 32, 32), 2);
540        assert_eq!(channel_block(neon(f16), 32, 32), 2);
541    }
542
543    #[test]
544    fn the_block_never_outgrows_the_output_vector() {
545        assert_eq!(channel_block(avx2(4), 8, 2), 2);
546    }
547
548    #[test]
549    fn an_accumulator_wider_than_the_budget_still_gets_a_block_of_one() {
550        assert_eq!(channel_block(avx2(8), 64, 16), 1);
551    }
552}