卷积加速之 im2col + GEMM

im2col + GEMM 优缺点以及适用的场景

  • 优点:

    • 易于理解和实现。

    • 将卷积转换为矩阵乘法后,可以利用高度优化的 BLAS 库(如 cuBLAS)进行计算。

  • 缺点:

    • 增加了内存的使用量,因为需要构造额外的矩阵。

    • 适用性有限,对于较大的核大小或较深的网络,内存占用可能成为瓶颈。

  • cuDNN 实现: cuDNN 提供了基于 im2col 和 GEMM 的卷积实现,特别是在处理较小的核和较浅的网络时效果很好。

下边给到一个 python 的 demo 程序,帮助理解下 im2col 的过程

import numpy as np

def im2col(image, kernel_shape):
    """
    Transform the input image into a matrix suitable for matrix multiplication.
    """
    # Extracting the dimensions
    img_rows, img_cols = image.shape
    kernel_rows, kernel_cols = kernel_shape

    # Output size
    # 当卷积核从图像的最上方开始滑动时,它可以在垂直方向上移动的位置数为 img_rows - kernel_rows
    # 加上初始位置,总共能够产生 img_rows - kernel_rows + 1 个不同的垂直位置,output_cols 同理
    output_rows = img_rows - kernel_rows + 1
    output_cols = img_cols - kernel_cols + 1

    # Create the im2col matrix
    # 行数是 kernel_rows * kernel_cols 的原因: 
    # 1.这代表了卷积核中的元素数量
    # 2.滑动卷积核的过程中,每一个卷积核元素和输入图像的一个相应元素相乘,我们会将卷积核展开成一个 1 x kernel_rows * kernel_cols 的一维数组,所以对应的 im2col 矩阵的行数就是 kernel_rows * kernel_cols
    # 列数是 output_rows * output_cols 的原因:
    # 1. 这代表了卷积操作产生的输出元素的数量,即卷积输出的尺寸
    # 2. 每一列代表了对应于输入图像中的一个唯一的卷积窗口
    im2col_matrix = np.zeros((kernel_rows * kernel_cols, output_rows * output_cols))
    col = 0
    for i in range(output_rows):
        for j in range(output_cols):
            # 选取当前的卷积窗口,i 和 j 是当前迭代行和列的索引,i + kernel_rows 指定了范围,j + kernal_cols 同理
			# ravel 函数用于展平成一维数组
            window = image[i:i + kernel_rows, j:j + kernel_cols].ravel()
            im2col_matrix[:, col] = window
            # 追踪和更新正在填充的列
            col += 1

    return im2col_matrix

def conv_to_matrix_mult(image, kernel):
    """
    Perform convolution by converting it to a matrix multiplication.
    """
    kernel_matrix = kernel.ravel()
    im2col_matrix = im2col(image, kernel.shape)

    # Perform matrix multiplication and reshape the result
    conv_result = kernel_matrix @ im2col_matrix
    output_shape = (image.shape[0] - kernel.shape[0] + 1, image.shape[1] - kernel.shape[1] + 1)
    conv_result = conv_result.reshape(output_shape)

    return conv_result

# Example input image and kernel
input_image = np.array([
    [1, 2, 3, 4],
    [5, 6, 7, 8],
    [9, 10, 11, 12],
    [13, 14, 15, 16]
])

kernel = np.array([
    [1, 0, -1],
    [1, 0, -1],
    [1, 0, -1]
])

# Perform convolution using matrix multiplication
convolution_result = conv_to_matrix_mult(input_image, kernel)
convolution_result

到底优化了多少,有没有一个量化的公式来反映快了多少呢?

速度提升程度取决于多种因素,包括硬件特性、卷积参数(如卷积核大小、步长、填充)、以及输入数据的大小

  • 硬件加速:使用 im2col 加上高效的矩阵乘法(如 GPU 上的 cuBLAS 或 CPU 上的优化 BLAS 实现)可以显著加速卷积运算,特别是在并行处理大型数据集时。

  • 内存使用和带宽im2col 方法会增加内存使用量,因为它创建了一个较大的矩阵来存储重排的输入数据。在内存带宽有限的情况下,这可能成为瓶颈。

  • 卷积核大小:较小的卷积核(例如 3x3)通常能从 im2col 方法中获得更大的速度提升,因为这种情况下矩阵乘法更高效。

  • 输入和输出尺寸:输入图像的大小和卷积核的数量也会影响性能。较大的输入图像或更多的卷积核意味着更多的计算,但同时也意味着更高的并行度,这在使用 GPU 时特别有利。

  • 算法优化:不同的库和框架对 im2col 和矩阵乘法的实现方式可能不同,这会影响性能。一些实现可能包括额外的优化,比如更有效地利用缓存或减少内存占用。

优化的点在什么地方呢 ?

改变了卷积运算的方式,使其能够更有效地利用现代计算硬件的优势,尤其是在并行处理方面。

  • 并行处理:传统的卷积操作是通过在输入图像上逐个位置滑动卷积核来实现的,这通常是一个顺序处理过程。而将卷积转换为矩阵乘法后,可以一次性处理多个卷积窗口。现代计算硬件(特别是 GPU)非常擅长同时执行大量的矩阵乘法运算,因此这种方法可以充分利用硬件的并行处理能力。

  • 高效的矩阵乘法库:现代计算环境中,矩阵乘法是高度优化的操作,特别是在使用诸如 cuBLAS(在 NVIDIA GPU 上)或 Intel MKL(在 Intel CPU 上)这样的库时。通过将卷积转换为矩阵乘法,可以利用这些库的优化来加速运算。

  • 增加的内存使用:虽然 im2col 方法可以提高计算效率,但它会增加内存的使用量。这是因为它需要创建一个额外的矩阵来存储重排后的输入数据。在某些情况下,这可能导致内存带宽成为性能瓶颈。

  • 适用性问题:对于小型卷积核和大型输入数据,im2col 方法通常更有效。但对于大型卷积核或小型输入,这种方法可能不会带来太大的性能提升,甚至可能因为额外的内存需求而减慢速度。