GEEM(General Matrix Multiply)卷积计算是一种常用的卷积神经网络(CNN)中的计算方法。在卷积神经网络中,卷积操作是一种重要的特征提取方法,而GEMM卷积算法则是用于加速卷积计算的一种优化方法。具体来说,GEMM卷积算法将卷积操作中的卷积核展开为一个举证,将输入特征图展开为一个举证,然后利用举证乘法的性质来进行计算,最后将计算结果从新组合成输出特征图。这种方法能够充分利用矩阵乘法的并行性,从而加速卷积计算过程。
在计算机科学领域,矩阵乘法是一项重要的运算。它不仅广泛应用于科学计算、图像处理和机器学习等领域,还作为其他复杂算法的基础。而在矩阵乘法中,GEMM(General Matrix Multiply)则是一个备受关注的算法。本文将带您深入理解矩阵乘法,并探索GEMM算法的奥秘。
首先,让我们来了解一下矩阵乘法的基本原理。矩阵乘法是指将两个矩阵相乘,得到一个新的矩阵。在矩阵乘法中,两个矩阵的维度必须满足一定的条件,具体来说,第一个矩阵的列数必须等于第二个矩阵的行数。通过遍历两个矩阵的元素,按照一定规则进行相乘和相加运算,可以得到结果矩阵的每个元素。
然而,普通的矩阵乘法算法并不高效。当涉及到大规模矩阵乘法运算时,计算时间会呈现指数级增长,给计算机带来很大的负担。因此,人们不断探索优化矩阵乘法算法的方法。
其中,GEMM算法就是一种被广泛采用的优化算法。GEMM算法通过将矩阵乘法分解为多个小规模的矩阵乘法运算,并使用一定的技巧来减少冗余计算,从而提高运算效率。这种分解和优化的思想在实际应用中取得了显著的成果。
GEMM算法的具体实现有多种方法,如基于循环的方法、基于分块的方法和基于并行计算的方法等。每种方法都有其独特的特点和适用场景。例如,基于循环的方法通过嵌套循环遍历矩阵元素,逐个进行相乘和相加运算。这种方法简单可靠,适用于小规模矩阵乘法运算。
而基于分块的方法则将大规模的矩阵划分为多个小块,分别进行矩阵乘法运算,并通过合并和重组结果来得到最终的结果矩阵。这种方法可以充分利用计算机的缓存系统,减少数据读写的开销,从而提高运算效率。
此外,基于并行计算的方法在多核处理器和GPU等平台上发挥着重要作用。通过将矩阵乘法拆分成多个子任务,并分配给不同的计算单元并行处理,可以充分发挥硬件设备的计算能力,加快运算速度。
GEMM算法的进一步优化还有很多挑战和方向。例如,如何针对不同的硬件平台和架构设计高效的GEMM算法、如何利用近似计算技术降低计算量等等。这些问题都是当前研究的热点。
总之,矩阵乘法是计算机科学中一项重要的运算,而GEMM算法则是优化矩阵乘法的关键所在。通过深入理解矩阵乘法,我们可以更好地把握GEMM算法的原理和应用。相信随着科技的不断发展,矩阵乘法及其相关算法将会在更多领域展现出强大的潜力和应用价值。
im2col和gemm实现卷积运算的性能分析
在这篇文章中提到使用 darknet 使用了 im2col 和 gemm 函数实现在 cpu 上对卷积运算的加速。那实际上是否真的起到了加速效果呢,本文就做了下测试。
将 im2col 和 gemm 的代码摘出来,然后再实现一个常规思路的卷积计算操作,接着生成指定大小的输入特征和卷积核对比两者的耗时。
gemm代码的疑似bug
在 vs2017 的环境是运行从 darknet 中拷贝出来的 gemm 代码时总会报越界访问。后来发现是原有的 darknet 代码存在 bug ,也可能是在 vs2017 环境下才出现。我们先看看 gemm 核心代码。
void gemm_nn(int M, int N, int K, float ALPHA,
float *A, int lda,
float *B, int ldb,
float *C, int ldc)
{
int i,j,k;
#pragma omp parallel for
for(i = 0; i < M; ++i){
for(k = 0; k < K; ++k){
register float A_PART = ALPHA*A[i*lda+k];
for(j = 0; j < N; ++j){
C[i*ldc+j] += A_PART*B[k*ldb+j];
}
}
}
}
这里使用了 #pragma omp parallel for,他的意思是下面的这个外层循环中的内容会以多线程的方式运行。
for(i = 0; i < M; ++i){//}
也就是会以多线程的方式执行
for(k = 0; k < K; ++k){
register float A_PART = ALPHA*A[i*lda+k];
for(j = 0; j < N; ++j){
C[i*ldc+j] += A_PART*B[k*ldb+j];
}
}
这代码哪里有问题呢?在进行多线程的编程时一般都需要对多个线性都可以访问的变量进行读写保护,那这段代码中内存循环的索引 k 和 j 都定义在外部,也就是每个线程在执行循环语句时,使用了同一个变量 k 和 j。这就几乎一定会导致循环的索引越界。了解这点后将 k 和 j 在内部进行定义即可正常运行。
void gemm_nn(int M, int N, int K, float ALPHA,
float *A, int lda,
float *B, int ldb,
float *C, int ldc)
{
int i;
#pragma omp parallel for
for(i = 0; i < M; ++i){
int k
for(k = 0; k < K; ++k){
register float A_PART = ALPHA*A[i*lda+k];
int j;
for(j = 0; j < N; ++j){
C[i*ldc+j] += A_PART*B[k*ldb+j];
}
}
}
}
卷积运算时间对比
设计一个
- 输入特征满足 W=200、H=200、C=1,并使用[0,255]来依次初始化。
- 卷积核有20个,卷积核满足W=7、H=7、C=1,并使用[0,255]来依次初始化。
- pading设为3,stride设为1。
常规卷积思路是卷积核在输入特征上滑动,具体实现如下
for (int f_i=0; f_i < kernel_num; f_i++)
{
for (int i = 0; i < n; i++)
{
int i_x = i / output_w % output_h;
int i_y = i % output_w;
i_x = i_x * kernel_stride - kernel_pad;
i_y = i_y * kernel_stride - kernel_pad;
float result = .0;
for (int j = 0; j < k; j++)
{
int k_x = j / kernel_size % kernel_size;
int k_y = j % kernel_size;
int x = i_x + k_x;
int y = i_y + k_y;
if (x < 0 || y < 0 || x >= input_h || y >= input_w)
{
continue;
}
result += a[f_i * k + j] * im[x * input_h + y];
}
c[f_i * n + i] = result;
}
}
对比结果如下
实现方法 | 卷积花费时间(ms) |
---|---|
常规思路 | 100 |
im2col结合gemm | 20 |
im2col结合gemm(不使用多线程) | 35 |
im2col结合gemm(不使用寄存器) | 20 |
从以上结果来看针对当前有20个卷积核参与卷积运行的情况 im2col 结合 gemm 对卷积运算的提速很明显。
接下来将输入的数据做如下修改再看看对比结果
- 输入特征满足 W=2000、H=2000、C=1,并使用[0,255]来依次初始化。
- 卷积核有1个,卷积核满足W=7、H=7、C=1,并使用[0,255]来依次初始化。
- pading设为3,stride设为1。
实现方法 | 卷积花费时间(ms) |
---|---|
常规思路 | 450 |
im2col结合gemm | 1050 |
im2col结合gemm(不使用多线程) | 1010 |
im2col结合gemm(不使用寄存器) | 1068 |
可以看到针对这样的输入数据常规思路比 im2col 结合 gemm 的速度更快。
为什么快为什么慢
为什么会变快呢?存放卷积计算参数的空间在计算机底层是一维线性排布的,当 cpu 需要读取内存中存放的数据用于计算时,系统会将一段连续的数据放到缓存中。这样做的目的是方便 cpu 快速的读取数据,因为读取内存的速度相比 cpu 的计算速度慢了很多。
知道这些再看我们常规的卷积操作,这个操作需要虽然在逻辑上和卷积核进行卷积运算的参数都在一个区域,但在计算机内部他们并不是在连续的位置。这样为了满足 cpu 的计算需求,系统需要频繁的更新缓存。
所以 im2col 采用的策略就是重新排布用于卷积运算的参数位置,让他们存于连续空间,这样对缓存的利用率就升高了,带来的就是卷积速度的提高。当然 gemm 提供的多线程功能也提高了卷积速度。
那为什么会变慢呢?因为 im2col 在调整排列的过程中还是会让跨区域的读取数据,这本身就会导致频繁的刷新缓存,如果我们的卷积核只有1个,那么 im2col 操作之后的新排列就只会用一次,这样就达不到加快卷积运算的效果。当卷积核有多个时,这个重新排列后的优势就体现出来了。