在 Apache Arrow 中使用 jemalloc 實現更快速、可擴展的記憶體配置


已發布 2018年7月20日
作者 Uwe Korn (uwe)

隨著 Apache Arrow 0.9 版本的發布,我們已將陣列緩衝區的預設分配器從系統分配器切換為 OSX 和 Linux 上的 jemalloc。這適用於 Arrow 的 C++/GLib/Python 實作。在大多數情況下,更換預設分配器通常是為了避免許多小型、頻繁的(取消)分配所發生的問題。相反地,在 Arrow 中,我們通常處理大型的記憶體內資料集。雖然 jemalloc 為避免記憶體頁面(4kb)以下分配的 RAM 碎片提供了良好的策略,但它也提供了改進跨越多個記憶體頁面分配效能的功能。

在 Apache Arrow 之外,jemalloc 為 Facebook 的基礎架構提供動力(這也是其大部分開發發生的地方)。它也被用作 Rust 中的預設分配器,並且它還有助於 Redis 減少 Linux 上的記憶體碎片(“Allocator”)。

Arrow 中我們需要的一項記憶體分配特殊性是記憶體應為 64 位元組對齊。這是為了讓我們能夠從像 AVX 這樣的 SIMD 指令集中獲得最佳效能。雖然最現代的 SIMD 指令也適用於未對齊的記憶體,但它們在對齊的記憶體上的效能要好得多。為了讓我們的分析應用程式獲得最佳效能,我們希望分配所有記憶體,以便最大限度地提高 SIMD 效能。

對於對齊的分配,POSIX API 僅提供 aligned_alloc(void** ptr, size_t alignment, size_t size) 函數來分配對齊的記憶體。還有 posix_memalign(void **ptr, size_t alignment, size_t size) 來修改分配以達到首選的對齊方式。但它們都不能滿足分配的擴展。雖然 realloc 函數通常可以在不實際移動分配的情況下擴展分配,但它不能確保在移動分配的情況下保持對齊。

在 Arrow 建置時未啟用 jemalloc 的情況下,這會導致在每次新的分配擴展時複製資料。為了減少記憶體複製的次數,我們使用 jemalloc 的 *allocx()-API 來建立、修改和釋放對齊的分配。這給我們帶來顯著加速的典型任務之一是在 Arrow 表格的增量建構上,該表格由多個列組成。我們通常事先不知道表格的大小,並且需要在載入資料時擴展我們的分配。

為了使用 2 倍的記憶體擴展來增量建構向量,我們將使用以下 C 程式碼與標準 POSIX API

size_t size = 128 * 1024;
void* ptr = aligned_alloc(64, size);
for (int i = 0; i < 10; i++) {
  size_t new_size = size * 2;
  void* ptr2 = aligned_alloc(64, new_size);
  memcpy(ptr2, ptr, size);
  free(ptr);
  ptr = ptr2;
  size = new_size;
}
free(ptr);

使用 jemalloc 的特殊 API,我們能夠省略對 memcpy 的顯式呼叫。在無法就地完成記憶體擴展的情況下,仍然由分配器呼叫它,但並非在所有情況下都需要。這將我們的使用者程式碼簡化為

size_t size = 128 * 1024;
void* ptr = mallocx(size, MALLOCX_ALIGN(64));
for (int i = 0; i < 10; i++) {
  size *= 2;
  ptr = rallocx(ptr, size, MALLOCX_ALIGN(64));
}
dallocx(ptr, MALLOCX_ALIGN(64));

為了了解使用 jemalloc 的實際好處,我們查看 Arrow C++ 中的基準測試。在那裡,我們模擬了一個典型的使用案例,即增量建立原始值陣列。對於陣列的建立,我們不知道最終陣列中元素的數量,因此我們需要不斷擴展儲存資料的記憶體區域。此基準測試的程式碼是 Arrow C++ 原始碼中 builder-benchmark 的一部分,名為 BuildPrimitiveArrayNoNulls

沒有 jemalloc 的執行時間

BM_BuildPrimitiveArrayNoNulls/repeats:3                 636726 us   804.114MB/s
BM_BuildPrimitiveArrayNoNulls/repeats:3                 621345 us   824.019MB/s
BM_BuildPrimitiveArrayNoNulls/repeats:3                 625008 us    819.19MB/s
BM_BuildPrimitiveArrayNoNulls/repeats:3_mean            627693 us   815.774MB/s
BM_BuildPrimitiveArrayNoNulls/repeats:3_median          625008 us    819.19MB/s
BM_BuildPrimitiveArrayNoNulls/repeats:3_stddev            8034 us   10.3829MB/s

有 jemalloc 的執行時間

BM_BuildPrimitiveArrayNoNulls/repeats:3                 630881 us   811.563MB/s
BM_BuildPrimitiveArrayNoNulls/repeats:3                 352891 us   1.41687GB/s
BM_BuildPrimitiveArrayNoNulls/repeats:3                 351039 us   1.42434GB/s
BM_BuildPrimitiveArrayNoNulls/repeats:3_mean            444937 us   1.21125GB/s
BM_BuildPrimitiveArrayNoNulls/repeats:3_median          352891 us   1.41687GB/s
BM_BuildPrimitiveArrayNoNulls/repeats:3_stddev          161035 us   371.335MB/s

基準測試針對每種配置運行了三次,以查看效能差異。每種配置的第一次運行產生了相同的效能,但在所有後續運行中,使用 jemalloc 的版本速度大約快兩倍。在這些情況下,用於建構陣列的記憶體區域可以在不移動資料的情況下就地擴展。這是可能的,因為有分配給進程的記憶體頁面未使用,但未被作業系統回收。如果沒有 jemalloc,我們就無法利用它們,原因很簡單,預設分配器沒有提供對齊重新分配的 API。