SSE指令集學習:Compiler Intrinsic


大多數的函數是在庫中,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的強大之處是其並行能力。

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM