自实现深度神经网络中的卷积算子

1. 概述

卷积算子是深度神经网络的核心算子, 为了加深对 卷积算子 的理解, 遂尝试自己用 C/C++ 来实现它, 花了一番功夫, 目前完成了初版且已在 GitHub 上开源, 欢迎使用, Star 和 PR.

https://github.com/quarrying/diyconv

一些说明:
1) 目前版本还不追求运行效率, 且没有完备的参数检查, 且只实现了二维卷积算子.
2) 为了便于使用, 还实现了卷积算子的 Python 接口 (通过 ctypes 调用 C/C++ 生成的库文件).
3) 自实现的卷积算子已与 PyToch 的卷积算子进行了一致性比较, 项目中附测试代码.
4) 项目代码不止包含完整版的卷积算子 (指的是包含 stride, padding, dilation 和 groups 四个超参数的卷积算子), 还包含了非完整版的卷积算子. 读者或许可以通过比较不同版本的卷积算法, 来帮助代码的理解.

2. 实现细节

上面说的非完整版的卷积算子和完整版的卷积算子, 在项目代码中具体指的是:

  • conv2d_with_stride
  • conv2d_with_stride_padding
  • conv2d_with_stride_padding_dilation
  • conv2d_with_stride_padding_dilation_groups

由它们的名字可以看出它们之间的区别, 在详细介绍之前, 先进行函数参数说明

  • input, kernel, output: 输入数据, 卷积核, 输出数据
  • stride_h, stride_w: 卷积步长
  • padding_h_begin, padding_h_end: 上侧填充尺寸, 下侧填充尺寸
  • padding_w_begin, padding_w_end: 左侧填充尺寸, 右侧填充尺寸
  • dilation_h, dilation_w: 膨胀系数
  • groups: 分组数

其中 input, kernel, output 用自定义的结构体 Tensor4f 来表示.

typedef struct Tensor
{
    float* data;
    int    dims[4];
} Tensor4f;

2.1. conv2d_with_stride

conv2d_with_stride 是其他函数的基础, 在此完整列出:

void conv2d_with_stride(
    const Tensor4f* input, const Tensor4f* kernel, Tensor4f* output, 
    int stride_h, int stride_w)
{
    assert (input->dims[0] == output->dims[0]);
    assert (input->dims[1] == kernel->dims[1]);
    assert (kernel->dims[0] == output->dims[1]);

    int batch_size = input->dims[0];
    int in_channels = input->dims[1];
    int input_height = input->dims[2];
    int input_width = input->dims[3];

    int out_channels = kernel->dims[0];
    int kernel_height = kernel->dims[2];
    int kernel_width = kernel->dims[3];

    int output_height = output->dims[2];
    int output_width = output->dims[3];

    int input_numel = in_channels * input_height * input_width;
    int output_numel = out_channels * output_height * output_width;
    int kernel_numel = in_channels * kernel_height * kernel_width;
    for (int batch_idx = 0; batch_idx < batch_size; ++batch_idx)
    {
        int input_offset = batch_idx * input_numel;
        int output_offset = batch_idx * output_numel;
        const float* input_ptr = input->data + input_offset;
        float* output_ptr = output->data + output_offset;
        for (int output_c_idx = 0; output_c_idx < out_channels; ++output_c_idx)
        {
            int kernel_offset = output_c_idx * kernel_numel;
            const float* kernel_ptr = kernel->data + kernel_offset;
            for (int output_h_idx = 0; output_h_idx < output_height; ++output_h_idx)
            {
                for (int output_w_idx = 0; output_w_idx < output_width; ++output_w_idx)
                {
                    float val = 0.0f;
                    for (int kernel_c_idx = 0; kernel_c_idx < in_channels; ++kernel_c_idx)
                    {
                        for (int kernel_h_idx = 0; kernel_h_idx < kernel_height; ++kernel_h_idx)
                        {
                            int input_h_idx = kernel_h_idx + output_h_idx * stride_h;
                            for (int kernel_w_idx = 0; kernel_w_idx < kernel_width; ++kernel_w_idx)
                            {
                                int input_w_idx = kernel_w_idx + output_w_idx * stride_w;
                                int input_idx = 
                                    input_w_idx +
                                    input_h_idx * input_width + 
                                    kernel_c_idx * input_width * input_height;
                                int kernel_idx = 
                                    kernel_w_idx + 
                                    kernel_h_idx * kernel_width + 
                                    kernel_c_idx * kernel_width * kernel_height;
                                val += input_ptr[input_idx] * kernel_ptr[kernel_idx];
                            }
                        }
                    }
                    int output_idx =
                        output_w_idx +
                        output_h_idx * output_width +
                        output_c_idx * output_width * output_height;
                    output_ptr[output_idx] = val;
                }
            }
        }
    }
}

2.2. conv2d_with_stride_padding

可以复用 conv2d_with_stride 函数的代码, 只需将

int input_h_idx = kernel_h_idx + output_h_idx * stride_h;

修改为

int input_h_idx = kernel_h_idx + output_h_idx * stride_h - padding_h_begin;
if ((input_h_idx < 0) || (input_h_idx >= input_height))
{
    continue;
}

且将

int input_w_idx = kernel_w_idx + output_w_idx * stride_w;

修改为

int input_w_idx = kernel_w_idx + output_w_idx * stride_w - padding_w_begin;
if ((input_w_idx < 0) || (input_w_idx >= input_width))
{
    continue;
}

2.3. conv2d_with_stride_padding_dilation

可以复用 conv2d_with_stride_padding 函数的代码, 只需将

int input_h_idx = kernel_h_idx + output_h_idx * stride_h;

修改为

int input_h_idx = kernel_h_idx * dilation_h + output_h_idx * stride_h - padding_h_begin;

且将

int input_w_idx = kernel_w_idx + output_w_idx * stride_w - padding_w_begin;

修改为

int input_w_idx = kernel_w_idx * dilation_w + output_w_idx * stride_w - padding_w_begin;

2.4. conv2d_with_stride_padding_dilation_groups

可以复用 conv2d_with_stride_padding_dilation 函数的代码, 但改动之处比较多, 在此不一一列举, 仅列出关键变动, 其他改动可以参考项目代码.

1) 改动之 1

int kernel_numel = in_channels * kernel_height * kernel_width;

修改为

int kernel_numel = in_channels_per_group * kernel_height * kernel_width;

2) 改动之 2

for (int batch_idx = 0; batch_idx < batch_size; ++batch_idx)

之后新增一层循环

for (int group_idx = 0; group_idx < groups; ++group_idx)

3) 改动之 3

int input_offset = batch_idx * input_numel;
int output_offset = batch_idx * output_numel;

修改为

int input_offset = batch_idx * input_numel + 
                   group_idx * in_channels_per_group * input_height * input_width;
int output_offset = batch_idx * output_numel + 
                    group_idx * out_channels_per_group * output_height * output_width;

4) 改动之 4

int kernel_offset = output_c_idx * kernel_numel;

修改为

int kernel_offset = (output_c_idx + group_idx * out_channels_per_group) * kernel_numel;

5) 改动之 5

for (int output_c_idx = 0; output_c_idx < out_channels; ++output_c_idx)

修改为

for (int output_c_idx = 0; output_c_idx < out_channels_per_group; ++output_c_idx)

6) 改动之 6

for (int kernel_c_idx = 0; kernel_c_idx < in_channels; ++kernel_c_idx)

修改为

for (int kernel_c_idx = 0; kernel_c_idx < in_channels_per_group; ++kernel_c_idx)

笔者还直接借用 Caffe 中的 im2col_cpu 再利用自实现的矩阵乘函数 matmulnp.dot 实现了一版 conv2d_with_stride_padding_dilation_groups, 详见项目, 在此就不赘述了.

2.5. output 的宽高

上面函数中的 output 的内存是事先申请好的, 其宽高可由下面的函数来算得:

def compute_output_size(input_size, kernel_size, stride=1, 
                        padding_begin=0, padding_end=0, dilation=1):
    kernel_extent = dilation * (kernel_size - 1) + 1
    return (input_size - kernel_extent + padding_begin + padding_end) // stride + 1

更新记录

  • 20211225, 发布
版权归属: 采石工
本文链接: https://quarryman.cn/article/20211225
版权声明: 除特别声明外, 文章采用《署名-非商业性使用-相同方式共享 4.0 国际》许可协议.