從網頁對話到 coding agent第 8 篇,共 9 篇

高效計算

跑一整天的 code,先量測,再向量化與平行化。

發佈於

經常聽到有人說自己的 code 或模擬一跑就是一天。耗時的原因之一是寫法,逐筆處理的迴圈在 R 與 Python 裡比整批運算慢上數倍至數十倍,寫得不巧時可以慢上千倍。

另一個原因是多數核心沒有用上,打開工作管理員一看,CPU 的使用率不到 10%,整台電腦只有一個核心在計算,其餘都閒置;沒開過工作管理員的人,也就不會發現。R 與 Python 預設都只用一個核心執行使用者寫的 code。

本篇先以量測找出耗時的部分,再處理寫法,也就是向量化與逐次加長,最後才是平行運算及其陷阱。

熱點分析

一份分析的耗時通常集中在少數幾段,其餘加起來不過幾秒,耗時集中的這幾段稱為熱點(hotspot)。改寫之前如果不先量測,容易把工夫花在本來就快的部分,code 變得難讀,總耗時卻幾乎不變。

Profiler 的原理

分段計時須在腳本裡插入計時的 code,也只量得到事先劃好的幾段。Profiler 是另一種量測工具,它在 code 執行期間每隔一段時間記下當下正在執行哪一行,好比監考老師每隔幾分鐘看一眼某位考生正在寫第幾題,看過幾十次之後,他在哪一題停留最久便看得出來,不必在他每題開始與結束時按碼錶。這種做法稱為取樣(sampling),腳本本身不必改動,量測也幾乎不拖慢 code。

以一個 Monte Carlo 模擬為例:從指數分布抽 30 筆樣本,比較 t 區間與 bootstrap 百分位區間的覆蓋率,重複兩千次,每次 bootstrap 五百次。以 profiler 執行一次,各行所佔的時間如下:

1set.seed(1)
2n_sim <- 2000
3n <- 30
4B <- 500
5true_mean <- 1
6
7results <- data.frame()
8for (sim in 1:n_sim) {
9 x <- rexp(n, rate = 1 / true_mean)
102 ci <- t.test(x)$conf.int
11 boot <- numeric(B)
12 for (b in 1:B) {
1385 boot[b] <- mean(sample(x, n, replace = TRUE))
14 }
154 b_ci <- quantile(boot, c(0.025, 0.975))
164 row <- data.frame(t = ci[1] < true_mean & true_mean < ci[2],
17 boot = b_ci[1] < true_mean & true_mean < b_ci[2])
185 results <- rbind(results, row)
19}
20
21print(colMeans(results))

85%line 13

時間幾乎都花在 bootstrap 的內圈,也就是每次重抽樣本求平均的那一行,R 佔八成五,Python 佔七成五;看起來較繁重的 t.test(),以及每次重複都把結果接上資料框的那一行,加起來不到一成。這一行改成一次抽出全部的 bootstrap 樣本之後,R 的耗時由 5.2 秒降為 1.2 秒,Python 由 7.4 秒降為 1.6 秒,覆蓋率都與改寫前相同,這種改寫方式見下文的「向量化」一節。

R 內建的 Rprof() 就是取樣式的 profiler,RStudio 的 Profile 選單也以它為基礎,結果以互動的圖呈現。Python 內建的 cProfile 則是在每個函數進出時記錄,只記到函數,看不出函數裡哪一行最慢,函數呼叫極多時還會拖慢執行,上例在 cProfile 之下由 6.6 秒變成 12.3 秒;它也只量主行程,平行運算時 worker 裡的計算量不到。Python 的腳本因此改用 py-spy 量測。

py-spy

py-spy 是一個獨立的工具,從外部讀取 Python 行程的記憶體,因此不必修改腳本,也不必裝進專案的環境,Windows、macOS 與 Linux 都能使用,請 agent 以 uv tool install py-spy 安裝即可,uv 見「Python:uv」一節。

py-spy record 從頭執行一支腳本並全程取樣,結束後寫出結果檔,預設是一張火焰圖(flame graph),以瀏覽器開啟,區塊越寬佔用的時間越多;指定輸出純文字時,每一行是一條呼叫路徑與它的取樣數,agent 讀的是這一種。py-spy top 以類似工作管理員的畫面即時列出最耗時的函數。py-spy dump 印出一個執行中的行程此刻停在哪一行,已經跑了數小時的模擬不必停下來重跑,便能知道它卡在哪裡。

上例的 Python 版本以 py-spy record 畫出的火焰圖如下,只截取最上面 20 層,可以照下一段的說明直接操作:

火焰圖最上面一條是整支腳本,往下每一層是上一層呼叫的函數,例如 <module> (mc.py:15) 之下是第 15 行用到的 NumPy 函數;每個區塊的寬度是它所佔的時間比例,左右的先後依名稱排列,與執行的先後無關,顏色也沒有意義。名稱太長時區塊上只顯示一部分,滑鼠移到區塊上會顯示全名與比例,點一下則把該區塊放大到整個寬度。上例右側那一叢又窄又深的區塊是載入 scipy,層數雖多卻不寬,因此不是熱點,熱點只看區塊的寬度。

與本篇有關的選項之一是 --subprocesses,連同 worker 一起取樣,可以確認平行版本的每個 worker 都在計算,見下文的「沒有報錯也沒有變快」一節;另一個是 --native,連同 NumPy 這類以 C 寫成的底層一起取樣,看得出時間花在 NumPy 的哪一個運算,只支援 Windows 與 Linux。macOS 上執行 py-spy 一律須系統管理員權限,Linux 上接上已在執行的行程時也須。

量測可以交給 agent:

以 profiler 執行這個專案的主要腳本,R 用 Rprof() 並開啟逐行記錄,
Python 用 py-spy record 並輸出純文字,列出最耗時的幾行與各自所佔的比例,
由長到短排列。先不要修改任何檔案。

已經在執行的 Python 腳本可以這樣查看:

這個 Python 腳本正在執行,用 py-spy dump 查看它目前停在哪裡,
每隔一分鐘看一次,共看三次,告訴我時間主要花在哪一段。

此後只處理排在最前面的一兩處。這一次的計時也是日後比較的基準。完整執行一次如果須數小時,先請 agent 以縮小的資料或較少的重複次數量測,各處所佔的比例大致不變。

向量化

R 與 Python 執行 code 時是逐行解讀的,每做一步運算都須先辨認資料的型別、找出對應的函數,這些準備所花的時間遠多於運算本身。迴圈跑一千萬圈,準備便做一千萬次。向量化是把整批資料交給一個運算一次處理,準備只做一次,其餘的重複交由底層以 C 或 Fortran 寫成的 code 完成。

以一千萬筆的單價與數量求總金額為例,迴圈的寫法在 R 與 Python 分別是:

total <- 0
for (i in seq_along(price)) {
total <- total + price[i] * quantity[i]
}
total = 0.0
for i in range(len(price)):
total += price[i] * quantity[i]

向量化之後各只剩一行,其中 Python 的 price 與 quantity 須是 NumPy 陣列:

total <- sum(price * quantity)
total = np.sum(price * quantity)

R 的向量與矩陣本身就支援整批運算,加減乘除、比較、sum()、rowSums()、矩陣相乘的 %*% 都是。Python 內建的 list 不支援整批運算,數值資料須放進 NumPy 陣列,表格資料則用 pandas,後者的欄位運算同樣是整批進行的。

這幾種寫法實際執行的耗時如下,每項重複數次取中位數;Python 的迴圈依資料放在 list 或 NumPy 陣列各測一次:

資料放進 NumPy 陣列之後如果仍逐筆跑迴圈,反而比 list 更慢,因為每取一筆都須把它另外包成一個 Python 物件。

改寫可以這樣交代:

把剛才最耗時的那一段改寫成向量化的寫法,不要改變計算的內容。
改寫前後各計時一次,並比對兩個版本的結果是否相同。

向量化之後的結果應與迴圈版本相同,至多在最後幾位小數有出入。

並非每個迴圈都值得改。圈數不多而每一圈本身費時的計算,例如每圈讀取一個檔案或執行一次模型,逐行解讀的開銷只佔極小的比例,向量化幫不上忙,這類計算是下文平行運算的對象。向量化也須一次把整批資料與中間結果放進記憶體,資料大到放不下時,只能分批處理。

逐次加長

慢上千倍的迴圈,多半是每一圈都把結果接到向量或資料表的後面。向量、陣列與資料表在記憶體裡佔的是一段連續的空間,大小在建立時便已確定。要在後面多加一筆,原本的空間通常沒有餘地,R 與 NumPy 的做法是另外申請一段更大的空間,把既有的資料整份複製過去,再加上新的一筆,然後放掉舊的空間,資料越長,複製越久。

length 3
Appendcopied 0
123
Preallocatecopied 0
123

在迴圈裡逐次加長的寫法,R 與 Python 分別如下:

result <- c()
for (i in seq_len(n)) {
result <- c(result, price[i] * quantity[i])
}
result = np.array([])
for i in range(n):
result = np.append(result, price[i] * quantity[i])

第 n 圈須先複製前 n − 1 筆,全部跑完所複製的總量與筆數的平方成正比。事先備妥完整長度的空間,則每圈只是把結果寫進已有的位置,沒有申請,也沒有複製:

result <- numeric(n)
for (i in seq_len(n)) {
result[i] <- price[i] * quantity[i]
}
result = np.empty(n)
for i in range(n):
result[i] = price[i] * quantity[i]

下表是在 R 裡以上述兩種迴圈與向量化的寫法,把兩個向量逐筆相乘並存下每一筆結果的耗時:

筆數加倍,逐次加長的寫法耗時變成四倍,其餘兩種只是加倍;照此推算,一千萬筆須算上十餘個小時,而向量化不到一秒。同樣是迴圈,先備妥空間再逐格填入便快了上千倍,可見拖慢速度的是加長,而非迴圈本身。

做法因此依事先知道多少而定。知道最終的筆數時,先建立完整長度的向量或陣列,再逐格填入。只知道上限時,照上限建立,結束之後截去沒有用到的部分。兩者都不知道時,把各圈的結果先收集起來,最後合併一次;R 與 Python 的 list 都適合用來收集,其中 Python 的 list 會預留多餘的空間,逐次 append() 的代價很小,這也是它與 NumPy 陣列不同之處。

這個問題不限於數值的迴圈,最常見的其實是合併檔案:逐一讀取數百個檔案,每讀一個便以 R 的 rbind() 或 pandas 的 concat() 接到總表後面,總表越來越大,每接一次都整份複製。改成先把各個檔案讀成的表格收集起來,全部讀完再合併一次即可。

既有的 code 裡有沒有這類寫法,可以請 agent 檢查:

檢查這個專案的迴圈裡有沒有逐次加長向量、陣列或資料表的寫法。
有的話,知道最終大小或上限的改成先建立完整長度再填入,
不知道的改成先收集、最後合併一次。改寫前後各計時一次,並比對結果是否相同。

BLAS(選讀)

做大型矩陣運算的 R 讀者才需要這一節。矩陣相乘、分解與求解方程組這類運算,最終是交給一個稱為 BLAS(Basic Linear Algebra Subprograms)的底層元件執行的。BLAS 有多種版本,快慢相差不小。以 pip 安裝的 NumPy 已內附其中較快的 OpenBLAS,Python 讀者不必處理。Windows 上自 CRAN 安裝的 R 內附的則是參考版,只保證正確與通用,並未針對處理器調校,也只用一個核心。

R 的這個元件可以換成 OpenBLAS,code 不必改動,做法見 R-openblas-in-windows 的說明。但這並非官方支援的安裝方式,R 的安裝手冊也提醒,調校過的版本結果可能略有出入,同時使用多個核心的版本有時反而更慢。只有量測之後確定耗時集中在大型矩陣運算,才值得更換;更換之後 OpenBLAS 會自行使用多個核心,再做平行運算時須留意「平行化的陷阱」一節的「核心被重複佔用」。

平行運算

改成向量化、也不再逐次加長之後仍然慢的計算,才輪到平行運算。

核心的概念與查看方式

核心(core)是處理器裡實際執行計算的單位,一個核心同一時間只做一件事。不妨想成助教改考卷,一個助教一次只能改一份,五百份考卷交給一個助教須改上一整天,分給八個助教則各改六十餘份,彼此不需交談,最後把成績彙整即可。現今的筆記型電腦多半有四至十餘個核心。

Windows 上的查看方式是按 Ctrl+Shift+Esc 開啟工作管理員,切到「效能」分頁並點選 CPU,右下方會列出核心數與邏輯處理器數。兩個數字經常不同,後者約為前者的兩倍,這是因為部分處理器讓一個實體核心同時排入兩件工作,系統便將它算成兩個邏輯處理器;對計算量大的工作而言,真正的產能較接近實體核心數。

電腦上每一個執行中的軟體稱為行程(process)。平行運算時派出去計算的每一個單位稱為 worker,也就是上述比喻裡的助教,通常是另外啟動的幾個 R 或 Python 行程,各自獨立計算,算完把結果交回原本的那一個,後者稱為主行程。worker 的數量一般不超過實體核心數,並且留一個核心給系統與其他軟體,否則計算期間電腦會慢得無法做別的事。

下圖以二十四個檔案各算一次摘要為例,按下 Run 之後,上方由主行程依序處理,下方先啟動四個 worker,再同時處理各自的六個檔案,最後由主行程彙整;把 Files 切換成 4 再執行一次,循序反而先完成,原因見「不值得平行化的情況」。

Files
Sequentialmain12340.0 s
Parallelmain12340.0 s

可平行化的工作

一段計算能否平行,取決於每一次計算是否不需要其他次的結果。如果是,則先算哪一次、由誰來算都沒有差別,可以拆開分給各個 worker;如果某一次須等上一次算完才能開始,便只能排隊。

資料分析與科學計算裡符合這個條件的工作相當多:對數百個檔案各做同樣的處理,例如逐一讀取各測站的紀錄並算出摘要;同一個模型以多組參數各執行一次;同一個模擬以不同的亂數重複數千次;同一套分析逐一套用於多份資料。每一次都用不到其他次的結果。

不符合的典型是每一步都須用到上一步結果的計算,例如迭代至收斂的最佳化,或逐期往前推的模擬,第 t 步的起點是第 t−1 步的結果,無法拆開。

實際的 code 未必一眼看得出屬於哪一類,例如迴圈裡如果一邊計算、一邊把結果累加到同一個變數,或者每一圈都修改同一個物件,表面上看似相依,實際上多半可以改寫成獨立的形式。這一類判斷可以直接詢問 agent:

閱讀這個專案裡的腳本,找出其中重複執行的計算,
逐一說明每一次重複之間是否彼此獨立、能否平行化,並說明理由。先不要修改任何檔案。

請 agent 改寫

平行化的寫法因語言而異,R 常用 future 系列套件,Python 常用 joblib 或內建的 concurrent.futures 與 multiprocessing。這些名稱與語法都不需記住,agent 會依專案所用的語言選擇,使用者看得出它改了哪一段、worker 開了幾個即可。

給 agent 的指示可以這樣寫:

這個專案裡最花時間的那段重複計算目前是循序執行的,請依序完成以下各步,每一步做完先回報:
1. 不修改 code,先執行一次並計時,記下耗時與結果。
2. 說明這段計算能否平行化;如果可以,提出改寫方式,等我同意後再動手。
3. 改寫成平行版本,worker 數取實體核心數減一;
計算用到亂數時,設定可重現的亂數種子。
4. 執行平行版本並計時,同時確認各個核心是否都在計算。
5. 列表對照兩個版本的耗時與結果,並說明兩者的結果是否一致。

Agent 如果回報尚未安裝所需的套件,同意它安裝即可;Codex 預設關閉網路,安裝套件時會多一次核准提示。

不值得平行化的情況

平行運算有固定的成本。啟動 worker 就是另外啟動數個 R 或 Python,各自須載入套件;資料與函數須從主行程複製到每個 worker,結果算完又須傳回來彙整,好比找八位助教改考卷,須先一一說明評分標準、分發考卷,改完還要收回登記成績,考卷如果只有十份,自己改還比較快。

這筆成本在 Windows 上比在 Linux 與 macOS 上高。後兩者的作業系統可以直接複製一個執行中的行程,這項功能稱為 fork,worker 一產生便帶著主行程已載入的套件與資料,幾乎不需準備;Windows 沒有此功能,每個 worker 都須從頭啟動、重新載入套件,資料也須逐份傳送過去。網路上的範例與 agent 慣用的寫法多半是在 Linux 或 macOS 上寫成的,同一份 code 搬到 Windows 上,啟動較慢,記憶體用得較多,有的寫法還會失效。

有幾種情況,平行之後可能沒有變快,甚至更慢。一種是總耗時本來就短。如果每一次重複只需幾毫秒,整體循序執行不過數秒,則啟動 worker 與搬運資料所花的時間比省下的還多。平行化適合單次耗時明顯、總耗時以分鐘或小時計的工作;等待時間尚可忍受的工作,維持循序較為單純。

另一種是瓶頸在讀寫硬碟。如果耗時主要來自讀取數百個檔案或寫出大量結果,核心本就多半在等待硬碟,再多派幾個 worker 只是一起等。逐一處理多個檔案的工作因此要看時間花在哪裡,讀進來之後的計算佔大部分時值得平行,讀取本身佔大部分時不值得。

還有一種是能拆開的部分只佔一小段。讀取資料、彙整結果這類無法拆開的步驟仍舊只由一個核心執行,核心加得再多也不會縮短,加速因此有上限。一段十分鐘的工作,如果其中一分鐘是讀取與彙整,其餘九分鐘可以拆開,八個 worker 最多把那九分鐘縮成一分多鐘,全程仍須兩分多鐘,快了四倍多,而非八倍。

1
19
8
11.1
0246810min

4.7×limit 10×

這幾種情況都不易事先看出,因此先計時再改寫的做法同樣適用,沒有變快便退回循序版本,「提交的概念」一節的提交在此正好用得上。也可以在動手之前先問 agent:

這段計算單次大約要多久、資料有多大、哪些步驟無法拆開?
依此判斷平行化是否划算,並估計大概能快多少。

平行化的陷阱

以下幾項是改寫之後才出現的,有的讓平行版本沒有變快,有的不報錯卻讓結果出錯,還有中途失敗時已算完的結果一起丟失。改寫雖由 agent 動手,這幾項仍須使用者知道,才能要求它檢查。

沒有報錯也沒有變快

有些平行的寫法在特定條件下會退回只用一個核心,code 照常執行完畢,結果也正確,只是沒有變快。R 的 parallel::mclapply() 是網路上常見的範例,它依靠 fork,在 Windows 上因此只以單一核心執行,見「平行運算」一節的「不值得平行化的情況」。Python 則有另一種情形:預設安裝的 Python 同一時間只允許一個執行緒(thread,同一個行程之內的分工單位)執行 Python code,這項限制稱為 GIL,因此計算量大的工作即使分給多個執行緒,也只用得到一個核心,須改成啟動多個行程才有效。

兩者的成因不同,檢查的方式相同:改寫前後各計時一次,並在平行版本執行期間開啟工作管理員,看 CPU 的使用率。如果始終只有一個核心在忙,耗時又與循序版本相近,便是沒有真的平行。

工作切得太細

單次計算如果很短,又一件一件分派給 worker,主行程與 worker 之間往返所花的時間便超過計算本身,worker 大部分時間都在等下一件,總耗時不比循序版本短,甚至更長;好比助教每改完一份考卷便走回講台領下一份,時間都花在走路上。

以矩陣運算為例,解兩千個 200 × 200 的線性方程組,每個只需約 0.3 毫秒。在 R 裡以 future 系列套件分給八個 worker,耗時如下:

做法耗時
循序0.59 秒
八個 worker,一次送一件25.27 秒
八個 worker,每個一批 250 個0.19 秒

一次送一件時,主行程來回兩千次,每次的打包、派送與回收約需 12 毫秒,是計算本身的四十倍,八個 worker 反而比循序慢了四十餘倍。分批則是在開始計算之前就分配好誰解哪幾個:兩千個方程組依序切成八批,第 1 至 250 個歸第一個 worker,第 251 至 500 個歸第二個,以此類推;每個 worker 一次收到自己的整批,全部解完才把 250 個結果一起交回,往返由兩千次減為八次,比循序快了三倍。這筆開銷的大小因工具而異,同樣的工作改用 R 內建的 parallel 套件,每件只多出約 0.04 毫秒。

這種情況在工作管理員裡看得出來。分批時每個 worker 所在的核心使用率都接近 100%;一次送一件時,各個核心都有動靜,卻都遠低於 100%,上例每個 worker 只用了約 3%。平行版本執行期間如果看到這樣的畫面,可以先考慮是工作切得太細;worker 都在等硬碟,或記憶體不足時,也會如此。

這類工作重複的次數極多、總耗時很長,仍值得平行,只是須分批。有的工具會自動分批,有的預設一次只送一件,因此交代 agent 時應指明:

單次計算很快時,把重複的工作分批交給 worker,不要一次只送一件。
說明你每批放了幾次重複,以及這個數字是怎麼決定的。

上例每個 worker 只分一批,是因為每個方程組的耗時相同。各件的耗時不平均時,先算完的 worker 便閒置著等其他 worker,此時把批數取為 worker 數的數倍,讓先算完的再領下一批,是常見的折衷。

核心被重複佔用

部分套件在矩陣運算或處理大型表格時已自行使用多個核心,例如 NumPy 內附的 OpenBLAS、R 的 data.table,以及換上多執行緒 BLAS 之後的 R。在其上再開數個 worker,每個 worker 又各自展開數個執行緒,結果是數十個執行緒搶奪幾個核心,比不平行更慢。

這種情況下擇一即可:讓底層繼續使用多個核心而不開 worker,或開 worker 並把每個 worker 之內的執行緒數限制為一。哪一種較快須實際計時,交代 agent 時指明兩種都試。

記憶體不足

每個 worker 通常各持有一份資料的複本,數 GB 的資料乘上八個 worker,記憶體會先於處理器耗盡。電腦開始使用硬碟充當記憶體之後,速度反而遠低於循序執行,嚴重時整台電腦停止回應。

資料很大時應減少 worker 數,或只把每次計算真正需要的部分傳過去;如果工作是逐一處理多個檔案,則讓每個 worker 自行讀取它負責的檔案,不由主行程讀完再分送。

亂數與結果的重現

模擬與重複抽樣都依賴亂數,而報告裡的數字須能重現,因此循序執行時習慣在開頭固定一次亂數種子。平行之後這樣做不再足夠,因為每個 worker 都是獨立的行程,各有自己的亂數產生器,出錯的方式有兩種。

一種是主行程設下的種子沒有傳到 worker,各 worker 每次啟動都自行取一個起點,同一份 code 每次執行的結果便都不同,無法重現。另一種是每個 worker 或每一次重複都設了同一個種子,例如把固定種子的那一行寫進了重複執行的函數裡,各次因此抽到同一串亂數,數千次重複實際上只是同樣的幾次計算被複製多遍,彙整出的結果看似正常,卻是錯的,而且不報錯。以種子加上 worker 編號充當各自的種子,同樣不可靠,NumPy 的文件便明白標示不應這樣做。

正確的做法是由一個種子衍生出多條彼此獨立的亂數流,每一次重複各用一條。亂數流綁定的是「第幾次重複」而非「哪一個 worker」,因此同一個種子之下,無論 worker 是兩個或八個、工作如何分批,結果都完全相同。各語言都有現成的機制,例如 R 的 future 系列套件與 Python 的 NumPy,agent 知道如何使用,使用者要做的是核對:

請確認以下事項並回報:
- 平行版本以同一個種子連續執行兩次,結果是否完全相同。
- 把 worker 數改成 2 再執行一次,結果是否仍然相同。
- 各次重複用到的亂數是否彼此不同。

有任何一項不符,就表示種子的設定有誤,應請 agent 修正後再用這份結果。

與循序版本的結果不逐位相同

平行版本與循序版本的數字未必相等,判斷改寫是否正確,不能只看兩者是否一模一樣。差距的來源有兩個,處理的方式不同。

第一個是亂數。兩個版本即使使用同一個種子,抽到的亂數也不同,因為循序版本由一條亂數流依序抽到底,平行版本則是每次重複各用一條,結果自然有出入。這與同一份循序 code 換一個種子重跑的差異屬於同一性質,因此比較的基準是換種子重跑時結果本來會波動多少,差距在這個範圍之內便算一致。

第二個是小數的捨入,計算沒有用到亂數時同樣會出現。十進位無法精確寫出 1/3,只能寫成 0.3333 並在某一位截斷,三個 0.3333 相加得 0.9999,而非 1。電腦以二進位、固定的位數儲存小數,這種存法稱為浮點數,情形相同,只是寫不盡的數字不一樣:0.1 在十進位只有一位,在二進位卻是循環小數,只能取最接近的值存下,每相加一次又捨入一次;相加的先後不同,捨入的位置便不同,最後的數字也就不同。以 0.1、0.2、0.3 三個數為例,Python 的結果如下:

>>> 0.1 + (0.2 + 0.3)
0.6
>>> (0.1 + 0.2) + 0.3
0.6000000000000001

循序版本由第一筆加到最後一筆。平行版本則常由各 worker 先加總自己負責的那一批,再把各批的小計相加,或依各 worker 完成的先後彙整,相加的順序因此與循序版本不同;worker 數一改,分批的方式也跟著改,結果又是另一個數字。這樣產生的差距只出現在最後幾位小數,不影響報告裡會寫出的位數,向量化前後的些微出入也是同一個原因。

處理的方式之一是比對時容許極小的誤差,不要求完全相等,各語言都有現成的函數,例如 R 的 all.equal() 與 NumPy 的 allclose();以等號直接比較兩個小數,則幾乎必然得到不相等。另一個方式是固定彙整的順序:各 worker 交回每一次重複各自的結果而非小計,主行程依重複的編號排序之後,再由頭到尾計算,相加的順序便與循序版本相同,結果逐位一致,也不隨 worker 數改變。交代 agent 時可以寫明:

彙整各 worker 的結果時,先依重複的編號排序,再依序計算,不要依完成的先後。
比對平行版本與循序版本時容許極小的誤差,並回報兩者最大的差距。

差距如果明顯超出上述範圍,才表示改寫時動到了計算本身,應請 agent 逐步比對兩個版本。報告定稿時以平行版本的數字為準,並在 code 裡保留種子,日後重跑才對得上。

同時寫入同一個檔案或變數

多個 worker 同時寫入同一個檔案時,各自寫入的內容會互相交錯或覆寫,檔案最後少了幾列或格式損壞,而每次執行壞的位置都不同。修改同一個變數則是另一種情形:worker 是獨立的行程,改到的只是自己那一份複本,主行程的變數並未改變,迴圈裡累加結果的寫法搬到平行版本之後,累加出來的仍是初始值,同樣不報錯。

穩妥的安排是每個 worker 只負責計算並把結果交回,由主行程統一彙整與寫檔;確實須由 worker 寫檔時,每個 worker 寫入各自的檔名。

中途失敗

循序執行時,迴圈在第幾次出錯一目了然,先前算完的也多半還在。平行之後,數千次之中只要有一次出錯,多數工具會中止整批工作,已算完的結果一起丟失,錯誤訊息也常只剩 worker 傳回的片段,看不出是哪一次、哪一筆資料造成的。

因此,總耗時以小時計的工作應分批執行,每一批算完就存檔,重新執行時略過已有結果的批次;正式執行之前,先以少量的重複試跑一次,確認流程走得完。

交給 agent 的效率規矩

Agent 沿用專案裡既有的逐筆迴圈時並不會提醒使用者這一段可以向量化,寫出循序的計算時也不會提醒其餘核心閒置,除非規範檔要求它這麼做。規範檔是放在專案資料夾內、agent 每個對話開始時都會讀取的一份檔案,Claude 的是 CLAUDE.md,Codex 的是 AGENTS.md,詳見「規範檔的作用與位置」一節;將以下內容寫入,或直接請 agent 代為寫入,專案裡還沒有這份檔案時它會一併建立:

## 計算效率
- 改寫 code 以求加快之前,先以 profiler(R 的 `Rprof()`、Python 的 py-spy)或分段計時找出最耗時的部分,只改這一部分;改寫前後各計時一次,並比對結果是否相同。
- 對整批資料做同樣的運算時,用向量化的寫法(R 的向量與矩陣運算、Python 的 NumPy 與 pandas),不逐筆跑迴圈。
- 不在迴圈裡逐次加長向量、陣列或資料表:知道最終大小或上限時先建立完整長度再填入,不知道時先收集、最後合併一次。
- 遇到彼此獨立的重複計算(對多個檔案或多組參數做同樣的處理、重複多次的模擬),且單次耗時明顯時,先提議平行化並說明預期效益與成本,等我同意後再改寫;不值得平行化時也請說明理由。
- 平行化之後回報兩個版本的耗時與結果的差距,並確認平行版本執行時各個核心確實都在計算。
- 計算用到亂數時,設定可重現的亂數種子:同一個種子重跑的結果須完全相同,且不隨 worker 數改變。
- worker 數不超過實體核心數減一。單次計算很快的工作分批交給 worker,不一次只送一件。每個 worker 只回傳結果,由主行程依編號的順序彙整與寫檔;總耗時以小時計的工作分批存檔。