一、背景
寫下本文的原因來自一次 bug 排查,平臺為某個 Arm64 處理器。
問題簡單來說就是,就是申請一塊 dma-buf 並對映到使用者空間,對 buffer 使用memcpy()
時發現一些異常效能問題:
-
從 dma-buf 向透過
malloc()
申請的普通堆記憶體複製速度,遠慢於從普通堆記憶體向 dma-buf 複製的速度,差距能有十倍以上,結果如下,資料大小為1Mdma-buf --> heap memory 550 us heap memory --> dma-buf 49 us -
對於(1)的現象,我懷疑 dma-buf 的讀速度慢於寫速度,也遠慢於普通記憶體的讀寫速度,且 dma-buf -> 普通記憶體複製慢的原因很像 cache miss 導致的,然而問題是
- 如果是 cache miss 導致,為什麼只有讀的速度慢?在我的認知中寫速度應該一樣很慢
- 如果是 cache miss 導致,為什麼是一直都慢?在我的認知中CPU應該有機制會對記憶體預快取,不可能一直 cache miss
-
同事用 perf 進行初步分析,發現更奇怪的現象
- dma-buf 向普通記憶體複製的 cache miss 率非常低,不足 0.5%,反而普通記憶體向 dma-buf 複製的 cache miss 率很高,有 16%
- perf 顯示,dma-buf 向普通記憶體複製過程耗時最長的地方在一條暫存器減法指令,這條指令在一個複製128位元組的迴圈體的末尾,理論上來說這種指令應該是所有指令中執行最快、耗時最少的,為何此時卻顯示為執行耗時最多的?
-
從(3)來看,似乎導致(1)異常的原因不是 cache miss,也不是讀速度慢於寫速度,但也無法解釋(1)中異常,於是我單測對 dma-buf 和普通記憶體的只讀、只寫速度,原始碼如下
// 測試記憶體連續地址的只讀效能 void test_read(void* addr, int size) { void* addrDst = addr + size; while (addr != addrDst) { asm volatile ( "ldp x3, x4, [%[addr]], #16 \n" : [addr] "+rw"(addr) : : ); } } // 測試記憶體連續地址的只寫效能 void test_write(void* addr, int size) { void* addrDst = addr + size; while (addr != addrDst) { asm volatile ( "stp x3, x4, [%[addr]], #16 \n" : [addr] "+rw"(addr) : : ); } }
結果如下
dma-buf heap memory 只讀 537 us 31 us 只寫 30 us 32 us 這個結果比較符合我(2)中最開始的猜想,也就是 dma-buf 的讀速度遠慢於寫速度,但產生這種現象的原因,以及為何與(3)中 perf 指向結論相悖的原因都讓人不解。
本文接下來就是解釋其中原因。
二、DMA
先簡單介紹 DMA 和 dma-buf。
1. DMA 與快取一致性問題
在還沒 DMA 技術的時候,外設通常與 CPU 直連,因此 CPU 要處理大量 I/O 任務,其中資料傳輸任務尤其浪費 CPU 資源,所有資料都要透過 CPU 中轉才能寫入記憶體,然後 CPU 再處理資料,會消耗大量 CPU 時間,而 DMA 技術就是用來將 CPU 從繁重的 I/O 任務中解脫。
簡單來說,外設連線在一個 DMA 控制器上,DMA 控制器直連記憶體,當有 I/O 任務時,DMA 控制器可以獨立對記憶體讀寫資料,而不需要經過 CPU ,因此從體系結構而言,CPU 可以和 I/O 裝置(DMA 控制器)並行工作。
這種設計雖然解決了 I/O 任務對 CPU 的高負載問題,但也引入了新問題,比如快取一致性問題。
大多數現代 CPU 上都有快取,它們雖然極大降低 CPU 訪存延遲,提升 CPU 執行效率,但也引入資料一致性問題,因此需要多種機制保證不同級別的快取、不同 CPU 簇的快取以及與記憶體間的資料被 CPU 訪問時應該一致,且應儘可能降低對效能的影響。
而 DMA 則讓快取一致性問題更加複雜,由於 DMA 不能訪問快取,因此當 CPU 對一個實體地址寫入時,它可能寫入的是快取,而記憶體上的資料並未實時更新,若 DMA 此時訪問這塊記憶體,則讀取的都是未實時更新的資料,從而產生資料一致性問題。
而 DMA 資料一致性問題,就是導致本文問題的根因。
2. dma-buf
嚴謹地說,dma-buf 是 Linux 核心的一個子系統,主要用於在不同驅動間、驅動核心態與使用者態、使用者態執行緒/程序間共享緩衝區。
(後面若無特別說明,dma-buf 用於指代 dma-buf 子系統申請的緩衝區)
通常 dma-buf 是驅動在核心中申請的,可透過檔案描述符共享給其它驅動使用,或透過mmap
對映到使用者空間,使用者空間透過虛擬地址訪問。
例如 DRM 申請的記憶體,尤其是 GEM 申請的記憶體,通常就是基於 dma-buf 實現的裝置記憶體,GPU 驅動或 EGL 這種 API,就是透過 dma-buf 建立普通圖形應用和視窗合成器之間的高效交換鏈機制,前後臺緩衝區就是在應用和合成器之間共享的 dma-buf。
在嵌入式計算機上,由於通常是 UMA 架構,因此CPU、GPU、DMA 之間可以透過 dma-buf 實現零複製的記憶體共享。
三、Arm64 記憶體模型與記憶體型別簡介
本節只介紹關聯問題的知識,更多知識請參閱其它資料。
1. 記憶體模型
(1) 弱排序訪問
Arm 的記憶體實現了弱排序架構——允許實際訪存順序與程式指定順序不同,但最終執行結果與程式預期相同。
這種架構的設計目的自然是為了提升效能,我們以 Arm 官方例子展示
上圖講得非常清楚,可能有讀者看不懂 Arm 彙編,我簡單解釋一下。
圖中左側有三條指令,它們從上至下按順序依次執行。
第一條指令將暫存器 R12 中的資料放到暫存器 R1 中資料指向的地址,這是一次寫記憶體。
第二條指令是將 SP 暫存器的值作為地址指向的資料放到 R0 暫存器中,然後對 SP 暫存器的值加 4,這是一次讀記憶體。
第三條指令是將 R3 暫存器的值加 8,並將新值作為地址指向的資料存放到 R2 暫存器中,這也是一次讀記憶體。
圖中右側則是 CPU 實際執行的順序。
首先 CPU 將 R12 暫存器中的資料放進寫快取中。寫快取簡單來說是一種 CPU 快取結構,若寫操作未命中 L1 cache,則會將資料先放進其中,CPU 此時認為寫操作已經完成,而只有當 cache 或記憶體準備好響應寫操作後,資料才會從寫快取真正寫進相應物理區域中。關於寫快取的具體細節,我們將在後文中介紹。
緊接著,執行第二條指令,CPU 訪存但未命中,因此它會觸發 cache 去對映記憶體資料,此時資料並未寫入 CPU。
當執行第三條指令時,CPU 訪存並命中,因此資料很快會立刻寫進 CPU,訪存操作完成,而這是三條指令中第一條真正完成記憶體操作的指令。
再然後,第二條指令觸發的快取對映完成,資料從緩衝中寫入 CPU,第二條指令記憶體操作執行完畢。
最後,第一條指令觸發的記憶體儲存請求完成響應,資料從寫快取中寫入記憶體,第一條指令操作執行完畢。
從時間順序上,可以得到下面這張圖。
雖然指令1 執行最早,但由於記憶體訪問可以不依賴實際訪問順序,因此它是最晚執行完的,三條指令執行的總時間就是指令1 執行的時間。
而若強制要求訪存順序必須按照程式執行順序完成,則會發生下面這種情況
可以看到,因為訪存順序必須按照指令順序,所以指令2 和指令3 的訪存操作必須在前面的指令訪存執行完後才能執行,故而指令進入流水線後,會在執行階段被阻塞,產生巨大的氣泡。所以三條指令的總執行時間基本等於三者訪存時間之和。
只看例子中的情況,似乎下面只比上面多兩次訪存時間消耗,但實際上指令3 之後還有指令,因此可能後續若干條指令都能在指令1 訪存結束前完成。而下面這種情況,很可能導致後面的指令都被阻塞,時間消耗不斷擠壓,因此最終的耗時遠高於上面這種情況。
當然,Arm 的記憶體並不是只有弱排序的,某些情況下記憶體不僅必須是強排序的,還有其它各種特性,這將在後面介紹。
(2) 寫合併與寫快取
寫快取是一種未對軟體開發者暴露細節的快取,Arm64 也未對其做出規定,通常由 vendor 自行實現,開發者最多知曉 CPU 存在這樣一種快取,因此我主要從其一般性原理出發來闡述。
寫快取通常位於 L1 cache 到記憶體之間,當寫記憶體操作未命中 L1 cache 時,資料首先會寫入寫快取中,此時 CPU 執行單元會認為資料已經寫入相應位置中,並繼續執行記憶體讀寫操作。只有當下級快取或記憶體準備相應寫入操作後,資料再從寫快取真正寫入相應的物理單元中。
通常寫快取支援寫合併,這種一種利用寫快取有效提升寫記憶體效率的方式,具體如下。
如下圖所示,假設一個寫緩衝有四個寫條目,每個寫條目有四項,每項可以存放8個位元組。現在要處理器要將地址 100 - 131 的32個位元組資料寫入到相關儲存器中,L1 cache 未命中,因此資料先寫入寫快取。
假設未使用寫合併,則每 8 個位元組佔據一個寫條目,32 個位元組佔據 4 個寫條目,當相關儲存器準備好寫入後,4 個寫條目按 4 次寫入儲存器中。
而若開啟寫合併,則 32 個位元組放在一個條目中,當儲存器準備好寫入時,32 個位元組最佳化為一次寫,從而降低訪存耗時。
此外,寫合併可以讓多個對相同地址的相同型別的記憶體訪問(讀或寫)操作合併為一個,比如,連續幾次對地址 108 的寫操作,會在寫緩衝上進行,當儲存器準備後寫入後,Mem[108] 的資料才實際寫入儲存器中,因此多個處理器的寫操作就合併為一個。
2. 記憶體型別
Arm64 體系結構中有兩種型別記憶體——普通記憶體 (Normal Memory)、裝置記憶體 (Device Memory),二者互斥,且記憶體一定為這兩種中的一種。
兩種記憶體型別又可以分為多種確定的記憶體屬性,早期 Arm64 支援 9 種型別,但隨著技術發展,比如 Memory Tagging 這種技術的出現,Arm64 逐漸支援更多的型別屬性,而任何一種記憶體一定屬於其中一種。
Arm64 支援作業系統自由選擇要使用的記憶體屬性,並自由對記憶體分配屬性,後文會講到如何分配。
通常記憶體型別是作業系統或驅動在核心態分配記憶體時確定的,一般的系統呼叫沒有辦法指定申請的記憶體型別,但不排除某些驅動暴露給使用者態的介面可以申請指定型別的記憶體。
(1) 普通記憶體
絕大部分記憶體都是普通記憶體,包括所有的程式碼區 (text segement)、大多數資料區 (data segement);從物理元件上來說,可以包括 RAM、ROM、快閃記憶體等。
普通記憶體是弱排序記憶體,因此效能在所有記憶體型別裡是最高的,支援編譯器最佳化和處理器重排序、合併訪問等最佳化。若需要對普通記憶體強制記憶體訪問排序,則需要用顯式的記憶體屏障實現。
普通記憶體包含多種具體的記憶體屬性,不同的記憶體屬性有不同的讀寫策略,比如關閉快取、回寫策略、寫直通策略等,以 6.12 Linux 原始碼為例,使用了三種:
// <arch/arm64/include/asm/memory.h>
#define MT_NORMAL 0
#define MT_NORMAL_TAGGED 1
#define MT_NORMAL_NC 2
其中MT_NORMAL_TAGGED
是使用了 Memory Tagging 技術的普通記憶體,這裡不詳細展開講。MT_NORMAL_NC
則是關閉快取使用的普通記憶體。
普通記憶體的介紹到此為止,不是本文重點,更多資訊請參閱其它文件。
(2) 裝置記憶體
裝置記憶體型別的記憶體通常用於外設暫存器對映,比如用於 DMA 訪問。某些情況下,由於外設訪問模式的特殊性,需要限制對記憶體訪問的最佳化,以保證資料的讀寫完全遵循程式指令的要求,此外還有快取一致性的問題,所以需要一種與普通記憶體訪問形式不同的記憶體。
因此,dma-buf 在相當多時候都是以裝置記憶體來實現的。
裝置記憶體最大的特點就在於其永遠不可快取,因此所有讀寫都必須在實體記憶體上完成,而這就某些 dma-buf 讀寫速度慢的根因。
而在此基礎上,裝置記憶體還有六種記憶體屬性來指定其讀寫策略,這 6 種讀寫策略根據三種最佳化記憶體讀寫的行為來規定:
- G/nG:G (Gathering) 表示聚合,即允許把多次訪存操作合併為一次,而這個合併操作,就是我們前面說的“寫合併”。
- R/nR:R (Re-ordering) 表示指令重排,即允許讀寫操作指令重排。
- E/nE:E (Early write acknowledgement) 表示提前寫應答,即當資料寫入寫快取後,寫應答立刻返回給處理器,因此處理器能進行後續訪存操作。而若關閉寫應答 (nE),則資料達到外設後(專業術語為端點,外設就是一種端點)才返回寫應答。
三種行為是否開啟共同決定一種具體的記憶體屬性,並且可能相互影響,Arm64 允許的裝置記憶體屬性與包含關係如下圖所示:
可以看到 Device-nGnRnE 是限制最嚴格的記憶體屬性,同時理論上也是效能最差的,而 Device-GRE 是限制最少的記憶體屬性,理論上也是效能最好的。
但上述只是理論上,實際的處理器很可能並不能支援所有行為限制,比如在有的處理器上 Device-GRE 和 Device-nGRE 行為相同,而,最終實際要處理器的具體表現。
6.12 Linux 原始碼中使用瞭如下兩種裝置記憶體屬性
// <arch/arm64/include/asm/memory.h>
#define MT_DEVICE_nGnRnE 3
#define MT_DEVICE_nGnRE 4
(3) 記憶體屬性的設定
作業系統可以將需要的記憶體屬性放在 MAIR_ELn (Memory Attribute Indirection Register_ ELn) 暫存器中,MAIR_ELn 一共有 8 個,每個暫存器應填入不同的記憶體屬性,也就是說 Arm64 最多支援使用 8 種不同的記憶體屬性。
然後在分配記憶體時,透過在 L3 頁表項的低位屬性 AttrIndx[2:0] 中指定
AttrIndx[2:0] 可以表示 8 個二進位制數,剛好對應 8 個 MAIR_ELn 暫存器。
因此,當處理器將虛擬地址翻譯為實體地址時,會根據 L3 頁表項的 AttrIndx[2:0] 的值,去對應的 MAIR_ELn 暫存器查詢這個頁面的記憶體屬性是什麼,然後根據這個記憶體屬性來決定對這塊記憶體的讀寫行為,也就是說,記憶體屬性實際作用的最小單位是頁面,而非某個具體的地址。
四、快取一致性相關
如前所說,dma-buf 的讀寫效能問題,核心在於其選擇使用裝置記憶體型別,因此關閉了快取加速,同時可能由於寫快取與寫合併機制,導致其讀速度慢,而寫速度快。
但實際工作中發現,並不是所有的 dma-buf 和裝置使用的記憶體都是裝置記憶體型別.
比如:可透過 libion 申請可快取的 dma-buf;某些高通平臺 OpenGL ES 申請的圖形記憶體都是普通記憶體型別,且可以對映到使用者地址空間中;某些嵌入式平臺上 Vulkan 可以申請記憶體屬性為 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_CACHED_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
(表示可被主機(通常為 CPU)訪問、快取)、堆標誌為VK_MEMORY_HEAP_DEVICE_LOCAL_BIT
(表示為裝置(通常為 GPU)本地)的記憶體。
也就是說,實際中外設使用的記憶體並不一定非得是裝置記憶體型別,也可以是普通記憶體型別,但這裡的問題是如何保證普通記憶體型別的記憶體對外設的一致性呢?這個問題的答案涉及到了現實中處理器的快取一致性處理方案。
1. 共享域
Arm64 根據資料的共享範圍,可以分為四個共享域,在某個共享域中,所有可訪存的硬體都要實現快取一致性,四個共享域如下所示
- 不可共享:通常就是一個 CPU,它有獨立的 L1 cache,不可被其它 CPU 訪問。
- 內部共享域:通常是一個 CPU 簇,比如四個 CPU 組成一個 CPU 簇,它們共享相同的 L2 cache。
- 外部共享域:通常是若干 CPU 簇構成,不同 CPU 簇可能是異構的(微架構不同),但它們可以共享 L3 cache。
- 系統共享域:系統中所有可訪存的硬體單元,除了 CPU 外,還可能有 DMA、GPU、NPU 等等。
2. 快取一致性實現
不同共享域的快取一致性通常需要不同的方式來實現,比如內部共享域通常就是透過大名鼎鼎的 MESI 協議來實現,MESI 協議是一個完全硬體實現的協議,對軟體透明,但某些情況下也需要手工干預,比如涉及到 DMA 緩衝時,就需要手動重新整理資料到記憶體,因為 MESI 協議並不保證和 DMA 裝置(也就是系統共享域)的快取一致性。
同樣的,MESI 協議也不能保證外部共享域一致性,現在很多移動端 CPU 都採用大小核異構架構,不同架構的 CPU 構成至少一個 CPU 簇,而 CPU 簇之間的快取一致性則由其它協議來實現,比如 AMBA 協議。
MESI 協議通常由 CPU 內部的快取控制器就可以實現,但 AMBA 不一樣,它需要獨立於 CPU 的專門控制器單元來實現,如下圖所示。
從圖中可以看到,除了兩個 CPU 簇透過 ACE (AXI Coherent Extension) 協議完成外部共享域的快取一致性外,GPU 和 DMA 也可以透過 ACE Lite 協議連線到 AMBA 控制器上,完成系統共享域的實現。
但 AXI 是 AMBA 4 中才引入的,早期並沒有,因此早期如 DMA、GPU 等外設並不能透過 AMBA 來實現快取一致性,而這也是為什麼裝置記憶體型別存在的原因。假設一顆 SoC 並沒有引入相關協議來實現系統域的快取一致性,那麼通常就只能透過關閉快取或軟體管理的方式來保證 DMA 或其它非 CPU 的訪存裝置的快取一致性。
由於 Linux 核心通常不能保證硬體平臺有系統域的快取一致性硬體實現,所以預設對為外設使用記憶體的實現都會比較保守,使用裝置型別記憶體。
但若硬體平臺支援硬體實現系統域快取一致性,甚至支援 SMMU (IOMMU),即 I/O 裝置的地址管理,那麼便可使用普通型別的記憶體用於 GPU、DMA 等外設使用。
而 vendor 或第三方可以在驅動中覆蓋或新增 Linux 原生的某些實現,從而使用普通型別記憶體代替預設的裝置型別記憶體。
文章的最後放一張高通官方給的體系結構圖,雖然已經是比較早的 SoC 了,但也可以看到複雜的系統域級快取一致性控制實現。