兩次拷貝操作的故事

haoheipi發表於2021-08-19

這是最好的時代,也是最壞的時代。最近,我遇到了一個效能方面的困惑,這讓我進入了一場為期數天的探索中。我正在寫部分程式碼來獲取一些條目,然後將它們新增到一個固定大小的記憶體緩衝區中,最後在緩衝區滿時將該緩衝區刷回到磁碟中。主程式碼看起來有點像這樣:

type Buffer struct {
    fh  *os.File
    n   uint
    buf [numEntries]Entry
}

func (b *Buffer) Append(ent Entry) error {
    if b.n < numEntries-1 {
        b.buf[b.n] = ent
        b.n++
        return nil
    }
    return b.appendSlow(ent)
}

我們的想法是,當緩衝區中有空間時,我們只需插入條目並增加一個計數器;當快取區滿了時,它將轉到那個寫入磁碟的較慢方法。非常地簡單,對嗎?

基準測試

我有一個關於條目大小的問題。我能將它們打包成的最小大小是 28 位元組,但對於對齊和其他情況來說,這沒有一個 2 的冪次方數合適,所以我想將它與 32 位元組進行比較。我決定編寫一個基準測試,而不是僅僅依靠我的直覺。基準測試將在每次迭代中追加固定數量的條目 (100,000) ,唯一改變的是條目大小是否為 28 或 32 位元組。

即使我不依賴我的直覺,但我發現嘗試去預測將會發生什麼是非常有用並且有趣的。於是,我心想:

大家都知道,I/O 效能通常比一個低效的小的 CPU 更占主導地位。與 32 位元組版本相比,28 位元組版本寫入的資料更少,對磁碟的重新整理也更少。即使在填充記憶體緩衝區時有點慢 (我對此表示懷疑),也會有更多的寫操作來彌補。

也許你想的是類似的事情,也許是完全不同的事情。也許你現在不是來思考的只是想讓我繼續思考。因此,我執行了以下基準測試:

func BenchmarkBuffer(b *testing.B) {
    fh := tempFile(b)
    defer fh.Close()

    buf := &Buffer{fh: fh}
    now := time.Now()
    ent := Entry{}

    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        fh.Seek(0, io.SeekStart)

        for i := 0; i < 1e5; i++ {
            _ = buf.Append(ent)
        }
        _ = buf.Flush()
    }

    b.ReportMetric(float64(time.Since(now).Nanoseconds())/float64(b.N)/1e5, "ns/key")
    b.ReportMetric(float64(buf.flushes)/float64(b.N), "flushes")
}

困惑

結果如下:

BenchmarkBuffer/28       734286 ns/op      171.0 flushes      7.343 ns/key
BenchmarkBuffer/32       436220 ns/op      196.0 flushes      4.362 ns/key

沒錯,在基準測試中,寫入磁碟資料更多的但還更快,效能相差近 2 倍!

我的探索就這樣開始了。以下是我為解答我認為正在發生的事情而進行的漫長而奇怪的探尋所做的努力。劇透警告:我錯了很多次,而且錯了很長一段時間。

探索開始

CPU 效能剖析

CPU 效能報告是有非常高的參考價值。要從 Go 基準測試中收集它們,你所要做的就是在命令列中指定 -cpuprofile=<some file> ,僅此而已。當然,這是我第一次嘗試。

不過,要記住的一件事是,Go 基準測試在預設情況下將嘗試執行固定的時間,如果一個基準測試比另一個要花更長的時間來完成它的工作,那麼它的迭代次數就會更少。因為我想更直接地比較結果,所以我確保向命令 -benchtime=2000x 傳遞固定次數的迭代。

讓我們來看看這些結果。首先,32 位元組版本:

    .          .     24:func (b *Buffer) Append(ent Entry) error {
 30ms       30ms     25:   if b.n < numEntries-1 {
110ms      110ms     26:       b.buf[b.n] = ent
 90ms       90ms     27:       b.n++
    .          .     28:       return nil
    .          .     29:   }
 10ms      520ms     30:   return b.appendSlow(ent)
    .          .     31:}

第一列顯示僅在所示函式的上下文中花費的時間,第二列是在該行 (包括它可能呼叫的任何函式) 上花費的時間。

由此,我們可以看到,與寫入記憶體緩衝區相比,大部分時間都花在了 appendSlow 中重新整理磁碟上。

這是 28 位元組的版本:

    .          .     24:func (b *Buffer) Append(ent Entry) error {
 20ms       20ms     25:   if b.n < numEntries-1 {
840ms      840ms     26:       b.buf[b.n] = ent
 20ms       20ms     27:       b.n++
    .          .     28:       return nil
    .          .     29:   }
    .      470ms     30:   return b.appendSlow(ent)
    .          .     31:}

與 32 位元組版本相比,它花費更少的時間重新整理到磁碟。這至少是符合預期的,因為它重新整理的次數更少 ( 171 次 vs 196 次)。

但也許記憶體不對齊的懲罰比我想象的更糟糕。讓我們看一下彙編程式碼,看看它使用了什麼指令。

彙編過程

下面是在上面的結果檔案中第 26 行出現 840ms 的程式碼部分:

    .          .     515129: IMULQ $0x1c, CX, CX          (1)
 90ms       90ms     51512d: LEAQ 0xc0(SP)(CX*1), CX      (2)
    .          .     515135: MOVUPS 0x7c(SP), X0          (3)
670ms      670ms     51513a: MOVUPS X0, 0(CX)             (4)
 80ms       80ms     51513d: MOVUPS 0x88(SP), X0          (5)
    .          .     515145: MOVUPS X0, 0xc(CX)           (6)

如果您以前從未讀過彙編,這可能會有點令人生畏,所以我已經對行進行了編號,並將提供一個簡短的解釋。要知道的最重要的暫存器是 CXSPX0 ,語法 0x18(CX) 意味著地址 CX + 0x18 的值。有了這些知識,我們就能理解這些行意思了:

  1. CX 暫存器乘以 0x1c 並將其儲存到 CX 中。 0x1c 是十進位制值 28 的十六進位制編碼。

  2. 這是計算我們將儲存的條目的地址。它計算 0xc0 + SP + (CX*1) 並將其儲存到 CX 中。由此,我們推斷 entry 陣列的開始位置是 0xc0(SP)

  3. 這將從 0x7c(SP) 開始載入 16 個位元組,並將其儲存到 X0

  4. 這將儲存我們剛剛載入到 0(CX) 中的 16 個位元組。

  5. 這將從 0x88(SP) 開始載入 16 個位元組並將其儲存到 X0 中。

  6. 這將儲存我們剛剛載入到 0xc(CX) 中的 16 個位元組。

我不知道你怎麼想的,但我看不出為什麼第 4 行比其他行有這麼大的權重。所以,我將它與 32 位元組版本進行了比較,看看生成的程式碼是否不同:

40ms       40ms     515129: SHLQ $0x5, CX
10ms       10ms     51512d: LEAQ 0xc8(SP)(CX*1), CX
   .          .     515135: MOVUPS 0x80(SP), X0
10ms       10ms     51513d: MOVUPS X0, 0(CX)
40ms       40ms     515140: MOVUPS 0x90(SP), X0
10ms       10ms     515148: MOVUPS X0, 0x10(CX)

看起來唯一的區別是 SHLQ vs IMULQ,但幾乎沒有時間花在這些指令上。前者是對 5 進行 “左移”,實際上是乘以 2 的 5 次方,也就是 32,而後者,正如我們之前看到的,是乘以 28。這可能是效能上的差異嗎?

流水線和埠

現代 cpu 是一個複雜的怪獸。也許你有這樣的思維模式: CPU 讀取指令,然後一次執行一個指令。但那根本不是事實。相反,它們在 流水線 中同時執行多條指令,可能是無序的。且它還有更好的地方: 它們限制了每種指令可以同時執行的數量。這是由具有多個 “埠” 的 CPU 完成的,某些指令需要並可以在這些埠的不同子集上執行。

這和 IMULQ 和 SHLQ 有什麼關係呢?好吧,你可能已經注意到,在 IMULQ/SHLQ 之後的 LEAQ 有一個乘法(CX*1)。但是,因為沒有無限的埠,所以能夠進行乘法運算的埠數量是有限的。

LLVM 專案有很多工具可以幫助您理解計算機的功能,其中一個工具叫做 LLVM-mca。實際上,如果我們通過 llvm-mca 執行 32 位元組和 28 位元組版本的第一個指令,它會讓我們知道在執行它們時將使用哪些埠:

Resource pressure by instruction (32 byte version):
[2]    [3]     [7]    [8]     Instructions:
0.50    -       -     0.50    shlq  $5, %rcx
 -     0.50    0.50    -      leaq  200(%rsp,%rcx), %rcx

Resource pressure by instruction (28 byte version):
[2]    [3]     [7]    [8]    Instructions:
 -     1.00     -      -     imulq  $28, %rcx, %rcx
 -      -      1.00    -     leaq   192(%rsp,%rcx), %rcx

這些數字是每條指令在迴圈執行時在埠上執行的時間百分比 (這裡,編號為 2 、 3 、 7 和 8 )。

也就是說,在 32 位元組版本中,SHLQ 有一半時間執行在埠 2 上,另一半時間執行在埠 8 上,LEAQ 有一半時間執行在埠 3 上,另一半時間執行在埠 7 上。這意味著它可以同時有 2 個並行執行。例如,在一次迭代中,它可以使用埠 2 和 3,在下一次迭代中,它可以使用埠 7 和 8,即使埠 2 和 3 仍然在使用。然而,對於 28 位元組版本,由於處理器的構建方式,IMULQ 只能在埠 3 上進行,這反過來又限制了最大吞吐量。

有一段時間,我以為這就是問題的原因。事實上,這篇博文的初稿就有這個結論,但我越想越覺得這個解釋不太好。

陷阱迷宮

以下是你可能會有的一些想法:

  1. 在最壞的情況下,這隻能是 2 倍的速度差。

  2. 迴圈中沒有其他指令嗎?不,那這必須使它在實際中遠遠小於 2 倍。

  3. 32 位元組版本在記憶體部分花費 230ms, 28 位元組版本花費 880ms。

  4. 這是比 2 倍大得多。

  5. 不對!

也許最後一個是我的心聲。帶著這些疑問,我試著弄清楚我該如何測試它是否與 IMULQ 和 SHLQ 有關。現在進入 “perf” 篇。

Perf

perf是一個在 linux 上執行的工具,它允許您執行程式,並暴露 cpu 儲存的關於如何執行指令的詳細計數器 (以及更多其他細節!)。現在,我不知道是否有一個計數器可以讓我看到 “流水線因埠不足或其他原因而停止” 之類的東西,但我知道它有類似於所有事情的計數器。

如果這是一部電影,這部分應該是主角在貧瘠的沙漠中跋涉,烈日炎炎,熱量從地面上升,看不到盡頭。他們會看到海市蜃樓般的綠洲,然後跳進水裡,大口大口地吞下水,突然意識到那是沙子。

快速瀏覽了一遍,perf 知道如何在我的機器上讀取 700 多個不同的計數器,我覺得我已經看過其中大部分了。如果你有興趣,可以看看 這個大表。我找不到任何計數器能解釋速度的巨大差異,我開始絕望了。

二進位制數編輯的樂趣和收益

此時,我不知道問題是什麼,但它看起來肯定不是我想的埠爭用。我認為唯一可能的事情之一就是對齊。cpu 傾向於以 2 的冪次放訪問記憶體,而 28 不是其中之一,所以我想改變基準測試,寫入 28 位元組的條目,但偏移量為 32 位元組。

不幸的是,這並不像我希望的那麼容易。被測試的程式碼與 Go 編譯器的內聯非常微妙地平衡了。基本上,對 Append 的任何更改都會導致它超過閾值並停止內聯,這實際上會改變正在執行的內容。

輸入二進位制補丁。在我們的例子中,IMULQ 指令編碼成與 SHLQ 相同的位元組數。事實上,IMULQ 編碼為486bc91c, SLHQ 編碼為 48c1e105 。因此,只需替換這些位元組並執行基準測試即可。我 (僅此一次) 就不告訴你我是如何編輯它的細節了 (好吧,我撒謊了:我經常使用 dd)。結果確實讓我大吃一驚:

BenchmarkBuffer/28@32    813529 ns/op      171.0 flushes      8.135 ns/key

我看到了這個結果,感到很挫敗。並不是 IMULQ 讓基準測試變慢了。這個基準沒有 IMULQ。這不是由於不對齊的寫入。最慢的指令是用與 32 位元組版本相同的對齊方式編寫的,正如我們從彙編效能中看到的:

    .          .     515129: SHLQ $0x5, CX
 60ms       60ms     51512d: LEAQ 0xc0(SP)(CX*1), CX
    .          .     515135: MOVUPS 0x7c(SP), X0
850ms      850ms     51513a: MOVUPS X0, 0(CX)
120ms      120ms     51513d: MOVUPS 0x88(SP), X0
    .          .     515145: MOVUPS X0, 0xc(CX)

還有什麼可以嘗試的呢?

一個小的轉變

有時,當我不知道為什麼某些程式碼慢時,我會嘗試用不同的方式編寫相同的程式碼。這可能會引起編譯器的調整,使它改變是否可以使用哪些優化,從而提供一些關於正在發生的事情的線索。本著這種精神,我把基準測試改成了這樣:

func BenchmarkBuffer(b *testing.B) {
    // ... setup code

    for i := 0; i < b.N; i++ {
        fh.Seek(0, io.SeekStart)

        for i := 0; i < 1e5; i++ {
            _ = buf.Append(Entry{})
        }
        _ = buf.Flush()
    }

    // .. teardown code
}

這很難看出區別,但它改為了每次都傳遞一個新的條目值,而不是手動將 ent 變數傳遞出迴圈。我又做了一次基準測試。

BenchmarkBuffer/28       407500 ns/op      171.0 flushes      4.075 ns/key
BenchmarkBuffer/32       446158 ns/op      196.0 flushes      4.462 ns/key

它起作用了嗎?這種變化怎麼可能導致效能差異呢?它居然比 32 位元組版本執行得更快了!像往常一樣,該看下彙編了。

 50ms       50ms     515109: IMULQ $0x1c, CX, CX
    .          .     51510d: LEAQ 0xa8(SP)(CX*1), CX
    .          .     515115: MOVUPS X0, 0(CX)
130ms      130ms     515118: MOVUPS X0, 0xc(CX)

它不再從棧中載入值來儲存到陣列中,而是直接從已經歸零的暫存器中儲存到陣列中。但是我們從前面所做的所有流水線分析中知道額外的負載應該是生效的,並且 32 位元組版本證實了這一點。它並沒有變得更快,即使它也不再從棧載入。

到底發生了什麼?

寫重疊

為了解釋這個想法,最重要的是要展示整個迴圈的彙編過程,而不僅僅是將條目寫入記憶體緩衝區的程式碼。下面是一個經過清理和註釋的較慢的 28 位元組基準測試的內部迴圈:

loop:
  INCQ AX                     (1)
  CMPQ $0x186a0, AX
  JGE exit

  MOVUPS 0x60(SP), X0         (2)
  MOVUPS X0, 0x7c(SP)
  MOVUPS 0x6c(SP), X0
  MOVUPS X0, 0x88(SP)

  MOVQ 0xb8(SP), CX           (3)
  CMPQ $0x248, CX
  JAE slow

  IMULQ $0x1c, CX, CX         (4)
  LEAQ 0xc0(SP)(CX*1), CX
  MOVUPS 0x7c(SP), X0         (5)
  MOVUPS X0, 0(CX)
  MOVUPS 0x88(SP), X0
  MOVUPS X0, 0xc(CX)

  INCQ 0xb8(SP)               (6)
  JMP loop

slow:
   // ... slow path goes here ...

exit:
  1. 增加 AX,將它與 100,000 比較,如果它更大則退出。

  2. 從 offset [0x60, 0x7c] 複製棧上的 28 個位元組到 offset [0x7c, 0x98]

  3. 載入記憶體計數器,看看記憶體緩衝區中是否還有空間。

  4. 計算條目將被寫入記憶體緩衝區的位置。

  5. 在 offset [0x7c, 0x98] 處將棧上的 28 個位元組複製到記憶體緩衝區中。

  6. 增加記憶體計數器並再次迴圈。

步驟 4 和步驟 5 是我們到目前為止一直在研究的內容。

似乎第二步看起來愚蠢而多餘。確實如此,沒有理由將棧上的值複製到棧上的另一個位置,然後從棧上的副本載入到記憶體緩衝區中。步驟 5 可以只使用偏移量 [0x60, 0x7c] 來代替,而步驟 2 可以被消除。Go 編譯器在這裡可以做得更好。

但這不應該是它慢的原因,對吧? 32 位元組的程式碼幾乎做了同樣愚蠢的事情,但它執行得很快,因為流水線或其他神奇的東西。到底發生了什麼事?

有一個關鍵的區別: 28 位元組的寫重疊。MOVUPS 指令一次寫 16 個位元組,眾所周知,16 + 16 通常大於 28。所以步驟 2 寫入位元組 [0x7c, 0x8c] 然後寫入位元組 [0x88, 0x98] 。這意味著 [0x88, 0x8c] 被寫入了兩次。下面是一個有用的 ASCII 圖表:

0x7c             0x8c
├────────────────┤
│  Write 1 (16b) │
└───────────┬────┴──────────┐
            │ Write 2 (16b) │
            ├───────────────┤
            0x88            0x98

儲存轉發

還記得 cpu 是多麼複雜的怪獸嗎?它還有更多其他的優化。一些 cpu 做的優化是它們有一個叫做 “寫緩衝區” 的東西。你看,記憶體訪問通常是 cpu 所做的最慢的部分。相反,你知道,當指令執行時,實際寫入記憶體前,cpu 首先將寫入放進緩衝區。我認為其思想是,在向較慢的記憶體子系統輸出之前,將一組較小的寫操作合併為較大的寫操作。聽起來是不是很熟悉?

現在它有一個寫緩衝區來緩衝所有寫操作。如果在這些寫操作中有一個讀操作會發生什麼呢?如果在讀取資料之前必須等待寫操作實際發生,那麼它會減慢所有操作的速度,因此,如果可能的話,它會嘗試直接從寫緩衝區處理讀操作,沒有人比它更聰明。這種優化稱為 儲存轉發

但是如果這些寫重疊呢?事實證明,至少在我的 CPU 上,這抑制了 “儲存轉發” 的優化。甚至還有一個效能計數器來跟蹤這種情況的發生:ld_blocks.store_forward

實際上,關於那個計數器的文件是這樣描述:

計算阻止儲存轉發的次數主要來源於載入操作。最常見的情況是由於記憶體訪問地址 (部分) 與前面未完成的儲存重疊而導致負載阻塞。

以下是到目前為止,計數器命中不同基準測試的頻率,“慢” 表示該條目在迴圈外構造,“快” 表示在每次迭代中該條目在迴圈內構造:

BenchmarkBuffer/28-Slow      7.292 ns/key      1,006,025,599 ld_blocks.store_forward
BenchmarkBuffer/32-Slow      4.394 ns/key          1,973,930 ld_blocks.store_forward
BenchmarkBuffer/28-Fast      4.078 ns/key          4,433,624 ld_blocks.store_forward
BenchmarkBuffer/32-Fast      4.369 ns/key          1,974,915 ld_blocks.store_forward

是的,十億通常比一百萬大。開香檳慶祝吧。

結論

在這之後,我有一些想法。

基準測試是困難的。人們經常這麼說,但也許唯一比基準測試更難的事情是充分傳達基準測試有多困難。比如,這更接近於微觀基準測試,而不是巨集觀基準測試,但仍然包括執行數以百萬計的操作,包括磁碟重新整理,並實際測量了實際效果。但與此同時,這在實踐中幾乎不會成為問題。它需要編譯器將一個常量值溢位到棧中,而這個常量值與隨後的讀入非常接近,這是沒有必要的。建立條目所做的任何實際工作都會導致這種效果消失。

隨著我對 cpu 工作方式瞭解的越來越多,一個反覆出現的主題是,你越接近 cpu 工作的 “核心”,它就越容易洩漏,也就越充滿邊緣情況和危險。例如,如果有部分重疊的寫,儲存轉發將會不工作。另一個原因是快取不是 完全關聯的,所以你只能根據它們的記憶體地址快取這麼多東西。比如,即使你有 1000 個可用的插槽,如果你所有的記憶體訪問都是某個因子的倍數,他們可能無法使用這些插槽。這篇博文有很好的討論。我猜測,也許這是因為在更嚴格的物理約束下,解決這些邊緣情況的 “空間” 更小了。

在此之前,我從未能夠在實際的非人為設定中具體觀察到埠耗盡問題導致的 CPU 減慢。我聽過這樣的說法,你可以想象每一條 CPU 指令佔用 0 個週期,除了那些與記憶體有關的指令。作為初步估計,這似乎是正確的。

我已經把完整的程式碼示例放在 a gist中,供您檢視/下載/執行/校驗。

通常情況下,探索比結果更重要,我認為在這裡也是如此。如果你能走到這一步,謝謝你陪我一路走來,希望你喜歡。下次再見。

更多原創文章乾貨分享,請關注公眾號
  • 兩次拷貝操作的故事
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章