【问题标题】:reading/writing a matrix with a stride much larger than its width causes a big loss in performance读取/写入步幅远大于其宽度的矩阵会导致性能大幅下降
【发布时间】:2014-03-09 08:46:12
【问题描述】:

我正在对 1024x1024 矩阵进行密集矩阵乘法。我使用 64x64 瓷砖使用循环阻塞/平铺来做到这一点。我创建了一个高度优化的 64x64 矩阵乘法函数(代码见我问题的结尾)。

gemm64(float *a, float *b, float *c, int stride).  

这是在图块上运行的代码。一个 1024x1204 矩阵,包含 16x16 块。

for(int i=0; i<16; i++) {
    for(int j=0; j<16; j++) {
        for(int k=0; k<16; k++) {
            gemm64(&a[64*(i*1024 + k)], &b[64*(k*1024 + j)], &c[64*(i*1024 + j)], 1024);
        }
    }
}

但是,作为一种猜测,我尝试在新矩阵 b2 中重新排列 b 矩阵的所有图块的内存(代码参见本问题的末尾),以便每个图块的步幅为64 而不是 1024。这有效地生成了一个 64x64 矩阵的数组,stride=64。

float *b2 = (float*)_mm_malloc(1024*1024*sizeof(float), 64);
reorder(b, b2, 1024);
for(int i=0; i<16; i++) {
    for(int j=0; j<16; j++) {
        for(int k=0; k<16; k++) {
            gemm64_v2(&a[64*(i*1024 + k)], &b2[64*64*(k*16 + j)], &c[64*(i*1024 + j)], 64);
        }
    }
}
_mm_free(b2);

注意 b 的偏移量如何从 &amp;b[64*(k*1024 + j)] 更改为 &amp;b2[64*64*(k*16 + j)] 并且传递给 gemm64 的步幅已从 1024 更改为 64。

在我的 Sandy Bridge 系统上,我的代码性能从低于峰值 flop 的 20% 跃升至 70%!

为什么以这种方式重新排列 b 矩阵中的图块会产生如此巨大的差异?

数组 a、b、b2 和 c 是 64 字节对齐的。

extern "C" void gemm64(float *a, float*b, float*c, int stride) {
    for(int i=0; i<64; i++) {
        row_m64x64(&a[1024*i], b, &c[1024*i], stride);
    }
}

void row_m64x64(const float *a, const float *b, float *c, int stride) {
    __m256 tmp0, tmp1, tmp2, tmp3, tmp4, tmp5, tmp6, tmp7;
    tmp0 = _mm256_loadu_ps(&c[ 0]);
    tmp1 = _mm256_loadu_ps(&c[ 8]);
    tmp2 = _mm256_loadu_ps(&c[16]);
    tmp3 = _mm256_loadu_ps(&c[24]);
    tmp4 = _mm256_loadu_ps(&c[32]);
    tmp5 = _mm256_loadu_ps(&c[40]);
    tmp6 = _mm256_loadu_ps(&c[48]);
    tmp7 = _mm256_loadu_ps(&c[56]);

    for(int i=0; i<64; i++) {
        __m256 areg0 = _mm256_broadcast_ss(&a[i]);

        __m256 breg0 = _mm256_loadu_ps(&b[stride*i +  0]);
        tmp0 = _mm256_add_ps(_mm256_mul_ps(areg0,breg0), tmp0);    
        __m256 breg1 = _mm256_loadu_ps(&b[stride*i +  8]);
        tmp1 = _mm256_add_ps(_mm256_mul_ps(areg0,breg1), tmp1);
        __m256 breg2 = _mm256_loadu_ps(&b[stride*i + 16]);
        tmp2 = _mm256_add_ps(_mm256_mul_ps(areg0,breg2), tmp2);    
        __m256 breg3 = _mm256_loadu_ps(&b[stride*i + 24]);
        tmp3 = _mm256_add_ps(_mm256_mul_ps(areg0,breg3), tmp3);   
        __m256 breg4 = _mm256_loadu_ps(&b[stride*i + 32]);
        tmp4 = _mm256_add_ps(_mm256_mul_ps(areg0,breg4), tmp4);    
        __m256 breg5 = _mm256_loadu_ps(&b[stride*i + 40]);
        tmp5 = _mm256_add_ps(_mm256_mul_ps(areg0,breg5), tmp5);    
        __m256 breg6 = _mm256_loadu_ps(&b[stride*i + 48]);
        tmp6 = _mm256_add_ps(_mm256_mul_ps(areg0,breg6), tmp6);    
        __m256 breg7 = _mm256_loadu_ps(&b[stride*i + 56]);
        tmp7 = _mm256_add_ps(_mm256_mul_ps(areg0,breg7), tmp7);    
    }
    _mm256_storeu_ps(&c[ 0], tmp0);
    _mm256_storeu_ps(&c[ 8], tmp1);
    _mm256_storeu_ps(&c[16], tmp2);
    _mm256_storeu_ps(&c[24], tmp3);
    _mm256_storeu_ps(&c[32], tmp4);
    _mm256_storeu_ps(&c[40], tmp5);
    _mm256_storeu_ps(&c[48], tmp6);
    _mm256_storeu_ps(&c[56], tmp7);
}

重新排序 b 矩阵的代码。

reorder(float *b, float *b2, int stride) {
    //int k = 0;
    for(int i=0; i<16; i++) {
        for(int j=0; j<16; j++) {
            for(int i2=0; i2<64; i2++) {
                for(int j2=0; j2<64; j2++) {
                    //b2[k++] = b[1024*(64*i+i2) + 64*j + j2];
                    b2[64*64*(i*16 + j) + 64*i2+j2] = b[1024*(64*i+i2) + 64*j + j2];
                }
            }
        }
    }
}

【问题讨论】:

  • 这很可能是一种虚假的分享效果,例如在 stride=1024 处,连续矩阵从缓存中相互驱逐。
  • 这是我的第一个猜测,但我不确定如何证明。我将步幅更改为 1030 以尝试避免临界步幅,但这并没有太大的区别。
  • @KrzysztofKosiński 这种效果称为缓存别名。虚假分享是另一回事。
  • @Zboson 使用 64x64 矩阵乘法作为基本原语是次优的。 L1 缓存的关联性最多为 8,因此您在缓存中保留的 1x64 行不得超过 8 个。
  • @Zboson 大小取决于微架构的几个参数(缓存关联性、寄存器宽度、寄存器计数),并且根据存储格式(行优先与列优先)还有其他变化。我建议您阅读 Goto 和 van de Geijn 的论文以了解详细信息。您可以查看 github.com/flame/blis/tree/master/config 中的 bli_kernel.h 以获得现代微架构的良好价值。

标签: c++ optimization x86 matrix-multiplication avx


【解决方案1】:

我想我可能已经找到了答案。我认为这与 Translation Look-aside Buffer (TLB) 有关。

他们在 Goto 和 Van de Geijn 的论文 Anatomy of High-Performance Matrix Multiplication 中写道

缓存未命中和 TLB 未命中之间最显着的区别是 高速缓存未命中不一定会使 CPU 停顿……相比之下,TLB 未命中会导致 CPU 停止直到 TLB 被新地址更新。 换句话说, 预取可以屏蔽缓存未命中,但不能屏蔽 TLB 未命中。”

在 4.2.3 节标题为“包装”之后不久,他们写道

现在的基本问题是 A 通常是较大矩阵的子矩阵,因此在内存中不连续。这反过来意味着解决它需要的不仅仅是最小数量的 TLB 条目。 解决方案是将 A 打包到一个连续的工作数组中

【讨论】:

  • 在论文写的时候确实如此,但现在 Intel Ivy Bridge 和 Haswell(可能还有最近的 AMD 处理器)可以在不阻塞执行的情况下处理 TLB 未命中。
  • @MaratDukhan,好的,很高兴知道。然后我仍然不是 100% 确定我知道为什么包装会产生如此大的差异(我这样做只是因为我认为它可能是)。
【解决方案2】:

我认为问题在于最里面的循环,并且与预取有关。您正在执行以下操作:

  1. 从 c 加载 256 个连续字节(4 个缓存行)
  2. 从 b 加载 256 个连续字节 64 次(总共 256 个缓存行)
  3. 将 256 个连续字节保存到 c

在第 2 步中,当 stride 为 64 时,您实际上是在读取一个长的连续内存块。当 stride 为 1024 时,您正在以不连续的步骤读取内存,一次 256 个字节。

因此,当 stride 为 64 时,预取器会提前将连续块读取到缓存中,并且每行最多有一次缓存未命中。当 stride 为 1024 时,预取器会被不连续读取(交替读取连续高速缓存行和广泛分离的高速缓存行)混淆,并且每行有 64 次高速缓存未命中。

【讨论】:

  • 很高兴您能完全理解我在做什么。我担心文字会不清楚。我现在不知道预取是如何工作的,但我认为它足够聪明,可以根据步幅查看加载函数并读取 cachd 行,而不仅仅是连续的缓存行。
  • 这里的问题是步幅不是恒定的。您以 1 个缓存行的步幅开始读取,在以这种方式读取 4 行之后,您读取了一个远远领先的行。可能如果您重新排序读取,以便首先读取所有 stridei+0 和 stridei+8 位置,然后读取 stridei+16 和 stridei+24,然后以此类推,你会有更好的表现,因为步幅是恒定的,每行会有 4 次未命中。
  • 我明白你的意思。但这会产生另一个问题。当前方法只需每行广播(读取)一次。您建议的方法必须从矩阵中重新读取是当前方法的四倍。我过去做过类似的事情,性能更差。
  • 我尝试了您阅读 16 侧列(而不是 64 宽列)的建议,它并不比没有重新排序的默认情况好。我什至将循环展开了两次,这样我就做了四个并行求和(至少需要三个),但没有任何区别。
  • 嗯,也许预取器不查看缓存行,而是直接查看加载/存储......在这种情况下,我的建议不会有太大帮助。您也可以尝试在单独的循环中读取每个 stridei+n 位置,而不是在每个循环中读取 stridei+n 和 stride*i+n+8。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2018-10-08
  • 2019-11-21
  • 1970-01-01
  • 2011-09-10
  • 2022-01-07
相关资源
最近更新 更多