【发布时间】:2014-07-28 22:05:33
【问题描述】:
问题
我一直在研究 HPC,特别是使用矩阵乘法作为我的项目(请参阅我在个人资料中的其他帖子)。我在这些方面取得了不错的成绩,但还不够好。我退后一步,看看我在点积计算方面能做多好。
点积与矩阵乘法
点积更简单,可以让我在不处理打包和其他相关问题的情况下测试 HPC 概念。缓存阻塞仍然是一个问题,这是我的第二个问题。
算法
将n两个double数组A和B中的对应元素相乘并将它们相加。组装中的double 点积只是movapd、mulpd、addpd 的系列。展开并以一种巧妙的方式排列,可以让movapd/mulpd/addpd 组在不同的xmm 寄存器上运行,因此是独立的,优化了流水线。当然,事实证明这并不重要,因为我的 CPU 有乱序执行。另请注意,重新排列需要剥离最后一次迭代。
其他假设
我不是为一般的点积编写代码。该代码适用于特定尺寸,我不处理边缘案例。这只是为了测试 HPC 概念,看看我能达到什么类型的 CPU 使用率。
结果
使用gcc -std=c99 -O2 -m32 -mincoming-stack-boundary=2 -msse3 -mfpmath=sse,387 -masm=intel 编译。我在与平常不同的计算机上。这台计算机有一个i5 540m,它可以在两步英特尔睿频加速后获得2.8 GHz * 4 FLOPS/cycle/core = 11.2 GFLOPS/s per core(两个内核现在都打开了,所以它只有两步……如果我关闭一个内核,则可以提高四步) . 32 位 LINPACK 设置为使用一个线程运行时,速度约为 9.5 GFLOPS/s。
N Total Gflops/s Residual
256 5.580521 1.421085e-014
384 5.734344 -2.842171e-014
512 5.791168 0.000000e+000
640 5.821629 0.000000e+000
768 5.814255 2.842171e-014
896 5.807132 0.000000e+000
1024 5.817208 -1.421085e-013
1152 5.805388 0.000000e+000
1280 5.830746 -5.684342e-014
1408 5.881937 -5.684342e-014
1536 5.872159 -1.705303e-013
1664 5.881536 5.684342e-014
1792 5.906261 -2.842171e-013
1920 5.477966 2.273737e-013
2048 5.620931 0.000000e+000
2176 3.998713 1.136868e-013
2304 3.370095 -3.410605e-013
2432 3.371386 -3.410605e-013
问题 1
我怎样才能做得比这更好?我什至没有接近巅峰表现。我已经将汇编代码优化到了天堂。进一步展开可能会稍微提升一点,但较少展开似乎会降低性能。
问题 2
n > 2048 时,您可以看到性能下降。这是因为我的L1缓存是32KB,当n = 2048和A和B是double时,就占满了整个缓存。任何更大的,它们都是从内存中流式传输的。
我尝试了缓存阻塞(源代码中未显示),但也许我做错了。谁能提供一些代码或解释如何阻止缓存的点积?
源代码
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
#include <string.h>
#include <x86intrin.h>
#include <math.h>
#include <omp.h>
#include <stdint.h>
#include <windows.h>
// computes 8 dot products
#define KERNEL(address) \
"movapd xmm4, XMMWORD PTR [eax+"#address"] \n\t" \
"mulpd xmm7, XMMWORD PTR [edx+48+"#address"] \n\t" \
"addpd xmm2, xmm6 \n\t" \
"movapd xmm5, XMMWORD PTR [eax+16+"#address"] \n\t" \
"mulpd xmm4, XMMWORD PTR [edx+"#address"] \n\t" \
"addpd xmm3, xmm7 \n\t" \
"movapd xmm6, XMMWORD PTR [eax+96+"#address"] \n\t" \
"mulpd xmm5, XMMWORD PTR [edx+16+"#address"] \n\t" \
"addpd xmm0, xmm4 \n\t" \
"movapd xmm7, XMMWORD PTR [eax+112+"#address"] \n\t" \
"mulpd xmm6, XMMWORD PTR [edx+96+"#address"] \n\t" \
"addpd xmm1, xmm5 \n\t"
#define PEELED(address) \
"movapd xmm4, XMMWORD PTR [eax+"#address"] \n\t" \
"mulpd xmm7, [edx+48+"#address"] \n\t" \
"addpd xmm2, xmm6 \n\t" \
"movapd xmm5, XMMWORD PTR [eax+16+"#address"] \n\t" \
"mulpd xmm4, XMMWORD PTR [edx+"#address"] \n\t" \
"addpd xmm3, xmm7 \n\t" \
"mulpd xmm5, XMMWORD PTR [edx+16+"#address"] \n\t" \
"addpd xmm0, xmm4 \n\t" \
"addpd xmm1, xmm5 \n\t"
inline double
__attribute__ ((gnu_inline))
__attribute__ ((aligned(64))) ddot_ref(
int n,
const double* restrict A,
const double* restrict B)
{
double sum0 = 0.0;
double sum1 = 0.0;
double sum2 = 0.0;
double sum3 = 0.0;
double sum;
for(int i = 0; i < n; i+=4) {
sum0 += *(A + i ) * *(B + i );
sum1 += *(A + i+1) * *(B + i+1);
sum2 += *(A + i+2) * *(B + i+2);
sum3 += *(A + i+3) * *(B + i+3);
}
sum = sum0+sum1+sum2+sum3;
return(sum);
}
inline double
__attribute__ ((gnu_inline))
__attribute__ ((aligned(64))) ddot_asm
( int n,
const double* restrict A,
const double* restrict B)
{
double sum;
__asm__ __volatile__
(
"mov eax, %[A] \n\t"
"mov edx, %[B] \n\t"
"mov ecx, %[n] \n\t"
"pxor xmm0, xmm0 \n\t"
"pxor xmm1, xmm1 \n\t"
"pxor xmm2, xmm2 \n\t"
"pxor xmm3, xmm3 \n\t"
"movapd xmm6, XMMWORD PTR [eax+32] \n\t"
"movapd xmm7, XMMWORD PTR [eax+48] \n\t"
"mulpd xmm6, XMMWORD PTR [edx+32] \n\t"
"sar ecx, 7 \n\t"
"sub ecx, 1 \n\t" // peel
"L%=: \n\t"
KERNEL(64 * 0)
KERNEL(64 * 1)
KERNEL(64 * 2)
KERNEL(64 * 3)
KERNEL(64 * 4)
KERNEL(64 * 5)
KERNEL(64 * 6)
KERNEL(64 * 7)
KERNEL(64 * 8)
KERNEL(64 * 9)
KERNEL(64 * 10)
KERNEL(64 * 11)
KERNEL(64 * 12)
KERNEL(64 * 13)
KERNEL(64 * 14)
KERNEL(64 * 15)
"lea eax, [eax+1024] \n\t"
"lea edx, [edx+1024] \n\t"
" \n\t"
"dec ecx \n\t"
"jnz L%= \n\t" // end loop
" \n\t"
KERNEL(64 * 0)
KERNEL(64 * 1)
KERNEL(64 * 2)
KERNEL(64 * 3)
KERNEL(64 * 4)
KERNEL(64 * 5)
KERNEL(64 * 6)
KERNEL(64 * 7)
KERNEL(64 * 8)
KERNEL(64 * 9)
KERNEL(64 * 10)
KERNEL(64 * 11)
KERNEL(64 * 12)
KERNEL(64 * 13)
KERNEL(64 * 14)
PEELED(64 * 15)
" \n\t"
"addpd xmm0, xmm1 \n\t" // summing result
"addpd xmm2, xmm3 \n\t"
"addpd xmm0, xmm2 \n\t" // cascading add
"movapd xmm1, xmm0 \n\t" // copy xmm0
"shufpd xmm1, xmm0, 0x03 \n\t" // shuffle
"addsd xmm0, xmm1 \n\t" // add low qword
"movsd %[sum], xmm0 \n\t" // mov low qw to sum
: // outputs
[sum] "=m" (sum)
: // inputs
[A] "m" (A),
[B] "m" (B),
[n] "m" (n)
: //register clobber
"memory",
"eax","ecx","edx","edi",
"xmm0","xmm1","xmm2","xmm3","xmm4","xmm5","xmm6","xmm7"
);
return(sum);
}
int main()
{
// timers
LARGE_INTEGER frequency, time1, time2;
double time3;
QueryPerformanceFrequency(&frequency);
// clock_t time1, time2;
double gflops;
int nmax = 4096;
int trials = 1e7;
double sum, residual;
FILE *f = fopen("soddot.txt","w+");
printf("%16s %16s %16s\n","N","Total Gflops/s","Residual");
fprintf(f,"%16s %16s %16s\n","N","Total Gflops/s","Residual");
for(int n = 256; n <= nmax; n += 128 ) {
double* A = NULL;
double* B = NULL;
A = _mm_malloc(n*sizeof(*A), 64); if (!A) {printf("A failed\n"); return(1);}
B = _mm_malloc(n*sizeof(*B), 64); if (!B) {printf("B failed\n"); return(1);}
srand(time(NULL));
// create arrays
for(int i = 0; i < n; ++i) {
*(A + i) = (double) rand()/RAND_MAX;
*(B + i) = (double) rand()/RAND_MAX;
}
// warmup
sum = ddot_asm(n,A,B);
QueryPerformanceCounter(&time1);
// time1 = clock();
for (int count = 0; count < trials; count++){
// sum = ddot_ref(n,A,B);
sum = ddot_asm(n,A,B);
}
QueryPerformanceCounter(&time2);
time3 = (double)(time2.QuadPart - time1.QuadPart) / frequency.QuadPart;
// time3 = (double) (clock() - time1)/CLOCKS_PER_SEC;
gflops = (double) (2.0*n*trials)/time3/1.0e9;
residual = ddot_ref(n,A,B) - sum;
printf("%16d %16f %16e\n",n,gflops,residual);
fprintf(f,"%16d %16f %16e\n",n,gflops,residual);
_mm_free(A);
_mm_free(B);
}
fclose(f);
return(0); // successful completion
}
编辑:组装说明
点积只是两个数字乘积的重复总和:sum += a[i]*b[i]。 sum 必须在第一次迭代之前初始化为 0。向量化,您一次可以做 2 个求和,必须在最后求和:[sum0 sum1] = [a[i] a[i+1]]*[b[i] b[i+1]],sum = sum0 + sum1。在 (Intel) 汇编中,这是 3 个步骤(在初始化之后):
pxor xmm0, xmm0 // accumulator [sum0 sum1] = [0 0]
movapd xmm1, XMMWORD PTR [eax] // load [a[i] a[i+1]] into xmm1
mulpd xmm1, XMMWORD PTR [edx] // xmm1 = xmm1 * [b[i] b[i+1]]
addpd xmm0, xmm1 // xmm0 = xmm0 + xmm1
此时你没有什么特别的,编译器可以想出这个。通常,通过展开代码足够多的时间以使用所有可用的xmm 寄存器(32 位模式下的 8 个寄存器),您通常可以获得更好的性能。因此,如果您将其展开 4 次,则可以使用所有 8 个寄存器 xmm0 到 xmm7。您将有 4 个累加器和 4 个寄存器用于存储 movapd 和 addpd 的结果。同样,编译器可以提出这一点。真正思考的部分是想出一种方法来管道代码,即使 MOV/MUL/ADD 组中的每条指令在不同的寄存器上操作,以便所有 3 条指令同时执行(通常在大多数 CPU)。这就是你击败编译器的方式。因此,您必须对 4x 展开的代码进行模式化才能做到这一点,这可能需要提前加载向量并剥离第一次或最后一次迭代。这就是KERNEL(address)。为方便起见,我制作了 4x 展开的流水线代码的宏。这样,我只需更改 address 就可以轻松地将其展开为 4 的倍数。每个 KERNEL 计算 8 个点积。
【问题讨论】:
-
您可能希望使用compiler intrinsics 而不是内联汇编代码。它看起来更好。
-
@tangrs,无论标志如何,它们都不会优化人类手动操作的方式。而且它们速度较慢。我已经试过了。
-
嗯,这很有趣。我一直认为内在函数与下面的程序集是 1-1 映射的。
-
@tangrs 我也这么认为。它们通常会生成正确的 MOVPD/MULPD/ADDPD 分组,但我似乎从来没有像他们那样进行重新排序以使每个 MOV/MUL/ADD 在不同的寄存器上工作。具有讽刺意味的是,编译器内在函数为我的矩阵乘法项目生成了一个快速内核,它比我从 Goto 复制的其他一些内核运行得更快。尽管如此,在内部函数案例中仍有改进的空间。
-
为什么是
-O2而不是-O3?为什么不-march=native?
标签: c caching optimization hpc