關於使用向量指令集對memcpy最佳化的分析

绝对精神的自我展开發表於2024-08-16

前段時間寫基於Neon的OpenCV演算法最佳化運算元,突然在想能不能用Neon加速memcpy?遂搜了一下,網上大家都說彳亍,我尋思一下,也覺得彳亍,又跑去看產品上的memcpy實現,發現竟沒用Neon指令,於是當場狂喜,心想我要是用Neon把memcpy的速度最佳化個幾倍,豈非不世之功?3.75、晉升P7、財富自由指日可待!於是立馬屁顛屁顛地寫了個demo驗證。

然而結果令人大跌眼鏡!demo的效能和原版memcpy幾乎沒區別,甚至更慢!

豈有此理,我的3.75,我的P7,我的財富自由就這樣沒啦?

不可能,絕對不可能!

於是懷著悲憤、不甘、不解,還有一絲渺茫的希望,開始探索產生上述結果的原因。

雖然最終結果徹底粉碎了我的白日美夢,但我決定將結論寫成文件,表示我雖然失敗了,但我掙扎過,我來,我見,我沒征服!

ps:

  1. 本文需要讀者有一定的體系結構知識基礎
  2. 關於Neon的基本介紹見此:https://www.cnblogs.com/tianrenbushuai/p/18362141

一、背景

Neon指令集中的ld/st指令擁有比常見Arm記憶體讀寫指令(ldrstrldpstp)更大的單指令資料吞吐量,例如ld1st1兩條指令可以一次從記憶體中讀取四個向量暫存器大小(4 * 128 = 512位)的資料放到向量暫存器中,或將資料從四個向量暫存器中放入記憶體。

因此,本案例嘗試探索Neon的記憶體讀寫能力,主要與C標準庫的memcpy函式進行對比。memcpy目前在各個系統和各個平臺上都是透過彙編實現,有機器級最佳化,可以認為是一個記憶體讀寫效能的標杆。

二、測試

1. 測試平臺

  1. CPU:某個不方便透露型號的Arm處理器,分大中小核,三種核心整形計算能力大概是3.3 : 3 : 1
  2. 系統:Linux 5.x

2. 測試資訊

測試1M大小的記憶體複製

  1. 為最大化體現效能,手動將複製源資料起點地址和目標資料起點地址進行64位元組對齊
  2. 複製前對源資料段和目標資料段寫入資料,保證所有頁面都是非虛擬頁面
  3. 測試時保證系統平均CPU使用率不超過10%
  4. 每種複製方式連續迴圈一千輪,取每輪執行平均值作為參考
  5. 所有時間都是使用者態的running時間

3. 複製方式

除了作為對比標杆的memcpy方式外,其餘借用C和彙編混合編寫實現,主要用到ld1st1ld4st4四個向量指令,一次最多可操作64個位元組。ld4st4與前兩者的區別在於它們是對資料交叉操作。

如下有5種複製方式,除memcpy外,其餘4中方式都是簡單的迴圈複製,區別在於每次迴圈的展開大小,以及用的指令和指令排布方式。至於memcpy預設用ldp/stp指令進行大容量複製。

  1. 使用標準庫memcpy進行複製

    memcpy(dst, src, TestSize);
    
  2. 使用ld1和st1進行64位元組展開複製

    void memcpy_neon_64(void* dst, void* src, size_t num) {
        void* srcDst = (void*)((uintptr_t)src + num);
        while (src != srcDst) {
            asm volatile (
                "ld1 {v0.4s - v3.4s}, [%[src]], #64 \n"
                "st1 {v0.4s - v3.4s}, [%[dst]], #64 \n"
                : [src] "+r"(src), [dst] "+r"(dst)
                :
                : "memory", "v0", "v1", "v2", "v3"
            );
        }
    }
    
  3. 使用ld1和st1進行128位元組展開複製

    void memcpy_neon_128(void* dst, void* src, size_t num) {
        void* srcDst = (void*)((uintptr_t)src + num);
        while (src != srcDst) {
            asm volatile (
                "ld1 {v0.4s - v3.4s}, [%[src]], #64 \n"
                "ld1 {v4.4s - v7.4s}, [%[src]], #64 \n"
                "st1 {v0.4s - v3.4s}, [%[dst]], #64 \n"
                "st1 {v4.4s - v7.4s}, [%[dst]], #64 \n"
                : [src] "+r"(src), [dst] "+r"(dst)
                :
                : "memory", "v0", "v1", "v2", "v3", "v4", "v5", "v6", "v7"
            );
        }
    }
    
  4. 使用ld1和st1進行16位元組展開複製

    void memcpy_neon_16(void* dst, void* src, size_t num) {
        void* srcDst = (void*)((uintptr_t)src + num);
        while (src != srcDst) {
            asm volatile (
                "ld1 {v0.4s}, [%[src]], #16 \n"
                "st1 {v0.4s}, [%[dst]], #16 \n"
                : [src] "+r"(src), [dst] "+r"(dst)
                :
                : "memory", "v0"
            );
        }
    }
    
  5. 使用ld4和st4進行64位元組展開複製

    void memcpy_neon_ld4(void* dst, void* src, size_t num) {
        void* srcDst = (void*)((uintptr_t)src + num);
        while (src != srcDst) {
            asm volatile (
                "ld1 {v0.4s - v3.4s}, [%[src]], #64 \n"
                "st1 {v0.4s - v3.4s}, [%[dst]], #64 \n"
                : [src] "+r"(src), [dst] "+r"(dst)
                :
                : "memory", "v0"
            );
        }
    }
    

4. 測試結果

單位:微秒(us)

大核 中核 小核
memcpy 76.1 92.4 152.6
ld1/st1 16 82.2 91.2 213.9
ld1/st1 64 78.1 88.9 185.3
ld1/st1 128 80.8 88.9 193.9
ld4/st4 94 102.9 187.5

三、分析

從測試結果可以看到,在三種核心上,幾種方式時間消耗都在一個數量級上,最快和最慢的差距一般不會超過30%。

這個結果令人失望,Neon的載入儲存指令不僅沒有最佳化,memcpy甚至略快一點,這和我對向量指令集的理解以及網上大量說法不符,令人疑惑。

而接下來的內容就是解釋疑惑。

要強調的是,上述結果和CPU特性高度相關,因此對上述結果的分析並不適用其它硬體平臺和標準庫版本,但可以提供分析思路。

1. 基礎分析

首先了解一下Arm64平臺上memcpy實現要點:

  1. 寫對齊:對從暫存器向記憶體的寫操作進行對齊。注意,Arm上裝置記憶體訪問必須是讀寫都對齊,普通記憶體則可非對齊訪問。
  2. 大容量迴圈展開複製:對於較大規模的複製任務,一次迴圈中的複製量展開為128位元組,降低比較和跳轉操作對效能的影響。
  3. 使用ldp/stp指令進行迴圈展開,兩條指令一次可以載入和儲存兩個通用暫存器(共16位元組/128位)的資料
  4. 分治:將複製量分為幾個區間,例如128位元組以上、64位元組、64位元組以下,不同區間採用不同複製方式。例如大於128的複製量,在地址對齊後按128位元組展開迴圈複製,剩下不足128位元組部分按64位元組、不足64位元組的部分進行複製。

再簡略提一些要用到的體系結構的知識:

  1. 指令多級流水線、多發射超標量、亂序執行、微指令架構是現代CPU標配。用人話講,就是一個指令分多個階段執行,一個時鐘週期可以有多個指令被CPU執行,並且會將指令分解為更小的執行指令,而且實際執行順序可能與指令順序不符。
  2. 從儲存體系來看,CPU是典型的記憶體延遲敏感型處理器,這裡說的記憶體指整個記憶體體系,包括物理快取、實體記憶體,以及交換到外存上的資料。對於所有涉及到暫存器之外的資料的操作,資料訪問延遲可能會造成指令執行stall,成為效能瓶頸。
  3. 現代CPU的訪存順序是L1 cache、L2 cache、L3 cache(如果有的話)、實體記憶體。當(可快取的)實體記憶體被訪問後,所在的cache line(Arm64上為64個位元組)會被交換到cache中去,之後CPU會直接去cache上訪問,這就是cache命中。一般快取命中率通常在95%以上。

透過上述知識,首先可以對複製任務做這樣一些判斷:

  1. 複製任務主要瓶頸在資料訪問延遲。從程式碼中可以看到,除記憶體操作指令外,主要計算指令只有比較地址,它是整數比較,在絕大多數CPU上都屬於執行最快的一類操作。而訪存指令,即便是訪問L1 cache也有至少四個時鐘週期的延遲,比整數比較只高不低。

  2. 複製操作對地址的訪問是連續的,因此可視為一種空間區域性性較好的程式,再加上現代CPU通常都有記憶體預取機制,1000輪的迴圈測試也進行了預熱,因此可以認為絕大部分讀寫操作都在快取上完成,透過perf檢視測試程式的快取命中率也能驗證這一點:

      50682494      cache-misses              #    2.860 % of all cache refs
    1772343067      cache-references
    

基於上述判斷,首先得到一個推測:不論使用怎樣的指令進行讀寫操作,都會受到快取讀寫效率的影響,Intel的最佳化手冊驗證了這個猜測。

2. Intel X64情況

上圖是Intel haswell讀寫cache的資料,可以看到L1 data cache在一個時鐘週期可以進行最大64位元組的載入操作(由兩個32位元組微載入指令構成)和32位元組的儲存操作,而L2 cache上可以做到64位元組的讀寫操作。

haswell是Intel第4代CPU架構,於2013年釋出,距今已有十年,現在的架構只會更先進。例如六年後釋出的Ice Lake架構,從下圖的微架構概覽圖可以看到新增一個STD單元,對L1 cache的儲存操作也上升為每個時鐘週期執行兩次。

有了基礎硬體讀寫能力的支援,向量指令集才有用武之地,在Intel最佳化手冊裡明確建議用SIMD指令集最佳化memcpy。

可見,向量指令集加速memcpy並非空穴來風,在X86-64上可以成立,並且還是官方推薦方法,但為什麼在我用的平臺上不行呢?這就是接下來要討論的問題。

3. Cortex-A55情況

根據瞭解,測試使用的CPU小核是公版Cortex-A55,中核和大核則是Cortex-A76魔改版本。

我們先來看A55的情況,如下是Cortex-A55的微架構圖,iss階段之前可以認為是CPU的前端解碼單元,iss之後是後端執行單元,iss就是將前端指令解碼後得到的微指令傳送到後端執行。

我們提取一些關注點資訊

  1. 從微架構圖上看,A55上普通指令是8級流水線,SIMD(即Neon)是十級流水線。即在最理想的情況下,Neon指令比普通指令多執行2個時鐘週期。從這能看出,用Neon指令做普通指令也能做的事,前者執行時間可能比後者高,因此一般情況下並不推薦使用Neon。

  2. 從微架構圖上看,A55上有一個store單元和一個load單元(還能看到有兩個普通管道的ALU,也就是說A55是一個兩標量的超標量處理器)。Neon部分則有MAC、DIV/SQRT、ALU單元,但沒獨立的store、load單元。這說明A55上Neon的記憶體讀寫指令和普通讀寫指令依賴相同的單元

  3. 如下圖所示,A55還是一個雙發射處理器

    並非所有指令都能雙發射,例如我們關注的load/store就有意外情況。

  4. 重點來了,如下圖所示,A55只支援每週期64位寬的讀取和128位寬的寫入

    這個位寬和X86的位寬是天壤之別

    Ice Lake上64位元組的寬度意味它能在單個週期內執行對一個完整的AVX2向量暫存器(256位)甚至是AVX512暫存器(512位)的讀寫,而A55上的位寬意味著它一個時鐘週期最多隻能載入一個通用暫存器或半個128位向量暫存器的資料,以及儲存兩個通用暫存器或一個128位向量暫存器的資料到記憶體。

    因此,若用Neon載入128位資料,它至少需要兩個時鐘週期對cache讀取,與用兩條普通指令讀取位相比,耗時並不會比更低,儲存同樣如此,下面就驗證了這個結論。

  5. 下面兩張圖描述普通ld/st指令在理想情況下的最佳執行延遲和指令吞吐量,理想情況包括L1 cache命中、訪問對齊。

    可以看到,ldp指令的執行延遲為4(不確定括號裡的3是啥意思),吞吐是1/2。stp指令的延遲只有1,吞吐為1。

    再看Neon的載入和儲存指令。

    (高能預警)

    噔噔咚!(心肺停止)

    從圖中可以看到,大部分Neon的ld/st指令延遲都比普通ld/st高,吞吐量比普通指令低。尤其是紅框圈出部分,兩個ld指令分別載入一個完整的向量暫存器(128位)和載入四個完整的向量暫存器,它們的執行延遲分別達到了4和10個時鐘週期!而吞吐量則是1/2和1/8!同樣的,紅框圈出的st指令分別將一個或四個完整的向量暫存器資料寫入記憶體,其執行延遲為1和4,吞吐量為1和1/4。

    透過簡單計算不難發現,在每載入128位資料的執行延遲和指令吞吐量的指標上,ld1st1比起ldpstp沒有任何優勢!

    還要注意到的是,上面都是基於理想情況下得到的資料,即可以認為是在連續不斷地執行同一個指令時得到的值,實際情況中並不存在,再考慮到Neon指令的階段比普通指令多兩個,於是可以得到一個結論——在A55上,Neon的ld/st類指令比普通的ld/st類指令不僅可能沒有效能優勢,甚至可能還有劣勢!

    看完小核,再來看中大核的情況。

4. Cortex-A75/77情況

目前我沒找到A76的具體資料,但是可以透過A75和A77來大致推測一下A76的特性。

A75:

A77:

從上面兩張圖可以看到,A75和A77微架構大致流程不變,都是先在fetch階段獲得指令,然後在CPU前端解碼、暫存器重新命名、排程並分解為微操作,最後發射給後端管道執行。

而二者主要區別就在於後端管道數量不一樣,可以看到A77的分支和標量處理單元都增加一倍,新增一個store單元。

我推測,測試CPU的中大核能力介於二者之間,可能更接近A77甚至就是A77的水平,但應該不會超出太多。

關於A75和A77的ld/st單元具體讀寫能力的資料沒找到,但指令特性裡多少表現出了能力。

A75:

A77:

可以看到,A75和A77的ldp在指令吞吐上高於A55,雖然延遲略大一點,但考慮到一般的二者頻率更高,其實際速度是要高於A55的。

至於Neon部分,A75:


A77:

可以大致估算一下,透過指令吞吐量來算每週期的資料吞吐量,Neon和ldp也比沒有任何優勢,和A55情況一致。

5. 總結

向量指令集雖然對暫存器上的資料處理能力大多數時候強於普通指令,但受制於實際硬體實現的限制,尤其是CPU後端執行單元能力的限制,向量指令集的載入/儲存能力可能並不能如其表面上的那樣可以達到普通指令幾倍的效果。而後端執行單元的能力也受限於計算機體系架構的整體設計,例如快取讀寫能力、記憶體控制器能力、記憶體晶片讀寫能力等等。而之所以Arm在這方面弱於X86,主要是二者使用場景不同。Arm處理器大多時候應用於低功耗平臺,而X86的使用場景大多數時候允許較高的功耗和較強的散熱,這就使得前者在很多方面的設計不能激進,從而在微架構方面與X86呈現出不同的特點。

四、如何寫出一個高效能複製函式

本節是延伸話題,主要是在總結在探索Neon讀寫能力過程中看到的、嘗試的關於提升讀寫能力的方法。

1. 手寫彙編

目前各個平臺上的memcpy都是手寫彙編實現,當然這並不是說一定要手寫彙編,用C語言寫也不是不行,但編譯器的行為總是無法完全預測的,例如下面同一段C語言寫的程式碼,同樣的最佳化等級,在gcc9.3和13.2上編出來的就不一樣

顯然,下圖才是理想中的情況,而上圖編譯器就做了一些難以理解的動作。

因此,在對執行流程熟悉的情況下,手寫彙編也許是一種最佳選擇,對memcpy這類函式尤其如此。

2. 使用最大化利用CPU讀寫後端單元位寬的最小消耗指令

前文提及過,CPU後端執行單元中的載入/儲存單元執行一條微指時有最大讀寫位寬,因此我們要保證讀寫指令能最大化使用其位寬,在X86-64上,AVX指令就可以滿足這個要求,而Arm只需要ldp/stp就足以。在這個基礎上,我們還需要讓指令本身的消耗小,例如Neon雖然可以吃滿讀寫位寬,但由於其可能更多的執行階段等因素,實際執行週期可能比普通指令更多,因此在不需要執行其它向量操作的情況下,並不建議使用它們用來複制資料。

3. 複製迴圈展開

除非CPU中提供特殊指令,否則我們繞不開透過迴圈進行複製,但可以透過在一次迴圈中儘可能多地複製資料,從而降低判斷和地址跳轉造成的效能消耗。一般來說,Arm64上memcpy對大容量複製時會採用128位元組的迴圈展開。當然,在CPU讀寫能力允許的情況下,可以使用更大讀寫能力的指令進行更大的迴圈展開。

4. 分治

對於大容量複製時可以使用較大的迴圈展開,但可能有不足一次迴圈複製長度的剩餘複製內容,此時可以分治處理,例如滿足64位元組部分使用一個策略,不足64位元組部分繼續分治,從而達到效能最最佳化。

5. 地址對齊

雖然大多數現代CPU在大多數情況下都支援地址不對齊的訪問,但很多時候地址不對齊的訪問會有額外的效能消耗,Arm明確表示,地址不對齊時某些載入/儲存指令會有懲罰週期,X86也明確表示建議地址對齊。此外,地址對齊讀寫也有利於cache line交換和寫合併機制發揮作用。

當然,我們不能保證函式被呼叫時傳入的地址一定是對齊的,尤其是使用迴圈展開和分治時,只能對源資料地址或者目標資料地址進行對齊,一般X86和Arm都建議對齊目標資料地址,memcpy的實現也是如此。

不過,從呼叫者角度來說,在呼叫memcpy或者類似要讀寫記憶體的函式時,儘量傳入對齊的地址,提升程式效能。

6. 暫存器交叉讀寫

如前文所說,CPU後端的載入/儲存操作是在不同單元上完成的,而且在一個週期內可以向不同單元發射微指令,這就允許我們在一個時鐘週期內同時進行載入和儲存。同時,現代CPU快取和記憶體都是雙埠的,意味著可以同時讀寫,因此,我們可以利用CPU和快取/記憶體的同時讀寫特性來提高效能。

具體來說,就是讓讀寫指令按一定規律交叉起來,例如先連執行三條讀執行,並載入到不同暫存器上,然後再連續執行三條寫指令,按讀指令的暫存器寫入順序從暫存器中取出資料寫入記憶體,以利用讀指令的記憶體訪問延遲,並避免對相同暫存器連續讀寫造成的指令依賴,提升效能。

A55上的示例:

7. 使用快取預載入指令

(有可能有用,但是有用不太可能)

某些CPU提供快取預載入指令,該指令顧名思義,是用來將記憶體中資料預載入到快取中的,以降低載入/儲存指令的訪存延遲。但在目前沒見過memcpy實際使用過,原因可能是CPU可能本身就有快取預取策略,再使用預載入指令可能會和前者衝突或者反而產生效能降低,具體應該如何使用要參考CPU最佳化手冊。

8. 多執行緒複製

(這位更是重量寄)

關於多執行緒複製能否提升速度、能提升多少長久以來一直是一個具有爭議的話題,在實際工程中很少會看到這種策略,但為了全面,我這裡還是要闡述一下這個手段。

多執行緒複製要考慮的因素比較複雜,首先考慮記憶體通道數量影響。

我們假設,兩個複製執行緒執行在不同的物理CPU上,當它們訪問實體記憶體時要透過記憶體控制器。通常一個記憶體控制器對應一個通道,一個通道一次只能進行一次讀/寫。當體系中只有一個記憶體通道時,兩個執行緒很可能會同時對記憶體控制器發出寫或者讀請求,從而要進行訪問仲裁,並有一個訪問請求產生延遲。

這就像我只準備了一桌飯,卻來了兩桌客人,這個飯怎麼吃?(101臉)

通常CPU的頻率是高於記憶體的,因此在複製操作這種密集訪存型任務會非常容易在上述的場景產生多執行緒訪存衝突從而造成延遲,一旦延遲發生,意味著記憶體效能成為了任務瓶頸,執行緒數量再多也沒用。

但如果有兩個以上的通道就不一樣了。

多通道場景下,一般記憶體地址按cache line大小(arm64為64byte)交叉編址,各個通道間訪問互不衝突。這意味著當不同執行緒同時向不同通道發出相同的讀/寫請求,不會相互影響。這時多執行緒複製的優勢便體現了出來。故而理論上,通道數越多,對多執行緒複製越有利。

上述情況還是基於UMA架構的CPU,現在某些CPU還使用NUMA機制(Non-Uniform Memory Access,非一致性記憶體訪問),該機制下,CPU上的核心會固定分成不同的節點(node),每個節點配備一個單獨的記憶體控制器。當CPU訪問非所屬節點記憶體控制器對應通道的實體記憶體時,則需要透過節點間通訊其地址對應的節點去獲得資料。

而從複製資料的角度來看,如果複製任務的記憶體地址在不同的節點對應的記憶體地址空間上,意味著可以使用多個執行緒在不同節點的CPU上執行,並只複製當前階段對應通道的記憶體地址空間的資料,從而完美規避執行緒之間對記憶體控制器和匯流排的訪問衝突。

再來看快取結構的影響,現代CPU上每個核心有獨立的L1 cache,多個核心會組成一個處理器簇(cluster),每個簇所有核心共享L2 cache,所有簇共享L3 cache。因此,如果不同執行緒執行在不同處理器上,則可只對自己的L1 cache訪問而互不影響(原子同步除外),若還在不同簇上,則可對簇所屬L2 cache獨立訪問而互不影響(原子同步除外)。在這種情況下,如果每個執行緒複製連續且互不重疊的地址空間,配合快取預取技術,就可以只對相對獨立的快取進行讀寫而互不影響,從而提升複製效率。

然而,即便可以利用多執行緒複製實現更快的複製速度,也不意味著CPU使用率會降低。

因為總複製任務量並未減少,而使用多執行緒,意味著執行緒建立銷燬、排程等本身就會產生額外消耗,此外正如我們前面所說,多執行緒複製可能產生訪問衝突而提高訪存延遲,訪存延遲造成的CPU stall是會實際反映在CPU執行時間上的,而CPU執行時間就是計算CPU使用率的關鍵因素。因此,除非是要求儘可能地加快複製速度、降低複製實踐,否則一般是不建議使用多執行緒複製。

9. 開掛

(小開不算開)

如果你問我什麼方案能在幾乎不消耗CPU的情況下完整複製,那我可以告訴你……還真有。

那就是使用DMA或類似的可以讀寫記憶體的異構處理器,CPU只需要向它們發出命令,剩下的交給這些處理器就行啦。因為這些處理單元不在CPU內部,所以可以從某種程度上視為外掛單元,使用外掛單元的功能,簡稱開掛,合理!

但是要注意的是,DMA訪問記憶體依舊要透過記憶體控制器,因此如果CPU和DMA或者其它硬體(例如GPU、DSP)同時進行記憶體訪問,會造成相互搶奪匯流排頻寬的問題,反而還會升高CPU的ld/st指令執行時間(如果要訪問記憶體)。

相關文章