『每週譯 Go』兩次拷貝操作的故事

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

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 中,供您查看 / 下載 / 運行 / 校驗。

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

● 原文地址:https://storj.io/blog/a-tale-of-two-copies

● 原文作者:Jeff Wendling

● 本文永久鏈接:https://github.com/gocn/translator/blob/master/2021/w32_A_Tale_Of_Two_Copies

● 譯者:haoheipi

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/pZ4_tZbS8DpVCmD_MfMXXQ