大多數的函數是在庫中,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位。
具體的數據類型及其說明如下:
- __m64 64位對應的數據類型,該類型僅能供MMX指令集使用。由於MMX指令集也能使用SSE指令集的128位寄存器,故該數據類型使用的情況較少。
- __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部分構成,這個三個部分的具體含義如下:
- 第一部分為前綴_mm,表示是SSE指令集對應的Intrinsic函數。_mm256或_mm512是AVX,AVX-512指令集的Intrinsic函數前綴,這里只討論SSE故略去不作說明。
- 第二部分為對應的指令的操作,如_add,_mul,_load等,有些操作可能會有修飾符,如loadu將未16位對齊的操作數加載到寄存器中。
- 第三部分為操作的對象名及數據類型,_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)
- 使用set函數將需要的數據填充到__m128Intel中
- mul函數進行乘法運算,兩個32位的整型相乘的結果是一個64位整型。
- 由於計算的是像素的偏移量,使用32位整型也就足夠了,使用shffule對__m128i中的數據進行重新排列,使用unpack函數再重新組合,將數據組合為需要的結構。
_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位的截斷。開始不注意時吃了大虧....
類型轉換主要以下幾種:
- 浮點數和整數的轉換及32位浮點數和64位浮點數之間的轉換。 這種轉換簡單直接,只需要調用相應的函數指令即可。
- 有符號整數的高位擴展將8位、16位、32位有符號整數擴展為16位、32位、64位。
- 有符號整數的截斷 將16位、32位、64位有符號壓縮
- 無符號整數到有符號整數的擴展
在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指令將近兩個周了,做了兩篇學習筆記,差不多也算入門了吧。這段時間的學習總結如下:
- SSE指令集正如其名字 Streaming SIMD Extensions,最強大的是其能夠在一條指令並行的對多個操作數進行相同的運算,根據操作數長度和寄存器長度的不同能夠同時運算的個數也不同。以32位有符號整數為例,128位寄存器(也是最常用的SSE指令集的寄存器)能夠同時運算4個;AVX指令集的256位寄存器能夠同時運算8個;AVX-512 的512位寄存器能夠同時運算16個。
- 在使用SSE指令時要特別主要操作數的類型,整型則要區分是有符號還是無符號;浮點數則注意其精度是單精度還是雙精度。另外就是操作數的長度。即使是同樣的128位二進制串,根據其類型和長度也有多種不同的解釋。
- 前面多次提到,編譯器的優化能力是很強的,不要刻意的使用SSE指令優化。而在要必須使用SSE的時候,要謹記SSE的強大之處是其並行能力。
又是一個陽光明媚的周五下午,說好的今天要下大暴雨呢,早晨都沒敢騎自行車來上班,回去的得擠公交啊。話說,為啥不說坐公交或者乘公交,而要擠公交呢。