數值計算優化方法C/C++(三)——SIMD

SIMD

1、概述

SIMD全稱Single Instruction Multiple Data,單指令多數據流,能夠讀取多個操作數,並把它們打包在大型寄存器的一組指令集。一次獲取多個操作數後,存放於一個大型寄存器,再進行運算,從而達到一條指令完成對多個對象計算的效果,實現加速。目前常見編譯器對X86-64的CPU上128bit的SIMD計算支持比較好,基本對於大多簡單的計算都可以做到使用SIMD做一個簡單的優化,但是對於較爲複雜的操作依舊需要手動編寫相應的C/C++或者彙編代碼。

Intel官網上SIMD指令使用的相關指南
https://software.intel.com/sites/landingpage/IntrinsicsGuide/#

2、簡單使用

SIMD這個東西我到現在也不是很會用,包括測試一些例子可能用了SIMD更慢了,而且我的CPU比較老了,只支持AVX2.0(AVX-512好像現在的i9和i7 9800x都已經支持了,不過我這個還在用四代i7的也買不起這玩意)。因此對於雙精度浮點型運算,只能一次打包256bit的數據,即四個雙精度浮點數。對了這裏需要注意的一點是,在使用gcc編譯帶有immintrin.h頭文件函數的代碼時,根據你使用的函數類別,需要添加相應的編譯選項(老的SSE是默認支持的通常不需要額外添加選項),如AVX2.0就需要使用-mavx2、FMA需要使用-mfma等等,否則編譯會報錯。如果你不知道用了什麼指令集,但是你確定你的電腦支持這個操作,那麼直接-march=native就可以了。其實如果你使用了AVX-512F指令集,那麼-mavx512f也可以成功編譯,不過如果你的CPU不支持AVX-512F那麼你運行編譯出來的文件的時候就會提示非法指令(#滑稽)。

矢量加法

沒錯又是矢量相加,這次我們也不搞什麼花哨的了,就是C的矢量相加

#include <iostream>
using namespace std;
#define N 20000000
int main(){
    double *x,*y,*z;
    x=(double*)malloc(sizeof(double)*N);
    y=(double*)malloc(sizeof(double)*N);
    z=(double*)malloc(sizeof(double)*N);
    for(int i=0;i<N;i++) z[i]=x[i]+y[i];
    free(x);
    free(y);
    free(z);
    return 1;
}

然後我們再用SIMD寫一個,要使用SIMD技術需要Intrinsics頭文件,這其中有好多不同的頭文件,具體哪個有什麼用我也不是特別清楚,不過一般計算用immintrin.h基本就可以了。

#include <iostream>
#include <immintrin.h>
#define N 20000000
using namespace std;
int main(){
    double *x,*y,*z,*px,*py,*pz;
    x=(double*)_mm_malloc(sizeof(double)*N,16);//申請內存並且按照2的4次方對齊地址
    y=(double*)_mm_malloc(sizeof(double)*N,16);
    z=(double*)_mm_malloc(sizeof(double)*N,16);
    px=x;py=y;pz=z;
    __m128d vx,vy,vz;// __m128d是SSE指令集中操作雙精度浮點數對應的數據類型
    for(int i=0;i<N/2;i++){
        vx=_mm_load_pd(px);//從px指向的內存中取出兩個數,放入入vx
        vy=_mm_load_pd(py);//從py指向的內存中取出兩個數,放入入vy
	    vz=vx+vy;//計算vx+vy並將結果放入vz,這一行也有對應的函數,不過GCC編譯的話直接這樣寫沒有什麼問題,測試發現VS的編譯器和Intel的編譯器都不支持這種寫法
	    _mm_store_pd(pz,vz);//將vz中的結果放入pz指向的內存
        px+=2;//由於前面取出了兩個數據,所以指針後移兩位
        py+=2;
        pz+=2;
    }
    _mm_free(x);//釋放_mm_malloc申請的內存
    _mm_free(y);
    _mm_free(z);
    return 1;
}

好了程序寫好接下來就是測試了,經過測試兩個程序的計算結果都是正確的,由於測計算結果還要寫賦值我嫌麻煩就不寫了,有興趣的可以自己賦個值看一下結果對不對。這裏就測一下效率,首先依舊是g++的O0條件下的測試。

未使用SIMD的用時

real	0m0.167s
user	0m0.092s
sys		0m0.075s

使用SIMD的用時

real	0m0.175s
user	0m0.122s
sys		0m0.053s

可以看到O0優化下SIMD較慢,不過二者用時差距也不大,多次測試感覺基本就是這個範圍上下波動,可以認爲兩個效率相當,或者SIMD略慢一點。

接下來是O3條件下測試
未使用SIMD時

real	0m0.112s
user	0m0.040s
sys		0m0.072s

使用SIMD時

real	0m0.113s
user	0m0.044s
sys		0m0.068s

二者基本沒有區別,我嘗試過加大數組維度,但是由於內存只有4G所以增加的也不多,我也嘗試過反覆相加,最後測試結果都是不論是否使用編譯器優化使用SIMD始終沒有提高效率。也就是說在這個示例中SIMD是沒有作用的,甚至使得計算變慢。

後來看了一些網上的帖子也有人遇到了與我類似的情況。

有人提到了在進行一些簡單運算時,編譯器會在你沒有主動使用SIMD的情況下通過一些優化技術自動轉換爲SIMD的代碼,所以你自己寫的使用SIMD的代碼最快也不會超過編譯器自動優化的結果。

還有一些人說是SIMD的額外開銷是比較大的,對於這種簡單加法,使用SIMD帶來的計算上的效率提升,掩蓋不了使用SIMD帶來的額外性能損失,可能計算比較複雜時SIMD纔有加速的效果。

另外還有一種說法是XMM寄存器總共有8個,而這裏並沒有把8個寄存器都用上,從而並沒有發揮出全部的性能,所以沒有效果。

通過g++的-S選項生成彙編代碼可以看到,編譯器自動優化這部分代碼,不論是使用immintrin.h頭文件中的函數,還是使用循環直接計算,生成的彙編代碼基本相同,也就是說編譯器已經自動優化了這部分的運算,因此並沒有什麼效果。


複數數組乘法

複數數組的乘法是我在寫一個快速傅里葉變換的實現的時候測試的,發現使用AVX2.0之後效率確實有明顯提升,這裏簡單展示一下

首先是計算的原理,因爲複數計算不同於實數,根據實部和虛部的關係,在計算時常常會因爲浮點數的位置不同而需要採用不同的運算方式,所以在使用SIMD加速時也需要做一些處理和變化。這裏使用AVX2.0指令,一次操作256bit的數據,即4個雙精度浮點數,2個雙精度複數。因此,這裏測試時採用z1[2]與z2[2]兩個複數數組進行相乘。具體的計算如下:

爲了方便我們記:
z1中的兩個複數爲a0 + i * b0和a1 + i * b1
z2中的兩個複數爲c0 + i * d0和c1 + i * d1

這時計算結果就表示爲:
(a0 * c0 - b0 * d0) + i * (a0 * d0 + b0 * c0)和(a1 * c1 - b1 * d1) + i * (a1 * d1+b1 * c1)

對於計算結果可以拆解爲兩部分:
{a0 * c0 , a0 * d0 , a1 * c1 , a1 * d1}
{b0 * d0 , b0 * c0 , b1 * d1 , b1 * c1}
將這兩部分的偶數下標位相減,奇數下標位相加就可以得到最終的計算結果,恰好AVX2.0爲我們提供了這條指令。

再進一步分解:
{a0 , a0 , a1 , a1}
{c0 , d0 , c1 , d1}
相乘可以得到第一部分而
{b0 , b0 , b1 , b1}
{d0 , c0 , d1 , c1}
相乘可以得到第二部分

可以看出,複數乘法計算可以通過將z1中的實部取出與z2直接做乘法,再將z1中的虛部取出與z2的實部虛部交換之後的排列做乘法,最終再交錯進行加減得到最終結果。

儘管看起來操作十分繁瑣,不過幸運的是其實這些操作都可以找到相應的指令的來完成。

在immintrin.h頭文件中,有一個__m256d _mm256_permute_pd (__m256d a, int imm8)函數。

這個函數接收一個__m256d變量和一個4位的即時數(0-15的十進制字面值),根據imm8的每一個二進制位上的數來決定該a中的對應位置的數是保持不變還是替換爲其前後的數。最終得到一個新的__m256d變量,並返回。具體的規則如下:
IF (imm8[0] == 0) dst[63:0] := a[63:0]
IF (imm8[0] == 1) dst[63:0] := a[127:64]
IF (imm8[1] == 0) dst[127:64] := a[63:0]
IF (imm8[1] == 1) dst[127:64] := a[127:64]
IF (imm8[2] == 0) dst[191:128] := a[191:128]
IF (imm8[2] == 1) dst[191:128] := a[255:192]
IF (imm8[3] == 0) dst[255:192] := a[191:128]
IF (imm8[3] == 1) dst[255:192] := a[255:192]
基本原理就是兩個數爲一組,00,01,10,11分別代表這兩個數交換、保持不變或者前一個覆蓋後一個,後一個覆蓋前一個。

前面已經提到了,我們需要一個只包含z1實部的__m256d變量和一個只包含z1虛部的__m256d變量。

這裏令imm8=15即可完成由虛部覆蓋實部的替換,而實部覆蓋虛部可以通過令imm8=0來實現,不過這裏用_mm256_movedup_pd函數也可以達到相同的效果(這裏吐槽一下,在SSE3中,通過_mm_moveldup_ps可以直接完成4個單精度數中由奇數下標覆蓋偶數下標的操作,但是這個指令只有操作4個單精度浮點數纔有,其他情況都只支持偶數下標覆蓋奇數下標,反過來就不支持,難道4個單精度浮點數操作在硬件實現上有優勢?)這樣我們就可以得到相應的只包含實部和只包含虛部的_mm256d變量

接下來令imm8=10即可將z2中的兩個複數的實部和虛部調轉,這樣就成功得到了計算需要的四個_mm256d變量,然後就可以計算了。

FMA乘加融合可以一次性完成a * b + c的操作,這一指令相對於分開計算擁有更快的速度和更高的精度,同時在immintrin.h中還提供了_mm256_fmaddsub_pd可以完成對偶數下標位相加,奇數下標位相減的操作。

具體的實現如下:

void mul(complex<double> *x,complex<double> *y){
    __m256d tx,ty,r;
    tx=_mm256_loadu_pd((double*)(x));
    ty=_mm256_loadu_pd((double*)(y));
    r=_mm256_permute_pd(ty,5)*_mm256_permute_pd(tx,15);
    _mm256_storeu_pd((double*)(x),_mm256_fmaddsub_pd(ty,_mm256_movedup_pd(tx),r));
}

接下來測試一下效率

#define N 10000
int main(){
    complex<double> x[2],y[2];
    x[0]=complex<double>(1,0);
    x[1]=complex<double>(-1,0);
    y[0]=complex<double>(0,1);
    y[1]=complex<double>(0,-1);
    for(int j=0;j<N;j++){
        for(int i=0;i<N;i++){
            mul(x,y);  
        }
    }
    cout<<x[0]<<'\t'<<x[1]<<endl;  
    return 0;
}

使用g++ -O3優化,直接用標準庫中重載的 * 進行相乘用時爲:

(1,-0)	(-1,-0)

real	0m0.714s
user	0m0.714s
sys		0m0.000s

使用SIMD加速之後的mul函數的用時爲:

(1,-0)	(-1,-0)

real	0m0.489s
user	0m0.485s
sys		0m0.004s

可以看到使用SIMD加速之後複數計算的效率有了明顯的提高,因此也從側面說明對於數組相加的那個例子,由於計算簡單,編譯器早已做了相應的SIMD加速,自己去寫相應的操作並不能使速度加快。


矩陣轉置

最近測試了一下用SIMD實現矩陣轉置法現加速效果不錯
這裏先說一下算法,利用AVX指令集可以一次操作4個雙精度浮點數,因此通過讀取四個__m256d的數據,一次做一個4x4的方陣轉置,而對於大於4x4的方陣則可以先完成每一個小塊的轉置,再把子塊的位置進行轉置,對於非原址的轉置,直接把轉置完的子塊寫到對應的位置就可以了。

以4x4矩陣爲例,假設原始矩陣是
a0,a1,a2,a3
b0,b1,b2,b3
c0,c1,c2,c3
d0,d1,d2,d3
轉置完成後就應該是
a0,b0,c0,d0
a1,b1,c1,d1
a2,b2,c2,d2
a3,b3,c3,d3

這裏我們需要用到AVX和AVX2指令集中_mm256_unpacklo_pd、_mm256_unpackhi_pd、 _mm256_permute4x64_pd、 _mm256_blend_pd四個操作。

_mm256_unpacklo_pd接受兩個__m256d參數,並且將兩個傳入的參數中的第一、第三個數按順序組成新的__m256d返回,例如當傳入a0,a1,a2,a3和b0,b1,b2,b3時,則返回a0,b0,a2,b2

_mm256_unpackhi_pd與_mm256_unpacklo_pd基本相似,只不過是取得傳入值的第二、第四個數組合返回

_mm256_permute4x64_pd接受一個__m256d參數和一個8位二進制數,8位二進制數每兩個一組根據其在相應的8位二進制數中的位置,將傳入的__m256d的第幾位賦到返回的__m256d的第幾位,例如傳入a0,a1,a2,a3和0b11100100(轉換爲四個十進制數是3,2,1,0),那麼返回的值就是a3,a2,a1,a0,這裏需要注意一點是寄存器中的數據排列問題,如果我們要倒置內存中的整個序列實際上應該給的二進制數是0b00011011

_mm256_blend_pd接受兩個__m256d參數a和b,和一個四位二進制數,根據二進制數中每一位是1還是0,確定生成的新的__m256d對應位置是取a的值還是b的值,例如傳入a0,a1,a2,a3和b0,b1,b2,b3和0b0101則生成的數據應該a0,b1,a2,b3,同樣這裏也需要注意寄存器中數據的排列。

有了這四個函數我們就可以設計轉置的算法了,首先我們倒過來推
要得到a0,b0,c0,d0和a1,b1,c1,d1
我們考慮使用_mm256_unpacklo_pd和_mm256_unpackhi_pd同時生成兩個轉置後的序列
那麼我們就應該有a0,a1,c0,c1和b0,b1,d0,d1
我們發現a0,a1,c0,c1而可以將a0,a1,a2,a3和c2,c3,c0,c1通過_mm256_blend_pd生成
而a0,a1,a2,a3就是原始序列,c2,c3,c0,c1可以通過_mm256_permute4x64_pd來對c0,c1,c2,c3重排得到,而b0,b1,d0,d1也可以用同樣的方法得到。通過這樣重排、混洗我們就可以實現一個4x4的矩陣轉置,具體的代碼如下:

			double* x=(double*)_mm_malloc(sizeof(double)*4*4,32);
    		double* y=(double*)_mm_malloc(sizeof(double)*4*4,32);
			double *p1=x,*p2=x+4,*p3=x+8,*p4=x+12;
   			double *d1=x,*d2=x+4,*d3=x+8,*d4=x+12,
   			int k=0;    
	    	for(int i=0;i<4;i++)
	    	{
    	    	for(int j=0;j<4;j++)
  	      		{
 	           		x[k++]=i;
        		}
    		}
			__m256d s1,s2,s3,s4,t1,t2,t3,t4,t5,t6,t7,t8;
			s1=_mm256_load_pd(p1);
            s2=_mm256_load_pd(p2);
            s3=_mm256_load_pd(p3);
            s4=_mm256_load_pd(p4);
            t1=_mm256_permute4x64_pd(s1,0b01001110);//先對四個序列進行重排,把前兩位和後兩位調換a2,a3,a0,a1
            t2=_mm256_permute4x64_pd(s2,0b01001110);//b2,b3,b0,b1
            t3=_mm256_permute4x64_pd(s3,0b01001110);//c2,c3,c0,c1
            t4=_mm256_permute4x64_pd(s4,0b01001110);//d2,d3,d0,d1
            t5=_mm256_blend_pd(s1,t3,0b1100);//將一個調換過的序列和一個原序列進行合併用以完成最後一步混洗a0,a1,c0,c1
            t6=_mm256_blend_pd(s2,t4,0b1100);//b0,b1,d0,d1
            t7=_mm256_blend_pd(t1,s3,0b1100);//a2,a3,c2,c3
            t8=_mm256_blend_pd(t2,s4,0b1100);//b2,b3,d2,d3
            s1=_mm256_unpacklo_pd(t5,t6);//將合併的序列進行混洗,得到轉置序列a0,b0,c0,d0
            s2=_mm256_unpackhi_pd(t5,t6);//a1,b1,c1,d1
            s3=_mm256_unpacklo_pd(t7,t8);
            s4=_mm256_unpackhi_pd(t7,t8);
            _mm256_store_pd(d1,s1);
            _mm256_store_pd(d2,s2);
            _mm256_store_pd(d3,s3);
            _mm256_store_pd(d4,s4);
            k=0
            for(int i=0;i<N;i++)
    		{
        		for(int j=0;j<N;j++)
        		{
            		cout<<y[k++]<<'\t';
        		}
        		cout<<'\n';
    		}

這樣就完成了4x4的矩陣轉置,接下來就是進行大規模矩陣轉置測試一下性能了,以4096的方陣爲例,SIMD的轉置代碼如下

#include <immintrin.h>
#include <iostream>
using namespace std;
#define N 4096
int main()
{
    double* x=(double*)_mm_malloc(sizeof(double)*N*N,32);
    double* y=(double*)_mm_malloc(sizeof(double)*N*N,32);
    int k=0;    
    for(int i=0;i<16;i++)
    {
        for(int j=0;j<16;j++)
        {
            x[k++]=i;
        }
    }
    double *p1,*p2,*p3,*p4;
    double *d1,*d2,*d3,*d4,*t=y;
    d1=y;d2=y+N;d3=y+2*N;d4=y+3*N;
    p1=x;p2=x+N;p3=x+2*N;p4=x+3*N;
    t+=4;
    for(int i=0;i<N/4;i++)
    {
        for(int j=0;j<N/4;j++)
        {
            __m256d s1,s2,s3,s4,t1,t2,t3,t4,t5,t6,t7,t8;
            s1=_mm256_load_pd(p1);
            s2=_mm256_load_pd(p2);
            s3=_mm256_load_pd(p3);
            s4=_mm256_load_pd(p4);
            t1=_mm256_permute4x64_pd(s1,0b01001110);
            t2=_mm256_permute4x64_pd(s2,0b01001110);
            t3=_mm256_permute4x64_pd(s3,0b01001110);
            t4=_mm256_permute4x64_pd(s4,0b01001110);
            t5=_mm256_blend_pd(s1,t3,0b1100);
            t6=_mm256_blend_pd(s2,t4,0b1100);
            t7=_mm256_blend_pd(t1,s3,0b1100);
            t8=_mm256_blend_pd(t2,s4,0b1100);
            s1=_mm256_unpacklo_pd(t5,t6);
            s2=_mm256_unpackhi_pd(t5,t6);
            s3=_mm256_unpacklo_pd(t7,t8);
            s4=_mm256_unpackhi_pd(t7,t8);
            _mm256_store_pd(d1,s1);
            _mm256_store_pd(d2,s2);
            _mm256_store_pd(d3,s3);
            _mm256_store_pd(d4,s4);
            p1+=4;p2+=4;p3+=4;p4+=4;
            d1+=4*N;d2+=4*N;d3+=4*N;d4+=4*N;
        }
        p1+=3*N;p2+=3*N;p3+=3*N;p4+=3*N;
        d1=t;d2=t+N;d3=t+2*N;d4=t+3*N;
        t+=4;
    } 
	cout<<y[100]<<'\n';
	/*
    k=0;
    for(int i=0;i<N;i++)
    {
        for(int j=0;j<N;j++)
        {
            cout<<y[k++]<<'\t';
        }
        cout<<'\n';
    }
	*/   
    return 0;
}

而原始代碼如下:

#include <immintrin.h>
#include <iostream>
using namespace std;
#define N 4096
int main()
{
    double* x=(double*)_mm_malloc(sizeof(double)*N*N,32);
    double* y=(double*)_mm_malloc(sizeof(double)*N*N,32);
    int k=0;    
    for(int i=0;i<N;i++)
    {
        for(int j=0;j<N;j++)
        {
            x[k++]=i;
        }
    }
    double *s=x,*d=y,*t=y;
    t++;
    for(int i=0;i<N;i++)
    {
        for(int j=0;j<N;j++)
        {
            *d=*(x++);
            d+=N;
        }
        d=t;
        t++;    
    }
cout<<y[100]<<'\n';
/*
    k=0;
    for(int i=0;i<N;i++)
    {
        for(int j=0;j<N;j++)
        {
            cout<<y[k++]<<'\t';
        }
        cout<<'\n';
    }
*/   
    return 0;
}

測試均使用-O2優化編譯,最終測試結果如下:
未使用SIMD

real	0m0.453s
user	0m0.393s
sys		0m0.061s

使用SIMD

real	0m0.147s
user	0m0.087s
sys		0m0.060s

可以看到通過SIMD加速,4096的方陣轉置的性能提高了將近3倍。


線性同餘生成僞隨機數

最近需要用SIMD做一個隨機數生成器,本來以爲很容易的,結果發現AVX的整型運算全是坑,折騰了一天可算做出來一個生成-1到1均勻分佈的雙精度浮點數生成器。

線性同餘就不多說了,利用seed=(seeda+c) mod mseed=(seed*a+c)\ mod\ m不斷更新種子就可以得到0到m-1的均勻分佈的僞隨機數了。這裏取m=2321,a=513,c=9973m=2^{32}-1,a=513,c=9973

因爲之前都是處理的浮點數,計算沒有什麼特別奇怪的地方,本以爲整型也就照着寫就好了。結果發現一通寫之後,結果差了十萬八千里。首先對於整型AVX-512纔開始支持類似浮點型的load、store指令(函數),但是我又沒有支持AVX-512的機器。要用AVX讀寫整型需要使用mask_load和mask_store,這兩個函數除了正常laod和store需要的地址和ymm寄存器變量以外,還需要一個額外的mask參數,用來控制哪些位置的整型數據讀寫,哪些位置不讀寫直接置零。這個mask也是個ymm寄存器變量,按照mask參數中各個位置數字的最高位是1還是0來決定是不是對數據進行讀寫。這就意味着相應位置給負數則讀寫相應位置,給0或者整數都會直接對相應位置置零。所以如果要將1,2,3,4四個種子讀進去需要按照如下操作進行。

unsigned t[8]={1,1,2,2,3,3,4,4};
__m256i k=_mm256_maskload_epi32((int*)t,_mm256_set1_epi32(-1));

讀完種子下一步就可以用線性同餘生成隨機數了。AVX的整型乘法函數只有兩個_mm256_mul_epu32和_mm256_mul_epi32,i表示有符號,u表示無符號,其他的沒區別,這個乘法並不是把__m256i中的8個int或者unsigned int對應相乘,而是把數據看成4個無符號整型,然後把高位截斷,只留下低32位進行相乘,然後返回存有4個64位的有符號或者無符號整型的__m256i。不過這裏問題不大,也就是少生成4個隨機數罷了,計算得到結果可以不同做任何修改繼續計算新的隨機數。

void Rand(__m256i &seed)
{
	seed=_mm256_mul_epu32(seed,_mm256_set1_epi32(513));
	seed=_mm256_add_epi64(seed,_mm256_set1_epi64x(9973));
	seed=_mm256_and_si256(seed,_mm256_set1_epi64x(4294967295));
}

其實如果單純只是需要無符號整型隨機數這個函數就夠了。但是由於我需要的是一個-1到1均勻分佈的浮點數,所以這個結果還要進一步處理。然而4個無符號的___m256i轉到__m256d的函數需要AVX512的支持,而4個64位無符號__m256i轉成4個無符號的__m128i的函數完全找不到。但是在cast函數中有一個強制轉換__m256i到__m128i的函數,這個函數直接強制截斷一半__m256i生成新的__m128i。但是由於seed中如果看做8個32位整數,則我們生成的隨機數是在0,2,4,6四個位置上的,直接截斷就只剩兩個數了。這就需要對數據進行重排,然後又要使用permute函數了。這裏使用_mm256_permutevar8x32_epi32函數進行重排,這個函數很好用,確定每個位置放置哪個原始位置的數是通過__m256i變量來確定的,直接給一個__mm256_set_epi32(p1,p2,…,p8)就行了(p1,p2,…,p8就是對應位置上需要放置的數據編號,例如要倒置就是7,6,5,4,3,2,1,0這裏注意大端小端的問題,具體是多少需要測試,這裏只是示意)。這樣就可以把四個隨機數集中到前面128位了,然後直接截斷就可以得到包含4個32位整型的__m128i變量。然後這個變量可以通過cvt轉換成包含4個雙精度的__m256d變量,再做一次除法即可生成4個-1到1均勻分佈的雙精度浮點數(這裏雙精度只是數據類型,實際隨機數的精度只有1/232{1}/{2^{32}})。這裏需要注意的是這個轉換雙精度浮點數是按有符號讀取的,因此化爲-1到1時除掉的整數,是Rand函數中m值的一半。

Rand(k);
__m256i rd=_mm256_permutevar8x32_epi32(k,_mm256_set_epi32(0,0,0,0,0,2,4,6));
__m128i rrd=_mm256_castsi256_si128 (rd);
__m256d u=_mm256_cvtepi32_pd(rrd);
_mm256_storeu_pd(x,u/_mm256_set1_pd(4294967295));

完整的代碼如下

#include <iostream>
#include <immintrin.h>
#include <cstdlib>
#include <ctime>
using namespace std;
void Rand(__m256i &seed)
{
	seed=_mm256_mul_epu32(seed,_mm256_set1_epi32(513));
	seed=_mm256_add_epi64(seed,_mm256_set1_epi64x(9973));
	seed=_mm256_and_si256(seed,_mm256_set1_epi64x(4294967295));
}
int main()
{
	unsigned t[8]={1,1,2,2,3,3,4,4},tj[16]={};
	double x[4];
	__m256i k=_mm256_maskload_epi32((int*)t,_mm256_set1_epi32(1));
    auto s=clock();	
    for(int i=0;i<10000;i++)
	{
		Rand(k);
		__m256i rd=_mm256_permutevar8x32_epi32(k,_mm256_set_epi32(0,0,0,0,0,2,4,6));
		__m128i rrd=_mm256_castsi256_si128 (rd);
		__m256d u=_mm256_cvtepi32_pd(rrd);
		_mm256_storeu_pd(x,u/_mm256_set1_pd(2147483647));
		for(int j=0;j<4;j+=1) 
		{
			cout<<x[j]<<'\t';
			//tj[int(x[j]*8+8)]++;
		}
		cout<<'\n';
	}
    auto e=clock();
    for(int i=0;i<40000;i++) 
    {   for(int j=0;j<4;j++)cout<<double(rand())/RAND_MAX<<'\t';
        cout<<'\n';
    }
    auto e1=clock();
    cout<<"time = "<<double(e-s)/CLOCKS_PER_SEC<<'\t'<<double(e1-e)/CLOCKS_PER_SEC;
	for(int i=0;i<16;i++) cout<<tj[i]<<'\n';
	return 0;
}

這裏用tj做一個了統計,測試生成的隨機數是不是均勻分佈的,然後還和用標準庫的rand函數生成相同數量的隨機數用時的差距。

單獨進行統計得到的結果如下

2588
2532
2536
2448
2592
2416
2572
2788
2688
2420
2420
2396
2356
2356
2668

可以看到把-1到1均分爲16份對40000個樣本進行統計,落在各個區間的隨機數的個數基本在2500附近,證明這個計算過程沒有太大問題。

然後看一下效率,由於不打印到控制檯,開了優化之後計算過程會被直接優化掉,而打印到控制檯,打印又異常耗時,這裏就簡單測一下。把輸出結果直接重定向到文件中,這樣輸出的時間會短一些,對結果的影響稍小。使用gcc的O3優化,用時統計如下

0.020918s(使用AVX)
0.0904440s(標準庫rand函數)

可以看到使用AVX之後生成隨機數的速度明顯變快了。

由於本人水平也有限,不能保證測試一定正確。最近也在學習SIMD,後續有時間我也會考慮再加上一些別的簡單使用的測試,補充完善一些。另外我在看張先軼老師的一個openblas矩陣乘法優化講座時,看到了SIMD的使用,我跟着也寫過一個,實測是有一定的效率提升的,具體代碼和測試在我會寫在後面的文章中的。

上一篇:數值計算優化方法C/C++(二)——表達式模板
下一篇:數值計算優化方法C/C++(四)——矩陣乘法優化示例(訪存優化和SIMD的使用)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章