天天看點

SIMD 程式設計的優勢 --TickerTape Part 2

簡介

背景

SSE 是一套專門為 SIMD(單指令多資料)架構設計的指令集。通過它,使用者可以同時在多個資料片段上執行運算,實作資料并行(有時又稱矢量處理)。例如,我們可以利用這套指令集使兩個數組各自相乘:

float a[nElements], b[nElements], c[nElements]; 

for (unsigned int i = 0; i < nElements; i++) { 

    c[i] = a[i] * b[i]; 

清單 1:兩個數組相乘的标量法,其中每次疊代處理一個元素

一般而言,如清單 1 所示,存在一個讓所有元素進行疊代的循環,在這個循環中每個元素會互相相乘,然後儲存乘積。現在,我們除了可以在每次循環疊代時執行一個乘法運算外,還可以執行多個乘法運算。下面是可以執行多個乘法運算的函數 MultiplyFourElements()。

void MultiplyFourElements(float *a, float *b, float *c) { 

    c[0] = a[0] * b[0]; 

    c[1] = a[1] * b[1]; 

    c[2] = a[2] * b[2]; 

    c[3] = a[3] * b[3]; 

for (unsigned int i = 0; i < nElements; i += 4) { 

    MultiplyFourElements(&a[i], &b[i], &c[i]); 

清單 2:兩個數組相乘的标量法,其中每次疊代處理四個元素

如清單 2 所示,我們建立了一個可以同時處理四個元素的函數。利用該函數,我們執行循環疊代的次數減少了四倍。盡管如此,由于每次疊代所需執行的數學運算相同,我們在效率上并未獲得較大提升。然而有了 SSE 指令,我們不再需要通過一個函數連續執行四次乘法運算,隻需借助一條指令即可同時執行四個乘法運算。SSE 會使用處理器上的 128 位寬專用寄存器。這些寄存器可以儲存任何 128 位資料,如兩個雙精度數字、四個單精度數字或 16 位元組數字等。采用 SSE 進行程式設計的方式有兩種:一種是直接編寫彙編指令代碼, 另一種是使用 intrinsics 函數程式設計。Ticker Tape 示範以及本文都将隻側重于使用 intrinsic 函數進行程式設計。使用 intrinsics 而非彙編指令程式設計是一種更加直接的方法,與标準 C/C++ 程式設計類似。此外,使用 intrinsics 程式設計還有助于編譯器更好地優化代碼,對此本文将稍後闡釋。

// Assembly SSE Instruction 

/// xmm0 and xmm1 are actual registers, not variables 

mulps xmm0,xmm1 

// Intrinsic SSE Instruction 

__m128 a, b, c; 

c = _mm_mul_ps(a, b); 

清單 3:彙編指令與 Intrinsic SSE 指令 

type __m128 是針對一個可映射至其中一個 SIMD 寄存器的 16 位元組對齊變量的定義。該程式首先需要将已完成 _mm_load_ps() intrinsic(未在清單 3 中顯示)運算的資料明确加載到 SIMD 寄存器中。_mm_mul_ps() 是實際執行運算的指令。一旦我們獲得運算結果,我們可以利用 _mm_store_ps()intrinsic 将其作為輸出數組儲存下來。

#include <xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h> 

__m128 A, B, C; 

//... load data in a, b 

    A = _mm_load_ps(&a[i]); 

    B = _mm_load_ps(&b[i]); 

    C = _mm_mul_ps(A, B); 

    _mm_store_ps(&c[i], C); 

<xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h><xmmintrin.h></xmmintrin.h>  

清單 4:兩個數組相乘的 SIMD 方法 

顯然,運算差異已在圖 1 中顯示:

SIMD 程式設計的優勢 --TickerTape Part 2

圖 1:對比标量循環運算法與 SIMD 運算法

資料布局

許多應用的瓶頸并非在于算法的運算部分,而在于資料讀寫上。在上一個示例中,75% 的指令被用于加載和儲存資料。一旦您想通路的資料儲存在了不同的區域中,則需花費大量時間才能将該資料加載至高速緩存中并繼而加載至 SIMD 寄存器中。例如,如果上一示例中的數組實際上是我們所擁有的數組類的成員變量,則将該資料加載至寄存器中會是一件十分困難且耗時的工作。各個元素将不得不被逐個加載至 SIMD 寄存器中。

class foo { 

    float a; 

    float b; 

    ... other data ... 

}; 

void bar() { 

    foo fooArray[nElements]; 

    float c[nElements]; 

    __m128 A, B, C; 

    // Non_SIMD Method 

    for (unsigned int i = 0; i < nElements; i++) { 

        c[i] = fooArray[i].a * fooArray[i].b; 

    } 

    // SIMD Method 

    for (unsigned int i = 0; i < nElements; i += 4) { 

        A = _mm_load_ps(&fooArray[i].a); // What will this do? 

        B = _mm_load_ps(&fooArray[i].b); // Load incorrect data 

        C = _mm_mul_ps(A, B); 

        _mm_store_ps(&c[i], C); 

清單 5:支援 SIMD 不适用資料布局(結構數組)的類 

在清單 5 中,資料加載方式出現了錯誤。該程式打算将連續記憶體加載至 SIMD 寄存器中,但這将會形成錯誤資料。要将資料加載至寄存器中不是不可能,但這樣做涉及到重新安排資料的格式,而這需要一定的步驟才能完成。無論重新安排資料實際生成的處理成本如何,需要轉移的資料很多,加載至記憶體的高速緩存行實際上也很多。然而,盡管如此,使用 SSE 指令仍然存在諸多優勢,尤其是當相同的資料正在執行多項運算時。重新安排資料布局是指按照順序安排類似資料片斷,便于高效加載和儲存這些資料。除此以外,安排資料布局的另一個優勢是在 SIMD 寄存器尺寸增加時,改寫代碼會更輕松。這樣一來,您便可以一次加載八個而非四個浮點資料。在上一示例的基礎上采用更高效的資料布局進行改編後的程式如下:

    float *a; 

    float *b; 

foo::foo(unsigned int nElements) { 

    a = (float *)_mm_malloc(nElements * sizeof(float), 16); 

    b = (float *)_mm_malloc(nElements * sizeof(float), 16); 

    foo fooVariable(nElements); 

        c[i] = fooVariable.a[i] * fooVariable.b[i]; 

        A = _mm_load_ps(&fooVariable.a[i]); 

        B = _mm_load_ps(&fooVariable.b[i]); 

清單 6:支援 SIMD 适用資料布局(結構數組)的類

SIMD 程式設計的優勢 --TickerTape Part 2

圖 2:SoA 與 AoS 記憶體布局

在 Ticker Tape 中實施 SIMD 優化

當我們通過優化 Ticker Tape 以發揮由兩個重要部分組成的 SIMD 優勢、重新安排資料使之更适用于 SIMD,以及重新編寫部分代碼以使用 SSE 指令時,我們對 Ticker Tape 示範進行了改造,最後發現我們的大部分時間花費在了 Newton() 函數上。這個函數主要計算各種力如何影響粒子。在原始架構中,每個粒子都會調用一次該函數,并會在每個角執行四次内部運算。在較高層面上進行的概述如下:

class RigidBody 

    void Newton(); 

    // ... Bunch more functions ... 

    D3DXVECTOR3 Position; 

    D3DXVECTOR3 Rotation; 

    // ... Lots of other data ... 

void RigidBody::Newton() 

    for (unsigned int = 0; i < 4; i++) 

    { 

        // ... some math goes on ... 

        // Velocity_Ground  

        D3DXVECTOR3 Vel_Ang_CM_global; 

        D3DXVec3TransformCoord(&Vel_Ang_CM_global,  

            &Velocity_Angular_CM, &this->LocalGlobal); 

        D3DXVec3Cross(&tmp, &Radial_Vec, &(Vel_Ang_CM_global)); 

        Velocity_Ground = (this->Velocity_Linear_CM) + tmp; 

        // ... more math ... 

    return; 

清單 7:原始 Ticker Tape 布局 

每個粒子都代表着不同的對象并包含着各自的操作方法。此外,程式中還存在一個通過每個調用其成員方法的粒子進行疊代的循環。在向 SSE 進行遷移時首先應建立另外一個名為 NewtonArray() 的 Newton() 函數。這與原始布局并無二緻,但卻是在整個粒子數組而非一個粒子上建立的新函數。同時,還應建立包含整個數組的 RigidBody 類,以便程式從擁有 RigidBody 對象的數組遷移至一個擁有數組的 RigidBody。根據針對不同資料布局的測試,資料進一步分解成了各自的組成部分(如清單 8 所示)。

// Original 

D3DXVECTOR3 rotation; 

// Half way 

D3DXVECTOR3 *rotation; 

// Final 

float *rotationX; 

float *rotationY; 

float *rotationZ; 

清單 8:改變資料布局

待資料重組後,NewtonArray() 已經被修改,可以使用 SSE 指令。實施這一修改的基本前提是存在嵌套循環結構。每個采用内部循環索引的變量一般都會被加載到 SIMD 寄存器中,但每個采用外部循環索引的變量卻都會被重複加載到寄存器中。事實上,我們删除了内部循環(如清單 9 所示)。_mm_set1_ps() 指令與 _mm_load_ps() 指令類似,唯一的不同之處在于前者可加載一個浮點值并能将其複制到寄存器的四個分區中。

// Original code example 

for (unsigned int i = 0; i < nParticles; i++) { 

    for (unsigned int j = 0; j < 4; j++) { 

        c = a[i] * b[j]; // Notice the different indexers 

// SSE version, inner loop removed 

    A = _mm_set1_ps(&a[i]);  // copy the value a[i] into all 4 slots 

    B = _mm_load_ps(&b[i * 4]); 

清單 9:删除内部循環

即使隻修改該函數的一小部分代碼,實施上述優化的優勢仍然十分明顯。在完成所有優化操作後,我們計算了 NewtonArray() 函數的執行時間。通過使用支援 Visual Studio 2008 的微軟編譯器(MSVCC),函數執行速度提高了 1.8 倍。由于具備自動矢量化性能,采用英特爾® C++ 編譯器(ICC)編寫原始代碼能夠帶來巨大優勢。采用 ICC 編寫手寫 SSE 可使函數執行速度提高達 4.5 倍。

Compiler:

MSVCC

MSVCC + SSE

ICC

ICC + SSE

Time (ms):

16.3

8.9

9.9

3.6

Improvement:

1.0x

1.8x

1.6x

4.5x

表 1:函數 Newton 的執行時間(以毫秒為機關)

點積示例

本章節将示範如何使用 SSE 指令計算點積(實際上是四個點積),并簡要介紹具體算法。所涉及的變量全部被命名為 xmm#,因為八個 SSE 寄存器的名稱從 xmm0 到 xmm7 不等。盡管如此,您沒有必要采用這種方式命名變量,也不用隻保留八個變量。

// Dot Product 

// Computes dot products on two arrays of vectors, 1 at a time 

        result[i] = v1[i].x * v2[i].x +  

                    v1[i].y * v2[i].y +  

                    v1[i].z * v2[i].z; 

// Computes dot products on two arrays of vectors, 4 at a time 

    xmm0 = _mm_load_ps(X1 + i);     // Load data into SIMD registers 

    xmm1 = _mm_load_ps(Y1 + i); 

    xmm2 = _mm_load_ps(Z1 + i); 

    xmm3 = _mm_load_ps(X2 + i); 

    xmm4 = _mm_load_ps(Y2 + i); 

    xmm5 = _mm_load_ps(Z2 + i); 

    xmm6 = _mm_mul_ps(xmm0, xmm3);  // Multiply x's together 

    xmm7 = _mm_mul_ps(xmm1, xmm4);  // Multiply y's together 

    xmm8 = _mm_mul_ps(xmm2, xmm5);  // Multiply z's together 

    xmm0 = _mm_add_ps(xmm6, xmm7);  // Add all the values together 

    xmm7 = _mm_add_ps(xmm0, xmm8); 

    _mm_store_ps(result + i, xmm7); // Save the results 

清單 10:SSE 版點積示例

采用 _mm_load_ps() 指令運作上述算法首先要将所有資料加載到 SIMD 寄存器中。這樣做會獲得作為一項參數的資料加載位址。所有擁有 ps 字尾的指令都是單精度版本的指令。許多指令都擁有多個版本,如 _mm_load_pd() 指令還可以用于加載兩個雙精度數字。同樣至關重要的是,資料必須為 16 位元組對齊資料。如果不是 16 位元組對齊資料,則必須采用一條不同的指令——_mm_loadu_ps() 來運作函數,但這樣做不會帶來相同的性能提升優勢。 

資料加載完畢後,應采用 _mm_mul_ps() 指令完成數字相乘運算。所相乘的元素來自兩個寄存器的相比對元素。

SIMD 程式設計的優勢 --TickerTape Part 2

圖 3:_mm_mul_ps() 圖示

未來展望

在 Ticker Tape 示範中顯然存在 SSE 需要改進的方面。其中一個應該是它需要進一步支援對代碼進行矢量化,而不應僅針對一個函數實施矢量化。此外,SSE 還應能與即将推出的英特爾® 進階矢量擴充指令集(英特爾® AVX)進行協作。另外一個值得探索的有趣領域是使用英特爾® C++ 編譯器自動矢量化代碼。目前存在一些可幫助編譯器執行矢量化操作的代碼模式和結構。經修改後的 Ticker Tape 代碼可采用這些代碼模式和結構,便于 SSE 仍然保持幕後運作狀态。

繼續閱讀