constributed by <henry0929016816
vtim9907
>
這是一個簡單的 sub-matrix 版本,把巨大的 array 切成小的 4 * 4 矩陣後再做運算。
for (int i = 0; i < dst->row; i += 4) {
for (int j = 0; j < dst->col; j += 4) {
for (int k = 0; k < N; k += 4) {
for (int i2 = 0; i2 < 4; ++i2) {
for (int j2 = 0; j2 < 4; ++j2) {
for (int k2 = 0; k2 < 4; ++k2) {
PRIV(dst)->values[i + i2][j + j2] += PRIV(l)->values[i + i2][k + k2] *
PRIV(r)->values[k + k2][j + j2];
}
}
}
}
}
}
在每次跑最內圈的 for 時,取左陣列和右陣列的元素出來看 memory address 到底長怎樣,並把左右陣列 print 出的結果分開來看:
address PRIV(l)->values[i + i2][k + k2] = 0x7e2470
address PRIV(l)->values[i + i2][k + k2] = 0x7e2474
address PRIV(l)->values[i + i2][k + k2] = 0x7e2478
address PRIV(l)->values[i + i2][k + k2] = 0x7e247c
address PRIV(r)->values[k + k2][j + j2] = 0x803ffc
address PRIV(r)->values[k + k2][j + j2] = 0x80443c
address PRIV(r)->values[k + k2][j + j2] = 0x80487c
address PRIV(r)->values[k + k2][j + j2] = 0x804cbc
很明顯,右陣列因為是以 column order 的方式讀取,所以 memory address 不連續,沒辦法有效利用 cache。
不過因為這個版本並沒有特別用到硬體支援的技術,譬如 SEE、 AVX,所以 Data alignment 與這個版本比較沒有關係。
我再確定一下 @@
所以如果你的資料是分布在 1~4 那還是會
第一次取:0~3 將,0 的資料去掉,留下 1~3
第二次取:4~7 將,5~7 的資料去掉,留下 4
再將 1~3 4 合起來
struct 會自動做 alignment,假設創了一個 struct,如下面 code 所示
struct s1 {
char c;
int a;
}
照我們學過的 char 為 1 byte,而 int 為 4 byte ,兩個加起來應該為 5 byte,然而實際上為 8 byte,由於 int 為 4 byte ,所以 char 為了要能 alignment 4 byte 就多加了 3 byte 進去 ,使得 cpu 存取速度不會因 address 不是在 4 的倍數上,存取變慢。
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
typedef struct _s1
{
char a[5];
}s1;
int main()
{
int i;
s1 p[10];
printf("struct s1 size: %ld byte\n",sizeof(s1));
for(i=0;i<10;i++)
{
printf("the struct p[%d] address =%p\n",i,p+i);
}
}
得到執行結果為
struct s1 size: 5 byte
the struct p[0] address =0x7ffe72bfd270
the struct p[1] address =0x7ffe72bfd275
the struct p[2] address =0x7ffe72bfd27a
the struct p[3] address =0x7ffe72bfd27f
the struct p[4] address =0x7ffe72bfd284
the struct p[5] address =0x7ffe72bfd289
the struct p[6] address =0x7ffe72bfd28e
the struct p[7] address =0x7ffe72bfd293
the struct p[8] address =0x7ffe72bfd298
the struct p[9] address =0x7ffe72bfd29d
所以如果我們現在探討的議題,matrix 不是 4x4 的大小,可能是 3x3,那我們就要考慮如何 aligment 才能使資料的位址符合 cpu 抓取的的方式。
注意
注意
...
...
#pragma pack(push)
#pragma pack(1)
typedef struct _test1
{
int t;
char a;
int b;
}test1;
#pragma pack(pop)
typedef struct _test2
{
int t;
char a;
int b;
}test2;
分別讓兩個 struct 執行對 b 的資料操作
...
for(int i=0;i<4000;i++)
{
t1.b=0;
t1.b+=1;
}
...
...
for(int i=0;i<4000;i++)
{
t2.b=0;
t2.b+=1;
}
...
發現執行的時間沒有差異
重新設計實驗
#pragma pack(1)
typedef struct _test1
{
char c[3];
int num[256];
}test1;
#pragma pack(pop)
typedef struct _test2
{
char c[3];
int num[256];
}test2;
echo 3 > /proc/sys/vm/drop_caches
for i in `seq 0 1 200`; do \
echo 3 > /proc/sys/vm/drop_caches ;\
printf "%d," $$i;\
./pointer; \
done > clock_gettime.txt
有幾比測資誤差滿大的,但大部份都相同,關於誤差很大的問題,到現在還是不懂,只知道這個問題從第1週就一直困惑我滿久的,知道跟 cache 有關 但不知道為什麼會這個樣子。
今天剛好有看到 c11 有提供一個新的函式 叫 void *aligned_alloc(size_t alignment, size_t size); 可以自訂要 align 的 size,等晚點再來看的詳細說明)
因為 SSE 的暫存器 (XMM0 ~ XMM7) 有 128 bits 的長度,也就是 4 個 integer 的大小,根據這個特性,在做矩陣乘法時,選擇將大矩陣切割成多個 4 * 4 的小矩陣來做會比較有效率。
因為原本要做乘法的兩個矩陣都是用 malloc 配置出來的,在 Matrix Multiplication using SIMD 有看到 malloc 本來配置出來的記憶體位置就有做 alignment,根據 malloc 的 man page 裡提到 :
The malloc() and calloc() functions return a pointer to the allocated memory, which is
suitably aligned for any built-in type.
但單看這句英文,其實我意會不太過來實際上到底 malloc 做了怎樣的 data alignment,所以又查了 The GNU C Library - Malloc Example,裡面有特別提到 :
In the GNU system, the address is always a multiple of eight on most systems, and a
multiple of 16 on 64-bit systems.
這句我就有看懂,所以說在 "大多數" 系統下,malloc 會以 8 bytes 進行對齊;而在 64-bit 的系統下,malloc 則會以 16 bytes 進行對齊。
做了小實驗,malloc 在我的系統(64-bit)下是以 16 bytes 對齊:
for(int i=0;i<10000;++i){
char *z;
z=malloc(sizeof(char));
}
結果用 gdb 測過之後,發現位址的結尾的確都是 0 結尾,表示真的是以 16-byte 做對齊。
其實這邊最大的疑惑是在 alignment 系列 function 的 man page 裡,最下面的 NOTES 提到 " The glibc malloc(3) always returns 8-byte aligned memory addresses ",所以才不太確定到底是只會對齊 8-byte,才去查其他文獻和做實驗,證明 malloc 在 64-bit 系統下會以 16-byte 對齊,所以也許上面貼的 man page 是單指 32-bit 的系統吧!?
確認了 malloc 的機制後,可以確定矩陣不需要額外用其他 function 做 data alignment,用原本 malloc 的配置方式,就可以直接按原計畫做 SSE 版本的矩陣乘法,就直接實作。
原本有在思考要不要先把第二個矩陣先做轉置,這樣就不會有 column major 的問題,不過想想,這樣有點太大費周章…,把轉置的時間也算進去的話,效能大概也會變慢,還是算了@@
實做的部份我把 4 * 4 的小矩陣乘法做了 loop unrolling,所以 code 有一點點長,不過因為想到一種比較特別的矩陣乘法實做方式,滿精簡的,所以實際用到的指令其實比我預想的少。
code :
for (int i = 0; i < dst->row; i += 4) {
for (int j = 0; j < dst->col; j += 4) {
__m128i dst0 = _mm_setzero_si128();
__m128i dst1 = _mm_setzero_si128();
__m128i dst2 = _mm_setzero_si128();
__m128i dst3 = _mm_setzero_si128();
for (int k = 0; k < l->col; k += 4) {
__m128i r1 = _mm_load_si128((__m128i *)(&PRIV(r)->values[k][j]));
__m128i r2 = _mm_load_si128((__m128i *)(&PRIV(r)->values[k+1][j]));
__m128i r3 = _mm_load_si128((__m128i *)(&PRIV(r)->values[k+2][j]));
__m128i r4 = _mm_load_si128((__m128i *)(&PRIV(r)->values[k+3][j]));
__m128i b1 = _mm_set1_epi32(PRIV(l)->values[i][k]);
__m128i b2 = _mm_set1_epi32(PRIV(l)->values[i][k+1]);
__m128i b3 = _mm_set1_epi32(PRIV(l)->values[i][k+2]);
__m128i b4 = _mm_set1_epi32(PRIV(l)->values[i][k+3]);
__m128i b5 = _mm_set1_epi32(PRIV(l)->values[i+1][k]);
__m128i b6 = _mm_set1_epi32(PRIV(l)->values[i+1][k+1]);
__m128i b7 = _mm_set1_epi32(PRIV(l)->values[i+1][k+2]);
__m128i b8 = _mm_set1_epi32(PRIV(l)->values[i+1][k+3]);
__m128i b9 = _mm_set1_epi32(PRIV(l)->values[i+2][k]);
__m128i b10 = _mm_set1_epi32(PRIV(l)->values[i+2][k+1]);
__m128i b11 = _mm_set1_epi32(PRIV(l)->values[i+2][k+2]);
__m128i b12 = _mm_set1_epi32(PRIV(l)->values[i+2][k+3]);
__m128i b13 = _mm_set1_epi32(PRIV(l)->values[i+3][k]);
__m128i b14 = _mm_set1_epi32(PRIV(l)->values[i+3][k+1]);
__m128i b15 = _mm_set1_epi32(PRIV(l)->values[i+3][k+2]);
__m128i b16 = _mm_set1_epi32(PRIV(l)->values[i+3][k+3]);
__m128i t0 = _mm_add_epi32(
_mm_add_epi32(
_mm_mullo_epi32(b1, r1),
_mm_mullo_epi32(b2, r2)),
_mm_add_epi32(
_mm_mullo_epi32(b3, r3),
_mm_mullo_epi32(b4, r4)));
__m128i t1 = _mm_add_epi32(
_mm_add_epi32(
_mm_mullo_epi32(b5, r1),
_mm_mullo_epi32(b6, r2)),
_mm_add_epi32(
_mm_mullo_epi32(b7, r3),
_mm_mullo_epi32(b8, r4)));
__m128i t2 = _mm_add_epi32(
_mm_add_epi32(
_mm_mullo_epi32(b9, r1),
_mm_mullo_epi32(b10, r2)),
_mm_add_epi32(
_mm_mullo_epi32(b11, r3),
_mm_mullo_epi32(b12, r4)));
__m128i t3 = _mm_add_epi32(
_mm_add_epi32(
_mm_mullo_epi32(b13, r1),
_mm_mullo_epi32(b14, r2)),
_mm_add_epi32(
_mm_mullo_epi32(b15, r3),
_mm_mullo_epi32(b16, r4)));
dst0 = _mm_add_epi32(t0, dst0);
dst1 = _mm_add_epi32(t1, dst1);
dst2 = _mm_add_epi32(t2, dst2);
dst3 = _mm_add_epi32(t3, dst3);
}
_mm_store_si128((__m128i *)(&PRIV(dst)->values[i][j]), dst0);
_mm_store_si128((__m128i *)(&PRIV(dst)->values[i+1][j]), dst1);
_mm_store_si128((__m128i *)(&PRIV(dst)->values[i+2][j]), dst2);
_mm_store_si128((__m128i *)(&PRIV(dst)->values[i+3][j]), dst3);
}
}
過程簡單講大概如下:
假設矩陣 A x B = C
分割步驟來看,先算
使用 _mm_set1_epi32 可以塞四個相同的 int 到一個 __m128i 裡,如下
而
然後就可以用 _mm_mullo_epi32 做相乘,然後全部加起來,就會得到
_mm_mullo_epi32(
_mm_mullo_epi32(
_mm_mullo_epi32(
_mm_mullo_epi32(
=
用同樣概念做四次,就完成了一次 4 * 4 的小矩陣相乘!
效能
submatrix 版本
sse 版本
matrix avx_multiple(matrix a,matrix b)
{
matrix result;
__m256 r[2][4];
for(int i=0;i<4;i++) {
__m256 a_row_01=_mm256_set_ps(a.value[0][i],a.value[0][i],a.value[0][i],a.value[0][i],
a.value[1][i],a.value[1][i],a.value[1][i],a.value[1][i]);
__m256 a_row_23=_mm256_set_ps(a.value[2][i],a.value[2][i],a.value[2][i],a.value[2][i],
a.value[3][i],a.value[3][i],a.value[3][i],a.value[3][i]);
__m256 b_col=_mm256_set_ps(b.value[i][0],b.value[i][1],b.value[i][2],b.value[i][3],
b.value[i][0],b.value[i][1],b.value[i][2],b.value[i][3]);
r[0][i]=_mm256_mul_ps (a_row_01,b_col);
r[1][i]=_mm256_mul_ps (a_row_23,b_col);
}
__m256 sum0= _mm256_add_ps (r[0][0],r[0][1]);
sum0 = _mm256_add_ps (sum0,r[0][2]);
sum0 = _mm256_add_ps (sum0,r[0][3]);
__m256 sum1= _mm256_add_ps (r[1][0],r[1][1]);
sum1 = _mm256_add_ps (sum1,r[1][2]);
sum1 = _mm256_add_ps (sum1,r[1][3]);
result.value[0][0]=sum0[7];
result.value[0][1]=sum0[6];
result.value[0][2]=sum0[5];
result.value[0][3]=sum0[4];
result.value[1][0]=sum0[3];
result.value[1][1]=sum0[2];
result.value[1][2]=sum0[1];
result.value[1][3]=sum0[0];
result.value[2][0]=sum1[7];
result.value[2][1]=sum1[6];
result.value[2][2]=sum1[5];
result.value[2][3]=sum1[4];
result.value[3][0]=sum1[3];
result.value[3][1]=sum1[2];
result.value[3][2]=sum1[1];
result.value[3][3]=sum1[0];
return result;
}
執行時間跟 naive 板相比
avx execution time 0.000579(s)
naive execution time 0.001123(s)
可以發現有比 naive 版本的乘法還要快
首先,先來介紹 code 的 概念,我們都知道 matrix 乘法是行列乘法
而我的寫法就是分別拆開相乘得到的結果,再相加得到答案
_mm256_set_ps (float e7, float e6, float e5, float e4, float e3, float e2, float e1, float e0)
是設定 avx 的 register 裡的值要為多少的函式,由於我們是 float type 的 matrix 所以要將 8 個值傳進去 ,傳值的方式還是一個一個傳,而這種作法很慢, 如果能一次就讀取 8 個值想必會快的多, 而這樣就要使用 _mm256_load_ps 這個函式 。
_mm256_load_ps (float const * mem_addr)
由參數的 address 開始往後讀取 31 byte 的資料,如果傳入的 address 不是 32 byte alignment 會出錯 (core dumped), 由於讀取到錯誤的記憶體,所以這裡就要考慮到 data alignment 的議題了(終於啊!)
henry0929016816關於這裡我還是有點不懂為何會讀取到錯誤的記憶體