Direct Memory Access

為什麼需要 DMA

你正在看一部 4K 影片(串流大約 25 Mbps),同時在背景編譯大 code,瀏覽器還開了十幾個分頁。影片每秒鐘需要把大量的畫面資料從 SSD 搬到記憶體、再送到 GPU 去解碼 render,累死。如果這些資料都要 CPU 親自一個 byte 一個 byte 搬運,你的 CPU 大概就什麼事都別做了,光搬資料就忙不過來。但實際上你的電腦可以邊播影片邊 compile 邊跑瀏覽器,就是因為有 DMA 在背後幫忙。

沒有 DMA 的世界是什麼樣子?假設你要從 SSD 讀 4KB 的資料到記憶體。CPU 對裝置下一個 read 指令,裝置準備好資料之後,CPU 要親自跑一個迴圈,一次搬幾個 byte,把資料從裝置的 data register 搬到記憶體。這叫 programmed I/O(PIO)。

PIO 的問題很明顯:CPU 在搬資料的時候什麼其他事都不能做。搬 4KB 要幾千次 register read/write 操作。在慢速裝置的年代(例如軟碟),資料量小、速度慢,PIO 還可以接受。但現在一顆 NVMe SSD 的 bandwidth 可以到 7 GB/s,你不可能讓 CPU 用迴圈搬 7 GB 的資料,CPU 的運算能力會被完全浪費掉。

DMA(Direct Memory Access)解決了這個問題。有了 DMA,CPU 只需要告訴 DMA controller:「從裝置讀資料,放到記憶體地址 X,長度 N bytes」。DMA controller 自己去完成傳輸,傳輸過程中 CPU 完全不介入,可以去做其他運算。傳輸完成後,DMA controller 送一個 interrupt 通知 CPU「ok 了」。

PIO vs DMA timeline comparison

DMA 對記憶體的要求

DMA 不是「設定一下就好」的事情。裝置要直接存取記憶體,有幾個重要的限制你必須理解。

Physical address:CPU 在執行程式的時候用的是 virtual address,經過 page table 轉換才對應到 physical address。但 DMA controller 和裝置通常不認識 virtual address,它們直接看 physical address(或者透過 IOMMU 轉換,但先不管這個)。所以設定 DMA 的時候,driver 必須把 buffer 的 virtual address 轉換成 physical address 告訴裝置。

Physically contiguous:OS 的 virtual memory 系統會把連續的 virtual address 映射到不連續的 physical page。你 malloc 一塊 64KB 的 buffer,在 virtual space 是連續的,但底層可能散佈在 16 個不相鄰的 physical page。最簡單的 DMA controller 需要一塊 physically contiguous 的記憶體,因為它要從一個起始 physical address 開始,連續搬 N bytes。如果記憶體不連續,DMA controller 搬到一半就會搬到錯誤的位置。

Pinned(不能被 swap out):OS 的 virtual memory 系統會把不常用的 memory page swap 到 disk 上。但 DMA 傳輸可能在任何時候發生(裝置準備好就開始搬),如果 DMA 目標的那個 page 剛好被 swap out,裝置就會寫到錯誤的 physical address,直接破壞其他 process 的資料,甚至導致 kernel panic。所以 DMA buffer 必須被 pin 住(也叫 lock),確保在 DMA 進行期間不會被 OS 搬走。

在 Linux kernel 裡,分配 DMA buffer 通常不用 kmalloc(它不保證 physically contiguous 大區塊),而是用專門的 dma_alloc_coherent() 或 dma_pool API,這些 API 會同時處理 physical contiguity、alignment 和 pinning 的問題。

Virtual vs physical memory contiguity for DMA

Scatter-Gather List

前面說最簡單的 DMA 需要 physically contiguous buffer,但在實際系統中,分配大塊 physically contiguous 記憶體既困難又浪費(尤其系統跑久了之後,physical memory 已經碎片化了)。

Scatter-gather list(SGL)解決了這個問題。它讓你在一次 DMA 操作中指定多段不連續的記憶體區間。具體來說,SGL 是一個陣列,每個 entry 包含一個 (physical_address, length) pair。DMA controller 會依序處理每個 entry,把資料搬到(或從)對應的記憶體區段。

舉個例子:你要從 SSD 讀 16KB 的資料,但你的 buffer 散佈在 4 個不連續的 physical page 上。沒有 scatter-gather 的話,你要先分配一塊 16KB 的 contiguous buffer 讓 DMA 搬進去,搬完之後再 memcpy 到你真正要用的 buffer。有了 scatter-gather,你直接建一個 4 個 entry 的 SGL,每個 entry 指向一個 4KB page 的 physical address,DMA controller 自己搞定。省掉了分配 contiguous buffer 和額外 memcpy 的成本。

NVMe 支援 scatter-gather(NVMe 的術語叫 PRP,Physical Region Page,或更新的 SGL),所以 NVMe driver 在設定 I/O command 的時候可以直接把 scatter-gather list 放進 command 裡面。

Scatter-gather list DMA operation

Zero-Copy I/O

理解了 DMA 和 scatter-gather 之後,你可能會問:既然 DMA 已經把資料搬到記憶體了,為什麼有時候 I/O 還是很慢?因為「搬到記憶體」不代表「搬到你程式的 buffer」。傳統的 I/O 路徑有很多次不必要的資料複製。以 read() system call 為例,典型的流程是:

  1. 裝置透過 DMA 把資料搬到 kernel 的 buffer(kernel space)
  2. Kernel 再用 memcpy 把資料從 kernel buffer 複製到你的 user space buffer

這個 kernel buffer 到 user buffer 的複製看起來好像沒什麼大不了,但 memcpy 的成本其實不低。複製 4KB 大約需要幾百個 nanoseconds,如果你每秒做幾十萬次 I/O,光是 memcpy 就會吃掉可觀的 CPU 時間和 memory bandwidth。而且同一份資料在記憶體裡佔了兩份空間(kernel buffer 和 user buffer),也浪費 DRAM。

Zero-copy I/O 的核心想法是:讓裝置的 DMA 直接搬到應用程式的 buffer,完全跳過 kernel buffer 這一層。資料從裝置出發,經過 DMA 直接到達 user space,不需要任何 CPU 介入的 memcpy。

要做到 zero-copy,application 的 buffer 必須滿足 DMA 的所有要求:physical address 已知、pinned、alignment 正確。在 Linux 上,O_DIRECT flag 就是走這條路,它繞過 kernel 的 page cache,讓裝置直接 DMA 到 application buffer(但 application buffer 必須是 page-aligned 的)。

更極端的做法是 kernel-bypass I/O(像 SPDK),不只省掉 kernel buffer copy,連 kernel 本身都繞過去了。我們後面會專門討論。

這裡要注意一個 trade-off:zero-copy 省掉了複製的成本,但同時也放棄了 kernel 的 page cache。如果你的 workload 有大量重複讀取相同資料的 pattern,page cache 的命中率可能非常高,走 zero-copy 反而更慢,因為每次都要真的去讀裝置。Zero-copy 最適合的場景是大量 sequential I/O 或者資料只讀一次的 workload,在這種情況下 page cache 幫不上忙,省掉 copy 的好處才會真正體現出來。

自問自答

試著用自己的話回答以下問題。如果卡住了,回去重讀對應的段落。

  1. 在 PIO 模式下,CPU 要親自搬運資料。如果一顆 NVMe SSD 的 bandwidth 是 7 GB/s,用 PIO 搬運所有資料會有什麼問題?DMA 如何解決這個問題?

  2. 為什麼 DMA buffer 必須用 physical address 而不是 virtual address?如果裝置使用 virtual address 去做 DMA,會發生什麼事?

  3. 為什麼 DMA buffer 必須被 pin 住不能被 swap out?

  4. 沒有 scatter-gather 的時候,如果你的資料分散在 4 個不連續的 physical page,你要怎麼做才能完成 DMA?有了 scatter-gather 之後省掉了什麼?

  5. Zero-copy I/O 省掉了哪些 overhead?在什麼情況下,zero-copy 反而會比走 kernel page cache 的傳統路徑更慢?