限制因素與記憶體效能 (Limiting Factors & Memory Performance)

Overview Table

小節 主題 核心結論
5.11.1 (p.584) Register Spilling 平行度 P 超過可用暫存器數量時,accumulator 被溢出到 stack,增加 load/store,unrolling 效益消失
5.11.2 (p.585) Branch Prediction / Misprediction 可預測分支幾乎零成本;不可預測分支每次 misprediction 罰款約 19 cycles;改寫成 conditional move 可消除罰款
5.12.1 (p.590) Load Performance 2 個 load unit → CPE 下限 k/2(每元素需 k 次 load);load 形成 critical path 時受 latency 4 cycles(L1)限制
5.12.2 (p.591) Store Performance store 完全 pipeline(CPE 1.0);write/read dependency(store 後緊接同址 load)使 CPE 由 1.3 升至 7.3
5.13 (p.597) 實務優化策略 高階設計 → 基本編碼原則 → 低階優化;改寫時用 checking code 防 bug
5.14 (p.598) Profiling 與 Amdahl gprof 找瓶頸;n-gram 案例從 3.5 分鐘優化到 0.2 秒(約 1000×);Amdahl's law 解釋單點優化上限
5.15 (p.604) 總結 程式員需消除 optimization blockers、理解 microarchitecture、用 profiler 聚焦大程式瓶頸

本章前段已建立兩個效能下限,本節探討「為何實際程式達不到、或如何逼近這些下限」:


5.11.1 Register Spilling(暫存器溢出)(p.584)

Loop parallelism 的效益受限於「能否用暫存器表達計算」。若程式的平行度 P 超過可用暫存器數,編譯器會進行 spilling:把部分暫時值存到記憶體(通常配置在 run-time stack)。

10x10 unrolling(acc0 在暫存器):
  vmulsd (%rdx), %xmm0, %xmm0        ; acc0 *= data[i](1 條指令)

20x20 unrolling(acc0 溢出至 stack):
  vmovsd 40(%rsp), %xmm0             ; 從 stack 讀 acc0
  vmulsd (%rdx), %xmm0, %xmm0        ; 乘 data[i]
  vmovsd %xmm0, 40(%rsp)             ; 寫回 stack
Tip

幸運的是 x86-64 暫存器夠多,大多數迴圈會先達到 throughput bound,才會發生 spilling。所以 unrolling 因子不必(也不該)無限加大。

5.11.2 Branch Prediction 與 Misprediction Penalty(p.585)

現代處理器採 speculative execution:遇到分支時猜測方向,先執行預測目標的指令,但不真正修改暫存器或記憶體;預測正確才 commit 結果,預測錯誤則丟棄所有推測結果、從正確位置重新 fetch——重新填滿 pipeline 的代價就是 misprediction penalty(參考機器約 19 cycles)。

分支的推測執行流程:

  fetch branch ──> 預測方向 ──> 沿預測路徑推測執行(結果暫存,不 commit)
                                   │
                     分支結果確定 ──┤
                                   ├─ 預測正確 → commit 結果(零額外成本)
                                   └─ 預測錯誤 → 丟棄推測結果
                                                → 從正確位址重新 fetch
                                                → 罰款 ~19 cycles(refill pipeline)

需要預測的對象:conditional jump(是否 taken)、indirect jump(如 jump table)與 procedure return(目標位址)。本節聚焦 conditional branch。

原則一:不必擔心「可預測」的分支(p.586)

原則二:改寫成適合 conditional move 的程式碼(p.587)

分支預測只對規律模式可靠;依資料任意特性(如正負)決定的分支本質上不可預測。此時應讓編譯器產生 conditional data transfer(cmov) 而非 conditional control transfer:先算出兩個分支的值,再用條件搬移選取,無需猜測、無罰款

寫法風格 範例(minmax) 隨機資料 CPE 可預測資料 CPE
Imperative(條件式地更新狀態) if (a[i] > b[i]) { swap } ~13.5 2.5–3.5
Functional(先算值再無條件賦值) min = a[i]<b[i] ? a[i]:b[i]; max = ...; a[i]=min; b[i]=max; ~4.0 ~4.0
Warning

例外:並非所有條件行為都能用 conditional move 實作(見 03-Machine-Level-Programs/03-Control-Flow)——兩邊都會被求值,若某一邊可能造成錯誤(如 null pointer dereference)或副作用、或計算成本過高,編譯器不會(也不該)使用 cmov。此時分支不可避免。

Exam 素材(Practice Problem 5.9,p.589)

mergesort 的 merge 步驟中,i1 < n && i2 < n 這類邊界比較高度可預測(只在變 false 時 mispredict 一次);但 src1[i1] < src2[i2] 的資料比較高度不可預測,隨機資料 CPE ≈ 15.0。解法:改寫成先取 v = min(src1[i1], src2[i2])、再用條件運算更新索引,使其可編譯為 cmov。


5.12 Understanding Memory Performance(p.589)

本節僅考慮所有資料都在 cache 中的情形(cache 細節見第 6 章)。現代處理器有專用 load/store functional units,內含 buffer 保存未完成的記憶體請求:

5.12.1 Load Performance(p.590)

Load 效能取決於兩個面向:throughput(pipeline 能力)latency

list_len 的 critical path(load latency 決定 CPE):

     %rdi ──> [load] ──> %rdi' ──> [load] ──> %rdi'' ──> ...
               (4c)                 (4c)
     每次 load 的位址 = 上一次 load 的結果 → 無法重疊

5.12.2 Store Performance(p.591)

Store Buffer 與位址比對

        Load unit                       Store unit
   ┌───────────────┐             ┌─────────────────────┐
   │               │   matching  │     Store buffer     │
   │   address ────┼────────────>│  ┌─────────┬──────┐  │
   │               │  addresses  │  │ Address │ Data │  │
   │      data <───┼─────────────│  ├─────────┼──────┤  │
   │               │  (若位址相符 │  │   ...   │ ...  │  │
   └──────┬────────┘   直接轉發) └──┴────┬────┴──────┴──┘
          │                              │
          v                              v
   ┌─────────────────────────────────────────┐
   │                Data cache               │
   └─────────────────────────────────────────┘

write_read 實驗:s_addr / s_data 分解

movq %rax,(%rsi) 這條 store 指令被解碼成兩個獨立操作:

write_read 內迴圈 (*dst = val; val = (*src)+1; cnt--) 的相依關係:

   %rax    %rsi    %rdi    %rdx
    │       │       │       │
    │       └──> [s_addr]   │      (1) s_addr 必須先於 s_data(entry 先建立)
    ├──────────> [s_data]   │      (2) load 必須與所有 pending store 比對位址
    │               ┆(3)    │          → load 相依於 s_addr
    │       ┌──> [load] <── %rdi   (3) 虛線:條件相依——僅當位址相符時,
    │       │       │       │          load 才須等 s_data 寫入 buffer
    v       v       v       v
  [add $1] <────────┘    [sub $1]
    │                       │
   %rax'                  %rdx'

兩種呼叫方式的 critical path 對比:

Example A:src != dst Example B:src == dst
位址比對 不相符 → load 與 store 獨立進行 相符 → load 必須等 s_data
Critical path 只剩 cnt--(sub)鏈 s_data → load → add 鏈,每輪 ~7 cycles
量測 CPE 1.3(理論下限 1.0) 7.3(write/read dependency 慢約 6 cycles)
Example A(位址不同)         Example B(位址相同)
  s_data   (獨立)               s_data ─┐
  load     (獨立)                       v
    │                                 load
    v                                   │
  [sub] ← 唯一 critical path            v
    │                                 [add] ──> 下一輪 s_data(critical path)
Practice Problem 5.10/5.11/5.12 的考點(p.595–597)

  • copy_array(a+1, a, 999):load 領先 store(讀舊值),無相依 → CPE 1.2。
  • copy_array(a, a+1, 999):每次 load 讀到上一輪 store 的值(整個陣列被填成 a[0])→ write/read dependency → CPE 5.0。
  • psum1 CPE 9.00 遠高於 fp add latency 3:因為每輪 store p[i] 後、下一輪 load p[i-1] 同址,critical path = load → add → s_data 循環;改用暫存變數保存前一次和(消除重複記憶體讀取)即降到 CPE 3.00(= fp add latency)。


5.13 實務效能改善技巧(p.597)

三層優化策略總覽:

層級 策略
High-level design 選對演算法與資料結構;警惕漸進複雜度差的寫法
Basic coding principles 避免 optimization blockers:消除多餘函式呼叫(移出迴圈、必要時犧牲部分模組化)、消除不必要記憶體參照(用暫時變數存中間結果,最終值才寫回)
Low-level optimizations loop unrolling 降 overhead;multiple accumulators / reassociation 提高 ILP;條件行為改寫成 functional style 促成 cmov
Warning

改寫求快極易引入 bug(新變數、改 loop bounds、程式碼複雜化)。務必使用 checking code:對每個優化版本跑一系列測試、比對與原版結果一致。程式越優化,測試集要越完整——例如 unrolling 後必須測多種不同的 n,涵蓋收尾單步迭代的各種數量。


5.14 找出並消除效能瓶頸(p.598)

大程式連「該優化哪裡」都不明顯,需要 code profiler(在執行時收集效能資料的分析工具)。

5.14.1 Program Profiling:gprof(p.598)

Profiling = 在程式中加入 instrumentation code,量測各部分耗時;優點是可用真實 benchmark 資料執行實際程式。gprof 提供兩種資訊:①各函式耗費的 CPU time;②各函式被呼叫次數(按 caller 分類)。

gprof 三步驟:
  1. 編譯連結時加 -pg(用 -Og,避免 inline 使呼叫計數失真)
       linux> gcc -Og -pg prog.c -o prog
  2. 正常執行(慢約 2 倍),產生 gmon.out
       linux> ./prog file.txt
  3. 分析
       linux> gprof prog

報告解讀:

gprof 的特性(限制):

5.14.2 用 Profiler 引導優化:n-gram 案例(p.601)

分析莎士比亞全集(965,028 詞)的 bigram 統計(363,039 個 unique bigrams),六個版本的演進:

版本 改動 時間 教訓
Initial insertion sort + 遞迴 list + 1,021 buckets + 加總 hash + lower1 ~209 s(3.5 分) 大部分時間在排序(quadratic)
Quicksort 改用 qsort(O(n log n)) ~5.4 s 消除最大瓶頸 → 新瓶頸浮現(list 掃描)
Iter first 遞迴改迭代,但插在 list 前端 ~7.5 s(變慢!) 頻繁 n-gram 被推到 list 後面 → 必須實驗驗證,別靠直覺
Iter last 迭代 + 插在 list 尾端 ~5.3 s 先出現的高頻 n-gram 留在前面
Big table buckets 1,021 → 199,999(平均 load 355.6 → 1.8) ~5.1 s(僅 −0.2 s) 加大表沒用 → 問題在 hash function
Better hash 加總 hash → shift + XOR hash ~0.6 s 加總 hash 值域太窄(≤122n)且可交換("rat" = "tar")
Linear lower lower1(quadratic)→ lower2(linear) ~0.2 s 總計約 1000× 加速
Profiling 的例外與盲點(p.604)

Profile 結果只適用於所測資料:若改測「少量長字串」,瓶頸會變成 lowercase 轉換;若只測短字串,可能永遠測不出 lower1 的 quadratic 隱藏瓶頸。Profiling 幫你優化 typical case,但仍須避免漸進效能差的演算法/寫法,確保 all cases 都有基本水準。

Amdahl's Law 的應用(p.604)

S=1(1α)+α/k

5.15 Summary(p.604)


Exam/Test Patterns

情境 / 關鍵字 答案
unrolling 因子加大(10×10 → 20×20)CPE 反而變差 Register spilling:accumulator 數超過 16 個整數 / 16 個 YMM 暫存器,被溢出到 stack,每次更新多出 load/store
判斷某段組語是否發生 spilling accumulator 更新出現 vmovsd 40(%rsp),%xmm0 ... vmovsd %xmm0,40(%rsp)(以 %rsp 位移存取)即是
分支 misprediction penalty 數值 參考機器約 19 cycles(minmax 實驗觀察到約 20)
迴圈收尾分支(loop-closing branch)的成本 幾乎為零——預測為 taken,只在最後一輪 mispredict 一次
bounds checking 是否拖慢程式 幾乎不會:分支恆成立、高度可預測,檢查計算與 critical path 平行進行
「隨機資料很慢、排序過資料很快」的函式 不可預測分支 + misprediction penalty;改寫成 functional style(?: 先算 min/max 再賦值)促成 cmov
CPE 下限 0.50 的來源(combining) 2 個 load units、每元素 1 次 load;每元素 k 次 load 時 CPE ≥ k/2
linked list 走訪 CPE = 4.00 的意義 load 位址依賴前次 load 結果 → critical path = load 鏈 → CPE = load latency = L1 存取 4 cycles
clear_array CPE = 1.0 單一 store unit、每 cycle 發 1 個 store;store 之間無資料相依
write_read 同址 vs 異址(CPE 7.3 vs 1.3) write/read dependency:同址時 load 須等 s_data 寫入 store buffer,critical path = s_data→load→add(~7 cycles);異址時兩者獨立
copy_array(a,a+1,n)copy_array(a+1,a,n) 前者每輪 load 讀到上輪 store 的位置 → write/read dependency(CPE 5.0 vs 1.2)
psum1 CPE 9.00 的診斷與修復 critical path = load p[i-1] → fp add → store p[i] 循環;用暫存變數保存前次和 → CPE 3.00(= fp add latency)
gprof 使用步驟 / 注意事項 gcc -Og -pg → 執行產生 gmon.outgprof prog;計時採 interval counting(δ ≈ 1–10 ms),<1 秒程式不準;呼叫計數可靠(需無 inline);library 時間併入 caller
由 gprof 呼叫計數推 list 平均長度 遞迴呼叫次數 ÷ 頂層呼叫次數(158,655,725/965,027 ≈ 164)
遞迴改迭代反而變慢 插入位置由尾端變前端,高頻元素被排到後面——必須實測,直覺會錯
hash table 加大 buckets 卻沒加速 hash function 太差:加總 ASCII 值域窄(≤122n)且可交換(rat=tar)→ 改用 shift+XOR
以 Amdahl 預測優化上限 α = 瓶頸佔比;S=1/((1α)+α/k);瓶頸完全消除時 Smax=1/(1α)
basic block 定義(valgrind/vtune 分析單位) 中間沒有控制轉移離開的指令序列,執行必整段執行