SSE指令集優化學習:雙線性插值

from:http://www.cnblogs.com/wangguchangqing/p/5445652.html

對SSE的學習總算邁出了第一步,用2天時間對雙線性插值的代碼進行了優化,現將實現的過程梳理以下,算是對這段學習的一個總結。

1. 什麼是SSE

說到SSE,首先要弄清楚的一個概念是SIMD(單指令多數據流,Single Instruction Multiple Data),是一種數據並行技術,能夠在一條指令中同時對多個數據執行運算操作,增加處理器的數據吞吐量。SIMD特別的適用於多媒體應用等數據密集型運算。

1.1 歷史

1996年Intel首先推出了支持MMX的Pentium處理器,極大地提高了CPU處理多媒體數據的能力,被廣泛地應用於語音合成、語音識別、音頻視頻編解碼、圖像處理和串流媒體等領域。但是MMX只支持整數運算,浮點數運算仍然要使用傳統的x87協處理器指令。由於MMX與x87的寄存器相互重疊,在MMX代碼中插入x87指令時必須先執行EMMS指令清除MMX狀態,頻繁地切換狀態將嚴重影響性能。這限制了MMX指令在需要大量浮點運算的程序,如三維幾何變換、裁剪和投影中的應用。
另一方面,由於x87古怪的堆棧式緩存器結構,使得硬件上將其流水線化和軟件上合理調度指令都很困難,這成爲提高x86架構浮點性能的一個瓶頸。爲了解決以上這兩個問題,AMD公司於1998年推出了包含21條指令的3DNow!指令集,並在其K6-2處理器中實現。K6-2是 第一個能執行浮點SIMD指令的x86處理器,也是第一個支持水平浮點寄存器模型的x86處理器。藉助3DNow!,K6-2實現了x86處理器上最快的浮點單元,在每個時鐘週期內最多可得到4個單精度浮點數結果,是傳統x87協處理器的4倍。許多遊戲廠商爲3DNow!優化了程序,微軟的DirectX 7也爲3DNow!做了優化,AMD處理器的遊戲性能第一次超過Intel,這大大提升了AMD在消費者心目中的地位。K6-2和隨後的K6-III成爲市場上的熱門貨。
1999年,隨着Athlon處理器的推出,AMD爲3DNow!增加了5條新的指令,用於增強其在DSP方面的性能,它們被稱爲“擴展3DNow!”(Extended 3DNow!)。
爲了對抗3DNow!,Intel公司於1999年推出了SSE指令集。SSE幾乎能提供3DNow!的所有功能,而且能在一條指令中處理兩倍多的單精度浮點數;同時,SSE完全支持IEEE 754,在處理單精度浮點數時可以完全代替x87。這迅速瓦解了3DNow!的優勢。
1999年後,隨着主流操作系統和軟件都開始支持SSE併爲SSE優化,AMD在其2000年發佈的代號爲“Thunderbird”的Athlon處理器中添加了對SSE的完全支持(“經典”的Athlon或K7只支持SSE中與MMX有關的部分,AMD稱之爲“擴展MMX”即Extended MMX)。隨後,AMD致力於AMD64架構的開發;在SIMD指令集方面,AMD跟隨Intel,爲自己的處理器添加SSE2和SSE3支持,而不再改進3DNow!。
2010年八月,AMD宣佈將在新一代處理器中取消除了兩條數據預取指令之外3DNow!指令的支持,並鼓勵開發者將3DNow!代碼重新用SSE實現。

1.2 MMX和SSE

MMX 是Intel在Pentium MMX中引入的指令集。其缺點是佔用浮點數寄存器進行運算(64位MMX寄存器實際上就是浮點數寄存器的別名)以至於MMX指令和浮點數操作不能同時工作。爲了減少在MMX和浮點數模式切換之間所消耗的時間,程序員們儘可能減少模式切換的次數,也就是說,這兩種操作在應用上是互斥的。後來Intel在此基礎上發展出SSE指令集;AMD在此基礎上發展出3D Now指令集。
SSE(Streaming SIMD Extensions)是Intel在3D Now!發佈一年之後,在PIII中引入的指令集,是MMX的超集。AMD後來在Athlon XP中加入了對這個指令集的支持。這個指令集增加了對8個128位寄存器XMM0-XMM7的支持,每個寄存器可以存儲4個單精度浮點數。使用這些寄存器的程序必須使用FXSAVE和FXRSTR指令來保持和恢復狀態。但是在PIII對SSE的實現中,浮點數寄存器又一次被新的指令集佔用了,但是這一次切換運算模式不是必要的了,只是SSE和浮點數指令不能同時進入CPU的處理線而已。
SSE2是Intel在P4的最初版本中引入的,但是AMD後來在Opteron 和Athlon 64中也加入了對它的支持。這個指令集添加了對64位雙精度浮點數的支持,以及對整型數據的支持,也就是說這個指令集中所有的MMX指令都是多餘的了,同時也避免了佔用浮點數寄存器。這個指令集還增加了對CPU的緩存的控制指令。AMD對它的擴展增加了8個XMM寄存器,但是需要切換到64位模式(AMD64)纔可以使用這些寄存器。Intel後來在其EM64T架構中也增加了對AMD64的支持。
SSE3是Intel在P4的Prescott版中引入的指令集,AMD在Athlon 64的第五個版本中也添加了對它的支持。這個指令集擴展的指令包含寄存器的局部位之間的運算,例如高位和低位之間的加減運算;浮點數到整數的轉換,以及對超線程技術的支持。

2 雙線性差值的優化

上面的多半是粘貼的,是前期學習SSE的資料蒐集,算是對SSE的由來有一個大致的瞭解。下面介紹對雙線性插值的優化的學習過程。

2.1 雙線性插值

在圖像變換時,變換後圖像的像素映射到源圖像上的座標有可能是一個浮點座標,插值算法就是要計算出浮點座標像素近似值。那麼要如何計算浮點座標的近似值呢。一個浮點座標必定會被四個整數座標所包圍,將這個四個整數座標的像素值按照一定的比例混合就可以求出浮點座標的像素值。混合比例爲距離浮點座標的距離,這就是雙線性插值的基本思想。關於雙線性插值的更多信息可以參見WIKI和 本人博文
要優化的雙線性插值的C++實現

    //計算縮放後的圖像大小
    dstWidth = static_cast<int>(width * fx);
    dstHeight = static_cast<int>(height * fy);

    depth /= 8;
    int dstSize = dstWidth * dstHeight * depth;
    dst = new byte[dstSize];
    memset(dst, 255, dstSize);

    byte* dstPixel = nullptr;

    double x = 0.0f; //縮放後圖像在映射到原圖像的橫座標
    double y = 0.0f; //映射到原圖像的橫座標

    for (int j = 0; j < dstHeight; j++)
    {
        y = j / fy;

        for (int i = 0; i < dstWidth; i++)
        {
            x = i / fx;

            dstPixel = dst + (j * dstWidth + i) * depth;

            //計算距離當前映射點(x,y)最近的4個點
            int x1, y1, x2, y2;
            x1 = static_cast<int>(x);
            x2 = x1 + 1;
            y1 = static_cast<int>(y);
            y2 = y1 + 1;

            double u = x - x1; //映射點和最左邊橫座標的差值
            double v = y - y1; //映射點和最下邊縱座標的差值

            byte cltr1, cltr2, cltr3, cltr4;
            //分別計算各個通道的分量
            for (int k = 0; k < depth; k++)
            {
                    cltr1 = src[(y1 * width + x1) * depth + k]; // x1,y1
                    cltr2 = src[(y1 * width + x2) * depth + k]; // x2,y1
                    cltr3 = src[(y2 * width + x1) * depth + k]; // x1,y2
                    cltr4 = src[(y2 * width + x2) * depth + k]; // x2,y2

                    double f1, f2;
                    f1 = u * cltr1 + (1-u) * cltr2;
                    f2 = u * cltr3 + (1-u) * cltr4;
                    dstPixel[k] = static_cast<byte>(v * f1 + (1-v) * f2); 
            }
        }
    }

優化的思路,使用128位的xmm寄存器,可以同時對4個32位的數據進行運算,選擇同時對一個像素的所有通道進行處理(3通道和4通道)。優化後的運行速度,因爲主要是用來學習SSE指令的,沒有很做很嚴謹的對比,在debug下大致是3倍速(實驗用的圖像是24位3通道),在release下優化和優化的速度沒有明顯的提升,編譯器的優化就是牛哇...。
下面就開始介紹用SSE實現雙線性插值的過程,主要分爲以下幾個步驟:

  1. 數據的移動
  2. 數據的組織:pack,unpck,shuffle
  3. 運算

2.2 數據的移動

使用SSE的第一步就是需要將要處理的數據從內存複製到xmm寄存器中。SSE的mov指令可謂無花八門,可以mov不同長度的數據到xmm寄存器,另外有的mov指令需要內存16位對齊,有的則不需要。SSEmov指令可以將數據在xmm寄存器和內存之間進行移動,也可以用於xmm寄存器之間。下面列舉幾個常用mov指令:

  1. movd 移動雙字到xmm寄存器
  2. movq 移動四字到xmm寄存器
  3. movapd / movaps 移動對齊的pack的雙精度浮點數/單精度浮點數
  4. movupd / movups 移動未對齊的pack的雙精度浮點數/單精度浮點數
  5. movdqa 移動對齊的雙四字
  6. movdqu 移動未對齊的雙四字
    SSE的數據移動指令很多,這裏只列舉一些,具體的可以參考Intel開發者手冊。
    在雙線性插值中,由於一個浮點座標要使用其附近的4個像素近似得到,利用SSE指令同時計算這個4個像素的座標,計算公式如下:

    (yi∗width+xi)∗depth,i=1,2j=1,2

    首先將這需要用到的數據移動到xmm寄存器中

    movd xm0,width
    movd xmm1,y1
    movd xmm2,y2

    width和y1,y2都是32位的整型,所以使用movd將其移動到xmm中。

2.3 數據的組織

數據的組織可以說是使用SSE指令的第一個難點,因爲128位寄存器可以同時對4個32位數據進行運算,就需要按照該一定的順序將數據放在寄存器中。SSE中提供了兩種指令來調整數據在xmm寄存器中的順序

  1. unpck 交叉組合。
  2. shuffle 亂序
2.3.1 unpck

unpck是將源操作數和目的操作中字(雙字,四字具體的交叉單位依賴於不同的指令)重新組合。
unpcklps32位單精度浮點數低位交叉 (unpckhps 32位浮點數高位交叉)

unpckhpd 64位浮點數高位交叉 (unpcklpd 64位浮點數低位交叉)

低位的unpck是按照一定的長度(根據指令的不同,一般有字/雙字/四字,字長爲16)取兩操作數的低位數據進行重組,重組後的數據的高位是源操作數的低位,低位是目的操作數的低位。重組的結果保存在目的操作數中。
除了上述的unpck指令外,常用的交叉指令還有

  1. punpcklbw 交叉組合低位4字中的字節
  2. punpcklwd 交叉組合低位雙字中字
  3. punpckldq 交叉組合低位四字中的雙字
  4. punpcklqdq 交叉組合低位四字
    利用上面提到的交叉指令,可以將byte變成16位的整型,16位的整型變成32爲整型,32位整型變成64位整型,後面有用到再詳述。
2.3.2 shuffle

shuffle 指令功能更贊,它能夠將xmm寄存器中的位調整爲我們需要的順序。這裏以pshufd爲例進行介紹。
pshufd xmm1,xmm2,imm8
pshufd有三個操作數,從左往右,第一個操作數是目的操作數保存結果,第二個操作數是源操作數,第三個操作數是一個8位立即數,指定以怎樣的順序將源操作數中數據保存到目的操作數。
imm8中每2位選擇源操作數的一個雙字(共4個),00選擇第一個,01選擇第二個,10選擇第三個,11選擇第四個。利用imm8位模式選擇好源操作數的雙字後,其每2位的位段決定着這些雙字如何在目的操作數中排列。[0-1]位選擇的雙字放在目的操作數的[0-31],[2-3]選擇的放在[32-63],[4-5]選擇的放在[64,95],[6-7]選擇的放在[96-127]中。
具體如下圖

例如:

_MM_ALIGN16 int a[4] = {1,2,3,4}; //要變成2,4,3,1的順序

_asm
{
    movdqa xmm0,[a];
    pshufd xmm0,xmm0,0x2D; // 0010 1101   
}

0x2d 就是0010 1101
將從源操作數選擇的雙字 從左到右,由高到底依次存放在目的操作數
00 選擇 1,放在[96-127]
10 選擇 3, 放在[64-95]
11 選擇 4, 放在[32-63]
01 選擇 2, 放在[0-31]

2.3.3 同時計算 y1 * width 和 y2 * width

上面說128位的寄存器可以同時進行4個32位數據的計算,這裏要將乘法排除在外,因爲32位 32位需要64位空間才能結果不溢出。當然在保證結果不溢出的前提下,也可以使用32位存放相乘的結果。(一定要在保證結果不溢出的前提下,才能如此處理。我在寫這段程序時,開始用16位保存的相乘結果,最後產生了溢出,調試了好久才找到錯誤所在。)
要同時運算y1 
width 和 y2 * width,就需要將一個寄存器中存放width ,width,一個寄存器存放y1,y2

                    punpckldq xmm0, xmm0; // xmm0 => width,width 低64位
                    movd xmm1, y1;
                    movd xmm2, y2;
                    punpckldq xmm1, xmm2; // xmm1 =>y1,y2 低64位  

執行上面指令後,還是不能進行計算。這是因爲 32位的乘法產生64位的結果,也就是
src[0-31] * dst[0-31] => src[0-64]
src[64-95] * dst[64-95] => src[64-127]
所以要調整順序 xmm0中數據的順序爲 width 0 width 0 ,xmm1中順序爲 y1 0 y2 0 這就要用到上面提到的shuffle指令

                    pshufd xmm1, xmm1, 0xd8; // 0x1101 1000
                    pshufd xmm0, xmm0, 0xd8;
                    pmuludq xmm0, xmm1;  

pmuludq 是乘法指令。xmm0得到的是兩個64位的數據,這裏是做圖像縮放用32位存放結果也就足夠了。(因爲 這裏計算的是圖像中某個像素的通道的索引,即使是4k * 4k * 4 = 0x400 0000,32位足夠保存了)。
得到y1 * width 和 y2 * width後,進行交叉後可以4個一起同時和x1,x2相加

                    unpcklpd xmm0, xmm0; // xmm0 y1*width y2*width y1*width y2*width
                    movd xmm1, x1;
                    punpckldq xmm1, xmm1; // xmm1 => x1,x1
                    movd xmm2, x2;
                    punpckldq xmm2, xmm2; // xmm2 => x2,x2
                    unpcklpd xmm1, xmm2; // xmm1 => x1,x1,x2,x2
                    paddd xmm0, xmm1; // xmm0 => y1*width + x1,y2*width + x1,y1*width+x2,y2*width+x2

下面需要計算 (y∗width+x)∗depth ,128位寄存器只能同時計算2個32位的乘法,所以講xmm0中的數據拆分放在兩個xmm寄存器中

                    pshufd xmm1, xmm0, 0x50; // xmm1 => (x1,y1) (x1,y2)
                    pshufd xmm2, xmm0, 0xfa; // xmm2 => (x2,y1) (x2,y2)

然後就和上面的類似過程,將得到的64位結果只取低32位,然後在一起放在同一個xmm寄存器中

                    movd xmm3, depth;
                    punpckldq xmm3, xmm3;
                    unpcklpd xmm3, xmm3;

                    pmuludq xmm1, xmm3;
                    pmuludq xmm2, xmm3;

                    pshufd xmm1, xmm1, 0xd8;
                    pshufd xmm2, xmm2, 0xd8;
                    punpcklqdq xmm1, xmm2;

到這裏 xmm1中就存放着要求的浮點座標周圍的4個像素的位置,依次爲(x1,y1) (x1,y2) (x2,y1) (x2,y2)。將這四個值放在數組中,在後面取像素值時會用到

    //像素的地址偏移量
    __declspec(align(16)) int bitOffset[] = { 0,0,0,0 };
    movdqa[bitOffset], xmm1;//(x1,y1) (x1,y2) (x2,y1) (x2,y2)
2.3.4 位擴展

在對像素的通道進行處理時,讀取的數據是8位,而通常在處理時需要運算是32位的或者64位的(本文中和通道數據做運算的是32位浮點數),這就需要將8位的通道數據進行擴展。
在上面也提到可以利用punpcklbw punpcklwd punpckldq對8位字節進行擴展。下面的SSE指令將8位的通道數據轉換爲32位的整型

                    // 取出像素依次存放在xmm0,xmm1,xmm2,xmm3
                    lea eax, bitOffset; // 取出數組地址
                    mov edx, [eax];
                    mov ecx, src;
                    movd xmm0, [ecx + edx]; //三通道像素,讀取了32位,只需要處理24位
                    //將xxm0中的低位4個字節擴展爲32位整數(只處理低3個32位整數)
                    psllw xmm1, 16;  //將xxm1置爲0
                    punpcklbw xmm0, xmm1;
                    punpcklwd xmm0, xmm1; // 擴展爲32位整數 

punpcklbw 交叉組合低位四字中的字節,如果源操作數爲0,則可以將低位的8位字節擴展爲16位整型。
psllw 是以字爲單位(16位)的邏輯左移指令,立即數16是左移的位數,由於是以字爲單位左移16位,所以該指令可以將xmm1清0。
'punpcklwd' 交叉組合低位四字中的字,如果源操作數爲0,則可以將低位的16位整型擴展爲32位整型。

2.3.5 數據類型轉換

數據類型的轉換主要在雙精度64位浮點數和單精度32位浮點64位浮點數和32位整型以及32位浮點數和32位整型之間,具體的轉換指令如下圖

指令前綴爲CVTT的表示帶截斷的類型轉換。
在上面,已將三通道的24爲像素數據轉換爲3個32位的整型數據存放在xmm寄存器中,但是下面要做的運算是浮點型的,還需要將32位整型轉換爲32位的浮點數

cvtdq2ps xmm0, xmm0; //32位整型轉換爲單精度浮點數 

#### 2.4 運算
SSE指令集能夠同時在多個數據上執行同一個操作,上面做了那麼多操作,就是要將參與運算的數據以需要的格式存放到xmm寄存器中,然後對這些數據同時執行相同的操作。SSE指令集對下面三類常用的運算都有提供

  1. 算術運算
    爲了適應各種樣的運算環境,SSE也提供了多種樣的算術運算。
    pmuludq無符號雙字乘法,源操作數和目的操作數的第一個和第三個32位無符號整型參與運算,結果爲2個64位整型,存放在目的操作數。這條指令是將乘法的結果依次給出了,但是隻能同時進行2個32位的運算(結果爲64位)。而有可能我們只關係乘法結果的某一部分(例如高位,判斷兩個有符號數的符號是否相同)。
    pmullw pack有符號字乘法取低位,16位的乘法運算,將32位結果的低16位保存到目的操作數。

pmulhw pack有符號字乘法取高位,16位的乘法運算,將32位結果的高16位保存到目的操作數。

  1. 邏輯
  2. 位移
  3. 比較
    具體的指令這裏就不再贅述了,可以參考Intel開發者手冊。

3 小結

初次的學習總結就到這裏了。使用SSE指令集可以總結爲三步:

  1. 將數據從內存移動到xmm寄存器,這裏注意指令是否要求16或者32位對齊。
  2. 利用各種unpckshuffle指令,對xmm寄存器中的數據進行操作,將其組織成後面運算需要的格式。
  3. 對組織好的數據執行運算,注意數據的溢出以及計算的數據類型(浮點、整型、有符號還是無符號),數據的長度(字,雙字,四字,雙四字)以及指令操作的是高位還是低位等。總之運算時要特別小心,不像在高級語言中那麼的省心。
    SSE指令集提供了很豐富的操作,但是Intel的手冊查閱卻不是很方便....。另外,直接在C/C++中內嵌彙編代碼,感覺也很拖沓,下一步準備學習下編譯器提供的Intrinsics

大多數的函數是在庫中,Intrinsic Function卻內嵌在編譯器中(built in to the compiler)。

1. Intrinsic Function

Intrinsic Function作爲內聯函數,直接在調用的地方插入代碼,即避免了函數調用的額外開銷,又能夠使用比較高效的機器指令對該函數進行優化。優化器(Optimizer)內置的一些Intrinsic Function行爲信息,可以對Intrinsic進行一些不適用於內聯彙編的優化,所以通常來說Intrinsic Function要比等效的內聯彙編(inline assembly)代碼快。優化器能夠根據不同的上下文環境對Intrinsic Function進行調整,例如:以不同的指令展開Intrinsic Function,將buffer存放在合適的寄存器等。
使用 Intrinsic Function對代碼的移植性會有一定的影響,這是由於有些Intrinsic Function只適用於Visual C++,在其他編譯器上是不適用的;更有些Intrinsic Function面向的是特定的CPU架構,不是全平臺通用的。上面提到的這些因素對使用Intrinsic Function代碼的移植性有一些不好的影響,但是和內聯彙編相比,移植含有Intrinsic Function的代碼無疑是方便了很多。另外,64位平臺已經不再支持內聯彙編。

2. SSE Intrinsic

VS和GCC都支持SSE指令的Intrinsic,SSE有多個不同的版本,其對應的Intrinsic也包含在不同的頭文件中,如果確定只使用某個版本的SSE指令則只包含相應的頭文件即可。


引用自:http://www.cnblogs.com/zyl910/archive/2012/02/28/vs_intrin_table.html

例如,要使用SSE3,則

#include <tmmintrin.h>

如果不關心使用那個版本的SSE指令,則可以包含所有

#include <intrin.h> 

2.1 數據類型

Intrinsic使用的數據類型和其寄存器是想對應,有

  • 64位 MMX指令集使用
  • 128位 SSE指令集使用
  • 256位 AVX指令集使用

甚至AVX-512指令集有512位的寄存器,那麼相對應Intrinsic的數據也就有512位。
具體的數據類型及其說明如下:

  1. **__m64** 64位對應的數據類型,該類型僅能供MMX指令集使用。由於MMX指令集也能使用SSE指令集的128位寄存器,故該數據類型使用的情況較少。
  2. **__m128 / __m128i / __m128d** 這三種數據類型都是128位的數據類型。由於SSE指令集即能操作整型,又能操作浮點型(單精度和雙精度),這三種數據類型根據所帶後綴的不同代表不同類型的操作數。__m128是單精度浮點數,__m128i是整型,__m128d是雙精度浮點數。

256和512的數據類型和128位的類似,只是存放的個數不同,這裏不再贅述。
知道了各種數據類型的長度以及其代碼的意義,那麼它的表現形式到底是怎麼樣的呢?看下圖

__m128i yy;

yy是__m128i型,從上圖可以看出__m128i是一個聯合體(union),根據不同成員包含不同的數據類型。看其具體的成員包含了8位、16位、32位和64位的有符號/無符號整數(這裏__m128i是整型,故只有整型的成員,浮點數的使用__m128)。而每個成員都是一個數組,數組中填充着相應的數據,並且根據數據長度的不同數組的長度也不同(數組長度 = 128 / 每個數據的長度(位))。在使用的時候一定要特別的注意要操作數據的類型,也就是數據的長度,例如上圖同一個變量yy當作4個32位有符號整型使用時其數據是:0,0,1024,1024;但是當做64位有符號整型時其數據爲:0,4398046512128,大大的不同。
在MSVC下可以使用yy.m128i_i32[0]取出第一個32位整型數據,原生的Intrinsic函數是沒有提供該功能的,這是在MSVC的擴展,比較像Microsoft的風格,使用及其的方便但是效率很差,所以這種方法在GCC/Clang下面是不可用的。在MSVC下面可以根據需要使用不使用這種抽取數據的方法,但是這種功能在調試代碼時是非常方便的,如上圖可以很容易的看出128位的數據在不同數據類型下其值的不同。

2.2 Intrinsic 函數的命名

Intrinsic函數的命名也是有一定的規律的,一個Intrinsic通常由3部分構成,這個三個部分的具體含義如下:

  1. 第一部分爲前綴_mm,表示是SSE指令集對應的Intrinsic函數。_mm256或_mm512是AVX,AVX-512指令集的Intrinsic函數前綴,這裏只討論SSE故略去不作說明。
  2. 第二部分爲對應的指令的操作,如_add,_mul,_load等,有些操作可能會有修飾符,如loadu將未16位對齊的操作數加載到寄存器中。
  3. 第三部分爲操作的對象名及數據類型,_ps packed操作所有的單精度浮點數;_pd packed操作所有的雙精度浮點數;_pixx(xx爲長度,可以是8,16,32,64)packed操作所有的xx位有符號整數,使用的寄存器長度爲64位;_epixx(xx爲長度)packed操作所有的xx位的有符號整數,使用的寄存器長度爲128位;_epuxx packed操作所有的xx位的無符號整數;_ss操作第一個單精度浮點數。....

將這三部分組合到以其就是一個完整的Intrinsic函數,如_mm_mul_epi32 對參數中所有的32位有符號整數進行乘法運算。

SSE指令集對分支處理能力非常的差,而且從128位的數據中提取某些元素數據的代價又非常的大,因此不適合有複雜邏輯的運算。

3. Intrinsic版雙線性插值

在上一篇文章SSE指令集優化學習:雙線性插值 使用SSE彙編指令對雙線性插值算法進行了優化,這裏將其改成爲Intrinsic版的。

3.1 計算 (y * width + x) * depth

目的像素需要其映射到源像素周圍最近的4個像素插值得到,這裏同時計算源像素的最近的4個像素值的偏移量。

                __m128i wwidth = _mm_set_epi32(0, width, 0, width);
                __m128i yy = _mm_set_epi32(0, y2, 0, y1);
                yy = _mm_mul_epi32(yy, wwidth);  //y1 * width 0 y2 *width 0
                yy = _mm_shuffle_epi32(yy, 0xd8); // y1 * width y2 * width 0 0        
                yy = _mm_unpacklo_epi32(yy, yy); // y1 * width y2 * width y1 * width y2 * width
                yy = _mm_shuffle_epi32(yy, _MM_SHUFFLE(3, 1, 2, 0));
                __m128i xx = _mm_set_epi32(x2, x2, x1, x1);
                xx = _mm_add_epi32(xx, yy); // (x1,y1) (x1,y2) (x2,y1) (x2,y2)
                __m128i x1x1 = _mm_shuffle_epi32(xx, 0x50); // (x1,y1) (x1,y2)
                __m128i x2x2 = _mm_shuffle_epi32(xx, 0xfa); // (x2,y1) (x2,y2) 
  1. 使用set函數將需要的數據填充到__m128Intel中
  2. mul函數進行乘法運算,兩個32位的整型相乘的結果是一個64位整型。
  3. 由於計算的是像素的偏移量,使用32位整型也就足夠了,使用shffule對__m128i中的數據進行重新排列,使用unpack函數再重新組合,將數據組合爲需要的結構。
  4. _MM_SHUFFLE是一個宏,能夠方便的生成shuffle中所需要的立即數。例如

    _mm_shuffle_epi32(yy,_MM_SHUFFLE(3,1,2,0);

    將yy中存放的第2和第3個32位整數交換順序。

3.2 數據類型的轉換

SSE彙編指令和其Intrinsic函數之間基本存在這一一對應的關係,有了彙編的實現再改爲Intrinsic是挺簡單的,再在這羅列代碼也乜嘢什麼意義了。這裏就記錄下使用的過程中遇到的最大的問題:數據類型之間的轉換
做圖像處理,由於像素通道值是8位的無符號整數,而與其運算的往往又是浮點數,這就需要將8位無符號整數轉換爲浮點數;運算完畢後,得到的結果又要寫回圖像通道,就要是8位無符號整數,還要涉及到超出8位的截斷。開始不注意時吃了大虧....
類型轉換主要以下幾種:

  1. 浮點數和整數的轉換及32位浮點數和64位浮點數之間的轉換。 這種轉換簡單直接,只需要調用相應的函數指令即可。
  2. 有符號整數的高位擴展將8位、16位、32位有符號整數擴展爲16位、32位、64位。
  3. 有符號整數的截斷 將16位、32位、64位有符號壓縮
  4. 無符號整數到有符號整數的擴展
    在Intrinsic函數中 上述類型轉換的格式
  • _mm_cvtepixx_epixx (xx是位數8/16/32/64)這是有符號整數之間的轉換
  • _mm_cvtepixx_ps / _mm_cvtepixx_pd 整數到單精度/雙精度浮點數之間的轉換
  • _mm_cvtepuxx_epixx 無符號整數向有符號整數的擴展,採用高位0擴展的方式,這些函數是對無符號高位0擴展變成相應位數的有符號整數。沒有32位無符號整數轉換爲16位有符號整數這樣的操作。
  • _mm_cvtepuxx_ps / _mm_cvtepuxx_pd 無符號整數轉換爲單精度/雙精度浮點數。

上面的數據轉換還少了一種,整數的飽和轉換。什麼是飽和轉換呢,超過的最大值的以最大值來計算,例如8位無符號整數最大值爲255,則轉換爲8位無符號時超過255的值視爲255。
整數的飽和轉換有兩種:

  • 有符號之間的 SSE的Intrinsic函數提供了兩種

    __m128i _mm_packs_epi32(__m128i a, __m128i b)
    __m128i _mm_packs_epi16(__m128i a , __m128i b)
    用於將16/32位的有符號整數飽和轉換爲8/16位有符號整數。
  • 有符號到無符號之間的

    __m128i _mm_packus_epi32(__m128i a, __m128i b)
    __m128i _mm_packus_epi16(__m128i a , __m128i b)

    用於將16/32位的有符號整數飽和轉換爲8/16位無符號整數

4. SSE彙編指令和Intrinsic函數的對比

這裏只是做了一個粗略的對比,畢竟還只是個初學者。先說結果吧,在Debug下使用純彙編的SSE代碼會快不少,應該是由於沒有編譯器的優化,彙編代碼的效率還是有很大的優勢的。但是在Release下面,前面也有提到過優化器內置了Intrinsic函數的行爲信息,能夠對Intrinsic函數提供很強大的優化,兩者沒有什麼差別。PS:應該是由於選用數據的問題 ,普通的C++代碼,SSE彙編代碼以及Intrinsic函數三者在Release下的速度相差無幾,編譯器本身的優化功能是很強大的。

4.1 Intrinsic 函數進行多次內存讀寫操作

在對比時發現使用Intrinsic函數另一個問題,就是數據的存取。使用SSE彙編時,可以將中間的計算結果保存到xmm寄存器中,在使用的時候直接取出即可。Intrinsic函數不能操作xmm寄存器,也就不能如此操作,它需要將每次的計算結果寫回內存中,使用的時候再次讀取到xmm寄存器中。

yy = _mm_mul_epi32(yy, wwidth);

上述代碼是進行32位有符號整數乘法運算,計算的結果保存在yy中,反彙編後其對應的彙編代碼:

000B0428  movaps      xmm0,xmmword ptr [ebp-1B0h] 
000B042F  pmuldq      xmm0,xmmword ptr [ebp-190h] 
000B0438  movaps      xmmword ptr [ebp-7A0h],xmm0 
000B043F  movaps      xmm0,xmmword ptr [ebp-7A0h] 
000B0446  movaps      xmmword ptr [ebp-1B0h],xmm0  

上述彙編代碼中有多次的movaps操作。而上述操作在使用匯編時只需一條指令

pmuludq xmm0, xmm1;

在使用Intrinsic函數時,每一個函數至少要進行一次內存的讀取,將操作數從內存讀入到xmm寄存器;一次內存的寫操作,將計算結果從xmm寄存器寫回內存,也就是保存到變量中去。由此可見,在只有很簡單的計算中(例如:同時進行4個32位浮點數的乘法運算)和使用SSE彙編指令不會有很大的差別,但是如果邏輯稍微複雜些或者調用的Intrinsic函數較多,就會有很多的內存讀寫操作,這在效率上還是有一部分損失的。

4.2 簡單運算的Intrinsic和SSE指令的對比

一個比較極端的例子,未經過優化的C++代碼如下:

    _MM_ALIGN16 float a[] = { 1.0f,2.0f,3.0f,4.0f };
    _MM_ALIGN16 float b[] = { 5.0f,6.0f,7.0f,8.0f };
    const int count = 1000000000;

    float c[4] = { 0,0,0,0 };
    cout << "Normal Time(ms):";
    double tStart = static_cast<double>(clock());
    for (int i = 0; i < count; i++)
        for (int j = 0; j < 4; j++)
            c[j] = a[j] + b[j];
    double tEnd = static_cast<double>(clock());

對兩個有4個單精度浮點數的數組做多次加法運算,並且這種加法是重複進行,進行1次和進行1000次的結果是相同的。使用SSE彙編指令的代碼如下:

    for(int i = 0; i < count; i ++)
        _asm
        {
            movaps xmm0, [a];
            movaps xmm1, [b];
            addps xmm0, xmm1;
        }

使用Intrinsic函數的代碼:

    __m128 a1, b2;
    __m128 c1;
    for (int i = 0; i < count; i++)
    {
        a1 = _mm_load_ps(a);
        b2 = _mm_load_ps(b);
        c1 = _mm_add_ps(a1, b2);
    }

在Debug下的運行

這個結果應該在意料之中的,SSE彙編指令 < Intrinsic函數 < C++。SSE彙編指令比Intrinsic函數快了近1/3,下面是Intrinsic函數的反彙編代碼

        a1 = _mm_load_ps(a);
00FB2570  movaps      xmm0,xmmword ptr [a] 
00FB2574  movaps      xmmword ptr [ebp-220h],xmm0 
00FB257B  movaps      xmm0,xmmword ptr [ebp-220h] 
00FB2582  movaps      xmmword ptr [a1],xmm0 
        b2 = _mm_load_ps(b);
00FB2586  movaps      xmm0,xmmword ptr [b] 
00FB258A  movaps      xmmword ptr [ebp-240h],xmm0 
00FB2591  movaps      xmm0,xmmword ptr [ebp-240h] 
00FB2598  movaps      xmmword ptr [b2],xmm0 
        c1 = _mm_add_ps(a1, b2);
00FB259F  movaps      xmm0,xmmword ptr [a1] 
00FB25A3  addps       xmm0,xmmword ptr [b2] 
00FB25AA  movaps      xmmword ptr [ebp-260h],xmm0 
00FB25B1  movaps      xmm0,xmmword ptr [ebp-260h] 
00FB25B8  movaps      xmmword ptr [c1],xmm0 

可以看到共有12個movaps指令和1個addps指令。而SSE的彙編代碼只有2個movaps指令和1個addps指令,可見其時間的差別應該主要是由於Intrinsic的內存讀寫造成的。
Debug下面的結果是沒有出意料之外的,那麼Release下的結果則真是出乎意料的

使用SSE彙編的最慢,C++實現都比起快很好,可見編譯器的優化還是非常給力的。而Intrinsic的時間則是0,是怎麼回事。查看反彙編的代碼發現,那個加法只執行了一次,而不是執行了很多次。應該是優化器根據Intrinsic行爲做了預測,後面的多次循環都是無意義的(一同學告訴我的,他是做編譯器生成代碼優化的,做的是分支預測,不過也是在實現中,不知道他說的對不對)。

5. 總結

學習SSE指令將近兩個周了,做了兩篇學習筆記,差不多也算入門了吧。這段時間的學習總結如下:

  1. SSE指令集正如其名字 Streaming SIMD Extensions,最強大的是其能夠在一條指令並行的對多個操作數進行相同的運算,根據操作數長度和寄存器長度的不同能夠同時運算的個數也不同。以32位有符號整數爲例,128位寄存器(也是最常用的SSE指令集的寄存器)能夠同時運算4個;AVX指令集的256位寄存器能夠同時運算8個;AVX-512 的512位寄存器能夠同時運算16個。
  2. 在使用SSE指令時要特別主要操作數的類型,整型則要區分是有符號還是無符號;浮點數則注意其精度是單精度還是雙精度。另外就是操作數的長度。即使是同樣的128位二進制串,根據其類型和長度也有多種不同的解釋。
  3. 前面多次提到,編譯器的優化能力是很強的,不要刻意的使用SSE指令優化。而在要必須使用SSE的時候,要謹記SSE的強大之處是其並行能力。

又是一個陽光明媚的週五下午,說好的今天要下大暴雨呢,早晨都沒敢騎自行車來上班,回去的得擠公交啊。話說,爲啥不說坐公交或者乘公交,而要擠公交呢。

發佈了28 篇原創文章 · 獲贊 29 · 訪問量 23萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章