บันไดความเร็วของ Backtest Engine: 298x บน CPU แล็ปท็อป, PnL เหมือนเดิมทุกประการจนถึง Trade สุดท้าย
ส่วนหนึ่งของซีรีส์ "Backtests Without Illusions"
📄 บทความนี้ขยายกลายเป็นเปเปอร์วิจัย backtest kernel แบบ path-dependent หนึ่งตัวถูก implement ห้าแบบ — ตั้งแต่ pandas แบบไร้เดียงสาไปจนถึง parallel numba kernel — แต่ละขั้นตรวจสอบไขว้กันแล้วว่าให้ PnL ต่อ combo เหมือนกันทุกประการ สิ่งเดียวที่ต่างกันคือความเร็ว อ่านเปเปอร์ฉบับเต็มออนไลน์ (เวอร์ชันอินเทอร์แอกทีฟ + PDF) ได้ที่ speed-ladder.marketmaker.cc โค้ดและข้อมูลอยู่ที่ github.com/suenot/backtest-speed-ladder
70 วินาที นั่นคือเวลาที่ reference implementation แบบไร้เดียงสาใช้ในการ sweep พารามิเตอร์ 80 combination ของกลยุทธ์ moving-average หนึ่งตัวบนข้อมูล 150,000 บาร์: ใช้ pandas rolling().apply() สำหรับ indicator และ Python loop ธรรมดาสำหรับการเทรด นี่คือโปรไฟล์ที่โค้ดวิจัยในโลกจริงจำนวนมากทำงานอยู่ เพราะมันคือโปรไฟล์ที่ได้มาโดยธรรมชาติเมื่อเขียนกลยุทธ์ด้วยวิธีที่ตรงไปตรงมาที่สุด
sweep เดียวกัน บนแล็ปท็อปเครื่องเดียวกัน ให้ PnL เดียวกันทุก combination จนถึง trade สุดท้าย: 0.23 วินาที
ช่องว่างระหว่างตัวเลขสองตัวนี้ — ที่วัดได้จริง 298x — คือหัวข้อของบทความนี้ ไม่มีแม้แต่เปอร์เซ็นต์เดียวที่มาจากฮาร์ดแวร์ใหม่ ไม่มีการใช้ GPU เลย (เครื่องนี้ไม่มี GPU ในความหมายของ CUDA ด้วยซ้ำ) ทุกขั้นของบันไดคือกลยุทธ์เดียวกัน ข้อมูลเดียวกัน ค่าธรรมเนียมเดียวกัน จำนวน trade เดียวกัน ตรวจสอบด้วย equivalence gate ที่จะทำให้ benchmark ทั้งหมดล้มเหลวหากผลลัพธ์ per-combo ของ implementation ใดเบี่ยงเบนไป สิ่งที่เปลี่ยนมีเพียง วิธีที่งานถูกแสดงออกมา: อะไรรันใน interpreter อะไรรันแบบ compiled และอะไรรันแบบ parallel และเพราะ baseline ที่ตั้งใจทำให้ช้าสามารถทำให้ตัวเลขพาดหัวใดๆ ดูดีเกินจริงได้ ขอเสริมอีกตัวเลขหนึ่งไว้ล่วงหน้า: แม้เทียบกับ numpy implementation แบบ vectorized ที่มีความสามารถจริง — โค้ดที่โปรแกรมเมอร์ numpy ฝีมือดีจะเขียนออกมา — เอนจินฉบับสมบูรณ์ก็ยังเร็วกว่าประมาณ 13x
เมื่อ parameter search ช้า ปฏิกิริยาสะท้อนกลับคือมองหาฮาร์ดแวร์ที่ใหญ่กว่า — GPU, คลัสเตอร์, งบประมาณคลาวด์ แต่ความจริงที่วัดได้จากการทดลองนี้ชี้ไปยังจุดที่ดูธรรมดากว่านั้นมาก: คอขวดคือ เอนจิน (inner loop แบบ interpreted ที่เรียก Python ทีละ window) และ การจัดออเคสตรา (การรัน combo ที่เป็นอิสระต่อกันแบบ serial บน core เดียว) ทั้งสองอย่างแก้ได้ภายในบ่ายเดียว บนเครื่องที่คุณมีอยู่แล้ว โดยผลลัพธ์ไม่เปลี่ยนแปลงเลยแม้แต่น้อย
นี่คือบันไดทั้งหมดที่แสดงไว้ล่วงหน้า ส่วนที่เหลือด้านล่างคือกายวิภาคของแต่ละขั้น
| ขั้น | Implementation | เวลาจริง | Speedup | Combos/s |
|---|---|---|---|---|
| M0 | pandas: rolling.apply + Python loop ทีละบาร์ |
69.92 s | 1.0x | 1.1 |
| M1 | numpy: WMA แบบ sliding-window + เทรดแบบ vectorized | 3.07 s | 22.7x | 26.0 |
| M2 | numba: @njit WMA + @njit event loop |
1.98 s | 35.3x | 40.4 |
| M3 | numba prange: thread ข้าม combo |
0.32 s | 217.6x | 248.9 |
| M4 | process pool + numba: process ข้าม combo | 0.23 s | 297.9x | 340.9 |
Apple M2 Max (12 core), Python 3.14.6, numpy 2.4.3, numba 0.64.0, BLAS (Accelerate) ถูกตรึงไว้ที่ 1 thread เพื่อให้ขั้นที่เป็น single-threaded เป็น single-core จริงๆ 150,000 บาร์ × 80 combo, wall time แบบ best-of-3, ไม่นับเวลา JIT warm-up ทุกขั้น — รวมถึง pandas baseline — วัดเวลาเต็มรูปแบบและตรวจสอบแล้วว่าให้ PnL ต่อ combo และจำนวน trade เหมือนกันทุกประการในทั้ง 80 combo
Kernel เดียว ห้า Implementation

เพื่อให้การเปรียบเทียบความเร็วมีความหมาย สิ่งที่กำลังถูกคำนวณ ต้องถูกกำหนดไว้อย่างชัดเจน และทุก implementation ต้องได้รับการพิสูจน์ว่าคำนวณสิ่งนั้นจริง ดังนั้นการทดลองนี้จึงตรึง strategy kernel หนึ่งตัวไว้และรักษามันให้คงที่ตลอดทั้งห้าขั้น
Kernel นี้คือ HMA/HMA3 cross — ระบบ stop-and-reverse บน Hull-style moving average สองตัว building block คือ weighted moving average:
Hull Moving Average ประกอบขึ้นจากสามตัวเพื่อลด lag:
และ HMA3 เป็นพี่น้องที่นุ่มนวลกว่า สร้างจาก WMA ที่ความยาวประมาณ , และ แล้ว smooth อีกครั้งหนึ่ง ต่อพารามิเตอร์หนึ่ง combination นั่นคือ WMA pass ทั้งหมด 7 ครั้งบน window length ที่แตกต่างกัน 6 ค่า — indicator stack ของจริง ไม่ใช่ของเล่น
กฎการเทรดถูกออกแบบให้มี state โดยตั้งใจและใช้ประโยชน์จากมัน: ทิศทางเป็น long เมื่อ HMA ต่ำกว่า HMA3 และ short ในกรณีอื่น เปิด position ที่ทิศทางที่ถูกกำหนดครั้งแรก ทุกครั้งที่เกิด cross ให้ปิด position บันทึก PnL ลบด้วยค่าธรรมเนียม round-trip 0.09% แล้วกลับทิศทาง position นี้ สืบทอดข้ามบาร์ — สิ่งที่คุณทำที่บาร์ ขึ้นอยู่กับ state ที่สะสมมาตั้งแต่ cross ครั้งล่าสุด path dependence นี้คือแก่นของการทดลองทั้งหมด: มันคือคุณสมบัติที่ทำให้ backtest แตกต่างจาก dataframe pipeline ทั่วไป และ (ตามที่เราจะวัดกัน) มันทำให้คำถามเรื่อง GPU ซับซ้อนขึ้น — แม้จะไม่ใช่ในแบบที่ตำนานเล่าขานกันก็ตาม
ส่วนที่เหลือของการตั้งค่า เพื่อให้คุณตัดสินตัวเลขได้เอง:
- ข้อมูล: 150,000 บาร์ของ synthetic geometric Brownian motion, seed คงที่ (
seed=42) ประสิทธิภาพในที่นี้ถูกจำกัดด้วยขนาด array และความยาว window ไม่ใช่ด้วยว่าคุณป้อน price path แบบไหนเข้าไป — และ series แบบ synthetic ทำให้การทดลองทั้งหมด deterministic และทำซ้ำได้โดยใครก็ตาม - Grid: ความยาว HMA ที่แตกต่างกัน 80 ค่า กระจายอยู่ใน — ดังนั้น sweep จึงมีทั้ง combo แบบ window สั้นที่คำนวณถูก และ combo แบบ window ยาวที่คำนวณแพง เหมือน grid จริงๆ
- การจับเวลา: wall-clock, best-of-3 ต่อขั้น โดย warm JIT compilation ไว้นอกตัวจับเวลา และ warm worker ของ pool ก่อนเริ่มจับเวลา ทุกขั้น — รวมถึง pandas baseline — จับเวลาแบบเต็มรูปแบบครบทั้ง 80 combo BLAS (Accelerate ของ Apple) ถูกตรึงไว้ที่ thread เดียว ดังนั้นขั้นที่เป็น single-threaded จึงเป็น single-core จริงๆ: ขั้น numpy ไม่ได้แอบทำ matvec แบบ multithread อยู่เบื้องหลังการเปรียบเทียบ
- Equivalence gate: หลังจับเวลาแล้ว vector ของ (PnL, จำนวน trade) ต่อ combo ของทุกขั้นจะถูกเทียบกับ reference — จำนวน trade ต้องตรงกัน เป๊ะ PnL ต้องอยู่ในช่วง absolute tolerance percentage point การรันที่ commit ไว้รายงาน
all_ok: trueสำหรับทุกขั้น รวมถึง pandas baseline ครบทั้ง 80 combo หาก gate นี้ล้มเหลว ก็ไม่มี benchmark เหลืออยู่ — มีแค่โปรแกรมห้าตัวที่คำนวณสิ่งที่ต่างกันห้าอย่างด้วยความเร็วที่ต่างกันห้าระดับ ซึ่งเป็นวิธีที่คำกล่าวอ้าง "เอนจินของเราเร็วกว่า 100x" จำนวนมากแอบทำงานอยู่จริงๆ
ตัวเลขหนึ่งตัวจาก equivalence block สมควรได้รับความซื่อสัตย์สักครู่: fingerprint ของ combo แรกคือ PnL −5165.58 percentage point บน 57,029 trade นี่ไม่ใช่ผลลัพธ์กลยุทธ์ที่ควรอาย — มันคือความยาว HMA ที่สั้นที่สุด (6) ที่ flip แทบทุกการกระเพื่อมของ random walk แล้วจ่าย 0.09% ทุกครั้ง ตรงตามที่ควรจะเป็น มันคือ correctness fingerprint ไม่ใช่ backtest ที่เทรดได้จริง อย่าอ่าน alpha เข้าไปในนั้น จงอ่าน determinism เข้าไปแทน — ห้า implementation ลงเอยที่ trade 57,029 ครั้งเดียวกันและ PnL เดียวกันถึงทศนิยม 6 ตำแหน่ง คือความหมายของคำว่า "เหมือนกันทุกประการ" ในที่นี้
เมื่อตั้งหลักตรงนี้แล้ว speedup ทุกตัวด้านล่างนี้คือความเร็วล้วนๆ ไม่มีอะไรถูกประมาณทิ้งไปเลย
ขั้น M0: โปรไฟล์ pandas แบบไร้เดียงสา — 69.9 s

Baseline นี้ไม่ใช่ strawman มันคือโค้ดที่คุณจะได้เมื่อเขียน WMA ตามที่เอกสาร pandas แนะนำ และเขียน event loop ตามที่คำอธิบายกลยุทธ์บอกไว้ตรงๆ:
def pd_wma(s: pd.Series, period: int) -> np.ndarray:
w = np.arange(1, period + 1, dtype=np.float64)
w /= w.sum()
return s.rolling(period).apply(lambda x: np.dot(x, w), raw=True).to_numpy()
def run_pandas_one(close, length):
h, h3 = pd_hma(close, length), pd_hma3(close, length) # 7 rolling.apply WMAs
total, ntr, prev_dir, entry, pos = 0.0, 0, 0, 0.0, 0
for i in range(len(close)): # Python bar loop
if np.isnan(h[i]) or np.isnan(h3[i]):
continue
d = 1 if h[i] < h3[i] else -1
if prev_dir == 0:
prev_dir, pos, entry = d, d, close[i]
continue
if d != prev_dir: # cross: close + reverse
pnl = ((close[i] - entry) if pos == 1
else (entry - close[i])) / entry * 100 - FEE
total += pnl
ntr += 1
pos, entry, prev_dir = d, close[i], d
return total, ntr
ทำไมสิ่งนี้ถึงช้า? ไม่ใช่เพราะ pandas "แย่" — แต่เพราะ ที่ที่ iteration อาศัยอยู่ rolling(period).apply(lambda ...) คือ loop ระดับ Python ที่สวมชุดของความเป็น vectorized ทุกหนึ่งใน 150,000 บาร์ pandas จะสร้าง window ขึ้นมา ข้ามขอบเขต C/Python เรียก Python callable และ box ผลลัพธ์ แม้จะใช้ raw=True (ซึ่งอย่างน้อยก็ส่ง ndarray เปล่าให้ lambda แทนที่จะเป็น Series) overhead ของ interpreter ต่อการเรียกก็ยังบดบัง FLOP หลักสิบถึงหลักร้อยที่ window ต้องการจริงๆ ไปหมด คูณด้วย WMA pass 7 ครั้งต่อ combo แล้ว indicator stack เพียงอย่างเดียวก็เป็นการเดินทางไปกลับของ interpreter หลายล้านครั้ง จากนั้น bar loop ก็รันอีก 150,000 iteration แบบ interpreted ต่อ combo แต่ละครั้งทำ bounds-checked indexing บน numpy scalar, box float และ dispatch แบบ dynamic บน type ที่ interpreter ต้องค้นพบใหม่ทุกครั้ง
ผลลัพธ์: 69.92 s สำหรับ sweep ประมาณ 0.87 s ต่อ combo throughput 1.1 combo ต่อวินาที บน grid 80 combo คุณก็แค่ยักไหล่แล้วรอสักนาที ปัญหาคือไม่มีใครรัน grid 80 combo กันนาน — และต้นทุนนี้ scale แบบ linear ไปตลอดกาล เราจะกลับมาที่ประเด็นนี้อีกครั้ง
ขั้น M1: numpy — เลิกเรียก Python ใน loop — 3.07 s, 22.7x
ขั้นแรกที่ไต่ขึ้นไปกำจัด interpreter loop ทั้งสองพร้อมกัน และคุ้มค่าที่จะแยกเทคนิคทั้งสองออกจากกันเพราะมันมีความทั่วไป (generality) ที่ต่างกันมาก
ฝั่ง indicator คือฝั่งที่ง่ายและ general เต็มที่ weighted moving average บนทุก window ก็แค่ matrix–vector product กับ strided view ของ input — ไม่มีการ copy เรียก BLAS ครั้งเดียว:
def vec_wma(x: np.ndarray, period: int) -> np.ndarray:
w = np.arange(1, period + 1, dtype=np.float64)
win = np.lib.stride_tricks.sliding_window_view(x, period) # zero-copy view
out = np.full(len(x), np.nan)
out[period - 1:] = win @ w / w.sum() # one matvec
return out
sliding_window_view สร้าง view ขนาด (n − p + 1, p) ของ memory ก้อนเดียวกัน และ win @ w คำนวณ dot product ของทุก window ด้วยโค้ดที่ compiled แล้ว การเรียก lambda นับล้านครั้งกลายเป็นการเรียก library เพียงครั้งเดียว
ฝั่ง trade คือฝั่งที่น่าสนใจ เพราะ event loop มี state — แต่กระนั้น สำหรับ kernel นี้ มัน vectorize ได้ insight คือ position ที่บาร์ใดๆ ขึ้นอยู่กับเครื่องหมายของ HMA − HMA3 เท่านั้น ไม่ขึ้นกับผลลัพธ์การเทรดใดๆ state ไม่ป้อนกลับเข้าไปใน decision เลย ดังนั้น loop ทั้งหมดจึงยุบตัวลงเหลือแค่ "หาจุดที่ sign พลิก แล้วดึงราคาที่ index เหล่านั้น":
d = np.where(h[idx] < h3[idx], 1, -1) # direction per valid bar
flips = np.flatnonzero(np.diff(d) != 0) + 1 # bars where it crosses
cross = idx[np.concatenate(([0], flips))] # entry/exit indices
side = d[np.concatenate(([0], flips))]
entries, exits, s = close[cross[:-1]], close[cross[1:]], side[:-1]
pnl = np.where(s == 1, (exits - entries) / entries,
(entries - exits) / entries) * 100 - FEE
return float(pnl.sum()), int(pnl.size)
3.07 s speedup 22.7x, 26.0 combo ต่อวินาที — บน core เดียว โดย BLAS ถูกตรึงไว้ที่ thread เดียว ขั้นนี้สมควรได้รับป้ายกำกับ: มันคือ competent baseline implementation ที่โปรแกรมเมอร์ numpy ฝีมือดีจะเขียนออกมา และเป็นไม้บรรทัดที่เป็นธรรมสำหรับทุกอย่างที่อยู่เหนือมัน แต่มีข้อควรระวังที่ซื่อสัตย์สองข้อติดมากับขั้นนี้
ข้อแรก vectorization นี้คือ การเขียนใหม่เชิงวิเคราะห์ที่เจาะจงกับกลยุทธ์ ไม่ใช่การแปลงแบบกลไก มันมีอยู่ได้เพราะ kernel เป็น stop-and-reverse ที่ไม่มี stop ไม่มี trailing exit ไม่มี position sizing ที่ขึ้นกับ running PnL เพิ่ม stop-loss เข้าไป — feature ธรรมดาที่สุดเท่าที่จินตนาการได้ — exit ที่บาร์ จะเปลี่ยนว่า entry ใดมีอยู่ที่บาร์ state จะป้อนกลับเข้าไปใน path และ closed form ก็ระเหยหายไป kernel ที่ใช้งานจริงส่วนใหญ่อยู่ฝั่งผิดของเส้นแบ่งนี้
ข้อสอง ขั้นนี้คือขั้นที่ความถูกต้องมักตายลง การทำ bookkeeping ของ flip-index (+1 ตรงนี้, [:-1] ตรงนั้น, การ seed ทิศทางแรก) เป็นโค้ดประเภทที่ก่อให้เกิด off-by-one execution bug พอดี — bug สายพันธุ์เดียวกับที่ look-ahead taxonomy ของเราแสดงให้เห็นว่าสามารถผลิต Sharpe 15 ขึ้นมาจาก noise ได้ equivalence gate ไม่ใช่พิธีการสำหรับขั้นนี้ มันคือเหตุผลเดียวที่จะเชื่อมันได้ การเขียนใหม่แบบ vectorized ที่ฉลาดโดยไม่มี equivalence check เทียบกับ reference implementation แบบโง่ๆ คือวิธีที่เอนจินค่อยๆ เบี่ยงเบนออกจากกลยุทธ์ที่มันอ้างว่ากำลังทดสอบอยู่
ขั้น M2: numba — compile loop ที่คุณอยากเขียนจริงๆ — 1.98 s, 35.3x

ขั้น M2 ใช้ปรัชญาตรงข้าม: แทนที่จะบิดอัลกอริทึมให้เข้ากับ vectorized primitive ก็เขียน loop แบบไร้เดียงสาไปเลย — แล้ว compile มัน Numba (Lam, Pitrou & Seibert, 2015) JIT-compile Python subset เชิงตัวเลขผ่าน LLVM ให้กลายเป็น machine code:
@njit(cache=True)
def nb_wma(x, period):
n = x.shape[0]
out = np.full(n, np.nan)
wsum = period * (period + 1) / 2.0
for i in range(period - 1, n): # the "slow" loop, now machine code
s = 0.0
for j in range(period):
s += x[i - period + 1 + j] * (j + 1)
out[i] = s / wsum
return out
@njit(cache=True)
def nb_sweep(close, half, full, sq, p3, p2, pi, fee):
h = nb_wma(2.0 * nb_wma(close, half) - nb_wma(close, full), sq)
a = 3.0 * nb_wma(close, p3) - nb_wma(close, p2) - nb_wma(close, pi)
h3 = nb_wma(a, pi)
Event loop ภายใน nb_sweep คือ loop ของ M0 แบบคำต่อคำ branch, continue, state ที่เก็บใน local — ทั้งหมดนั้น ภายใต้ @njit local เหล่านั้นอาศัยอยู่ใน register, branch คือ jump instruction จริงๆ และต้นทุนต่อ iteration ลดจากระดับ microsecond ของ interpreter dispatch ลงเหลือระดับ nanosecond
1.98 s — 35.3x เมื่อเทียบกับ pandas แต่แค่ประมาณ 1.6x เมื่อเทียบกับ numpy (คำนวณจาก: 3.07/1.98) ก้าวที่ดูพอประมาณนี้ให้บทเรียนในตัวมันเอง: inner loop ของ numpy นั้น compiled อยู่แล้ว ดังนั้นชัยชนะของ numba บนคณิตศาสตร์ของ feature จึงถูกจำกัดแค่การข้าม window materialization และ intermediate array ส่วนที่เปลี่ยนแปลงจริงอยู่ที่อื่น:
- Event loop ฟรีแล้วตอนนี้ — และ "ฟรี" คือสิ่งที่วัดได้ ไม่ใช่แค่คำพูด M1 ใช้ความฉลาดทั้งหมดไปกับการทำให้ trade logic vectorize ได้ M2 ทำให้ความฉลาดนั้นไม่จำเป็นอีกต่อไป — loop แบบไร้เดียงสา ตรวจสอบได้ง่าย แก้ไขได้ง่าย รันด้วยความเร็วระดับ machine การจับเวลา feature stage แยกจาก trade loop ภายใน kernel ที่ compiled แล้วนี้ ทำให้เห็นว่า 99.3% ของเวลาอยู่ที่คณิตศาสตร์ของ WMA feature และแค่ 0.7% อยู่ที่ event loop ที่มี state คุณสามารถเพิ่ม stop-loss พรุ่งนี้ได้โดยไม่ต้องทำ research project — และจำการแบ่งนี้ไว้ให้ดี มันจะตัดสิน argument เรื่อง GPU ใหม่ด้านล่าง
- มันปลดล็อกสองขั้นถัดไป Kernel ที่ compiled แล้ว ปล่อย GIL ได้ และ allocation น้อย คือหน่วยงานที่ orchestration แบบ parallel ต้องการ คุณ parallelize M0 ไม่ได้อย่างมีประสิทธิผล — 12 สำเนาของสิ่งที่ช้าก็ยังช้าอยู่ดี แค่อุ่นขึ้นเท่านั้น
หมายเหตุเชิงระเบียบวิธีข้อหนึ่ง: numba compile ตอนถูกเรียกครั้งแรก และการ compile นั้น (หลายร้อย millisecond) ต้องไม่อยู่ในตัวจับเวลา — harness จะ warm JIT บน slice ขนาด 500 บาร์ก่อนวัดผล และ cache=True จะเก็บ kernel ที่ compiled ไว้ข้ามการรัน process benchmark ที่ "ลืม" รายละเอียดนี้ไปจะได้ตัวเลข numba ที่ไม่ยุติธรรม (นับ cold compile รวมไปด้วย) หรือไม่สามารถทำซ้ำได้
ขั้น M3: prange — parallelism ที่คุณมีอยู่แล้ว — 0.32 s, 217.6x

นี่คือข้อสังเกตที่ทำให้ mass parameter search พิเศษ: 80 combo นั้น เป็นอิสระต่อกันอย่างสมบูรณ์ ไม่มี shared state ไม่มีลำดับ ไม่มีการสื่อสาร นี่คืองานที่ขนานได้แบบ embarrassingly parallel ซึ่งขั้น M0–M2 กลับรันบน core เดียวจากทั้งหมด 12 core เพียงเพราะความเคยชิน
Numba ทำให้การแก้ไขนี้เกือบจะเป็นแค่เรื่อง syntax — สลับ range ของ combo loop เป็น prange:
@njit(parallel=True, cache=True)
def nb_sweep_all(close, params, fee):
N = params.shape[0]
totals = np.empty(N, dtype=np.float64)
ntrs = np.empty(N, dtype=np.int64)
for k in prange(N): # threads across combos
t, ntr = nb_sweep(close, params[k, 0], params[k, 1], params[k, 2],
params[k, 3], params[k, 4], params[k, 5], fee)
totals[k] = t
ntrs[k] = ntr
return totals, ntrs
เพราะ nb_sweep ถูก compile แบบ nopython มันจึงไม่ถือ GIL และ threading layer ของ numba กระจาย iteration ไปทั่วทั้ง 12 core array close ที่อ่านอย่างเดียวถูกแชร์โดยทุก thread โดยไม่มีต้นทุนเพิ่ม
0.32 s — 217.6x เมื่อเทียบกับ pandas, 248.9 combo ต่อวินาที ก้าวที่เพิ่มจาก M2 แบบ single-threaded อยู่ที่ประมาณ 6.2x บน 12 core (คำนวณจาก: 1.98/0.32) และช่องว่างจาก "12x ในอุดมคติ" ก็คุ้มค่าที่จะพูดตรงๆ แทนที่จะซ่อนไว้: 12 core ของ M2 Max คือ 8 performance core + 4 efficiency core ดังนั้นเพดานตามทฤษฎีจึงไม่เคยเป็น 12x ตั้งแต่แรก; 80 combo มีต้นทุนที่ไม่เท่ากันอย่างมาก (HMA ความยาว 6 ถูกกว่า HMA ความยาว 200 มาก) ดังนั้น thread จึงจบไม่พร้อมกัน; และแต่ละ kernel call จะ allocate intermediate array ของมันจาก allocator ที่ใช้ร่วมกัน speedup แบบ parallel บนเครื่องจริงมีหน้าตาแบบนี้แหละ ใครก็ตามที่อ้าง Nx-on-N-core ที่สะอาดสำหรับงานที่ heterogeneous กำลังวัดอะไรบางอย่างที่ synthetic อยู่
ขั้น M4: process pool สำหรับหนึ่งในสามสุดท้าย — 0.23 s, 297.9x
ขั้นสุดท้ายแทนที่ thread ด้วย process — kernel ที่ compiled แล้วตัวเดิม จัดออเคสตราด้วย ProcessPoolExecutor:
with ProcessPoolExecutor(max_workers=12, initializer=_init_worker,
initargs=(close,)) as ex: # ship data ONCE
list(ex.map(_warmup_worker, range(12 * 3))) # JIT-warm every worker
results = list(ex.map(_run_one_combo, grid, chunksize=1))
0.23 s — 297.9x เมื่อเทียบกับ pandas, 340.9 combo ต่อวินาที ลองอ่าน throughput นั้นอีกครั้ง: แล็ปท็อปเครื่องนี้กำลังรัน backtest เต็มรูปแบบขนาด 150,000 บาร์ประมาณ 340 ครั้งต่อวินาที แต่ละครั้งคำนวณ weighted moving average เจ็ดตัวและจำลอง trade ที่มี state นับหมื่นครั้ง
ความได้เปรียบเหนือ prange มีอยู่จริงแต่พอประมาณ — ประมาณ 1.4x (คำนวณจาก: 0.32/0.23) — และกลไกที่เป็นไปได้คือการจัดตารางงานและการแยก memory: ด้วย chunksize=1 pool จะแจก combo ทีละหนึ่ง ดังนั้นส่วนผสมที่ไม่เท่ากันของ window ถูกและแพงจึงถูก load-balance แบบ dynamic ข้าม core ที่ไม่สมมาตร และแต่ละ worker process ได้ allocator ของตัวเอง หลีกเลี่ยงการแย่งชิงกันบน temporary ต่อ combo เรารายงานสิ่งเหล่านี้เป็นกลไกที่สอดคล้องกับผลการวัด ไม่ใช่ข้อเท็จจริงที่พิสูจน์แยกต่างหาก
Process ไม่ได้มาฟรี และ harness จ่ายต้นทุนของมันอย่างซื่อสัตย์นอกตัวจับเวลา ในจุดที่มันเป็นต้นทุนแบบครั้งเดียว (worker startup, การส่ง close ไปยังทุก worker ผ่าน initializer, JIT warm-up ต่อ worker) — เพราะในการค้นหาจริง ต้นทุนเหล่านี้จะถูก amortize ไปกับ combo หลายพันตัว ไม่ใช่แปดสิบตัว คำแนะนำทั่วไปที่ซื่อสัตย์: prange ง่ายกว่าและมักจะเพียงพอ; process pool ชนะเมื่องานเป็นก้อนใหญ่ grid ใหญ่มาก หรืองานต่อ combo ของคุณถือ GIL อยู่ในจุดที่ numba เข้าไม่ถึง
และด้วยเหตุนี้ บันไดทั้งหมดจึงแยกตัวประกอบออกมาเป็นสรุปที่ชัดเจน จาก M0 ไป M2 — เอนจิน: 35.3x บน core เดียว จากการย้าย iteration ออกจาก interpreter จาก M2 ไป M4 — การจัดออเคสตรา: อีก 8.4x (คำนวณจาก: 1.98/0.23) จากการใช้ core ที่มีอยู่แล้ว คูณกัน: 298x ไม่มีฮาร์ดแวร์ใหม่ ผลลัพธ์เหมือนเดิมทุกประการ และเมื่อวัดจาก M1 baseline ที่มีความสามารถแทนที่จะเป็น baseline แบบไร้เดียงสา เอนจินฉบับสมบูรณ์ก็ยังสูงกว่าประมาณ 13x (คำนวณจาก: 3.07/0.23) — บันไดนี้ไม่ใช่สิ่งประดิษฐ์ที่เกิดจากการเลือกจุดเริ่มต้นที่ช้า
ทำไมไม่ใช้ GPU — เวอร์ชันที่ซื่อสัตย์

"แค่ port ไปที่ GPU สิ" คือคำตอบที่พบบ่อยที่สุดต่อ parameter sweep ที่ช้า ดังนั้นการทดลองนี้จึงวัดตัวเลขสองตัวที่บทสนทนานั้นควรเริ่มต้นจาก — และไม่มีตัวไหนสนับสนุนคำตอบแบบขี้เกียจของทั้งสองฝั่งเลย
Roofline model (Williams, Waterman & Patterson, 2009) จัดประเภท kernel ตาม arithmetic intensity — FLOP ต่อ byte ที่เคลื่อนย้าย สำหรับ WMA feature stack ใน sweep นี้ นับ FLOP ต่อบาร์ต่อ window ความยาว เทียบกับการอ่าน 8 byte ต่อบาร์หนึ่งครั้ง sweep ทั้ง 80 combo คำนวณออกมาได้ประมาณ 6.2 GFLOP บนข้อมูล 576 MB ที่ stream ผ่าน:
(นั่นคือค่านับแบบ idealized บน WMA window ที่แตกต่างกันหกค่าต่อ combo; หากนับ pass ทั้งเจ็ดตามที่รันจริงจะได้ 11.07 FLOP/byte ข้อสรุปเหมือนกันทั้งสองแบบ)
ตัวเลขนั้นสำคัญเพราะสิ่งที่มัน ตัดออก: คำกล่าวอ้างยอดนิยมที่ว่าคณิตศาสตร์ของ backtest "memory-bound ดังนั้น GPU ช่วยไม่ได้" เป็นเท็จในที่นี้ ที่ประมาณ 10.8 FLOP/byte คณิตศาสตร์ของ feature นั้นออกจะเป็น compute-ish อย่างชัดเจน — เลยจุด ridge point ที่ฮาร์ดแวร์ทั่วไปหยุด bandwidth-limited ไปแล้ว GPU สามารถ batch 80 combo × WMA pass 7 ครั้งเข้าเป็น kernel ขนาดใหญ่ไม่กี่ตัวและเคี้ยวคณิตศาสตร์นั้นได้จริงๆ หาก feature stack เป็นปัญหาทั้งหมด กรณีของ GPU ก็จะน่าเชื่อถือ
ตัวเลขที่วัดได้ตัวที่สองฆ่าคำตอบขี้เกียจอีกฝั่ง — ฝั่งที่เราเองก็คงหยิบมาใช้เหมือนกัน การจับเวลา feature stage แยกจาก trade loop ภายใน kernel ที่ compiled แล้วให้การแบ่ง 99.3% features, 0.7% event loop argument ที่ชวนให้เชื่อ — "backtest มี event loop ที่มี state และ branchy และนั่นคือสิ่งที่ block GPU" — ผิดในเชิงปริมาณตรงนี้: CPU ใช้เวลาเกือบทั้งหมดไปกับส่วนที่ GPU สามารถ batch ได้พอดี เขียน 80 combo × WMA pass 7 ครั้งใหม่ให้เป็น batched convolution ขนาดใหญ่ แล้วคุณจะได้ tensor workload ที่สมเหตุสมผลอย่างสมบูรณ์ ดังนั้นคำถามที่ซื่อสัตย์ไม่ใช่ว่างานนี้ไปที่ GPU ได้หรือไม่ — ส่วนใหญ่ไปได้ คำถามคือการเดินทางนั้นคุ้มหรือไม่ และสำหรับ sweep นี้มันไม่คุ้ม ด้วยเหตุผลเฉพาะสองข้อ:
1. ความกว้างที่ใช้ประโยชน์ได้คือ 80 combo — และ GPU คือเครื่องจักรของความกว้าง แกนเดียวที่ซื่อสัตย์ของ parallelism ใน parameter sweep คือ grid เอง: ภายใน combo หนึ่ง path ขนาด 150,000 บาร์เป็น sequential GPU ต้องการ work item ที่เป็นอิสระนับหมื่นตัวเพื่อเติมเต็ม lane และซ่อน latency sweep นี้มีให้แปดสิบตัว CPU 12 core ก็ทำให้ความกว้างนั้นอิ่มตัวอยู่แล้ว — นั่นคือสิ่งที่ขั้น M3–M4 วัดได้พอดี สำหรับจำนวน combo ที่ความกว้างของ GPU จะเริ่ม engage ได้จริง บันได CPU ก็ส่งมอบ backtest เต็มรูปแบบหลายร้อยครั้งต่อวินาทีอยู่แล้ว
2. งานทั้งหมดใช้เวลา 0.23 วินาที ที่ความเร็ว M4 combo หนึ่งมีต้นทุนประมาณ 2.9 ms (คำนวณจาก: 0.23 s / 80) เทียบกับงบนั้น kernel-launch latency และ device synchronization point ไม่ใช่ rounding error ที่ amortize ได้ — มันคือสัดส่วนที่มีนัยสำคัญของงาน (บนเครื่อง Apple แบบ unified-memory นี้ การ transfer จาก host ไป device เป็นเรื่องเล็กน้อย; บนเครื่อง CUDA แบบ discrete-GPU มันจะเข้ามาสมทบในบิลด้วย) ชัยชนะแบบคลาสสิกของ GPU amortize overhead คงที่ไปกับ batch งานขนาดมหึมา sweep ที่ใช้เวลาต่ำกว่าหนึ่งวินาทีไม่มีทางผลิตสิ่งนั้นได้
แล้ว event loop ล่ะ? มันคือส่วนเดียวที่จะ ไม่ batch — serial, branchy, path-dependent, loop-carried dependency ยาว 150,000 บาร์ที่ไม่มีฮาร์ดแวร์ไหน parallelize ได้ภายใน combo เดียว พร้อมกับ branch ที่แตกต่างกันแบบที่ SIMT lane เกลียดพอดี GPU port จะทิ้งมันไว้บน CPU หรือรันหนึ่ง lane ต่อหนึ่ง combo แต่ที่ 0.7% ของ kernel มันคือ Amdahl term ที่เล็กเกินกว่าจะตัดสินอะไรได้ มันคือส่วนที่จะไม่ไป ไม่ใช่เหตุผลที่จะไม่ไป (จำไว้จากขั้น M1 ว่าสำหรับ kernel ที่ไม่มี feedback loop สามารถ vectorize เชิงวิเคราะห์ได้ด้วยซ้ำ — การเขียนใหม่ที่คุณจะเสียไปทันทีที่กลยุทธ์งอก stop ขึ้นมา)
หมายเหตุท้ายเรื่องแพลตฟอร์มหนึ่งข้อเพื่อความครบถ้วน: บนเครื่องนี้ (Apple Silicon) เส้นทาง GPU จะเป็น MLX หรือ PyTorch-MPS ไม่ใช่ CUDA — cupy และ ecosystem ของ CUDA ใช้ไม่ได้เลย — และไม่ว่าจะเลือกทางไหนก็ต้องเขียน hot path ใหม่เป็น tensor dialect เพียงเพื่อจะลองการทดลองนี้ นั่นคือต้นทุนจริงที่ตามการวิเคราะห์ข้างต้นแล้วไม่มีผลตอบแทนที่ระบุได้สำหรับรูปร่างของ sweep นี้ การอภิปรายเรื่อง GPU ในที่นี้เป็นเชิงวิเคราะห์ วางอยู่บน arithmetic intensity ที่วัดได้และการแบ่ง feature/loop ที่วัดได้ และเราขอระบุไว้ตรงนี้: ไม่มีการรัน CUDA เกิดขึ้นเพราะเป็นไปไม่ได้บนฮาร์ดแวร์ที่เปิดเผยไว้
ประโยคสรุปที่เราจะปกป้องในการ review: งานเกือบทั้งหมดนี้สามารถไปที่ GPU ได้ sweep นี้แคบเกินไปและสั้นเกินไปที่การเดินทางจะคุ้มค่า และอ่านมันในทั้งสองทิศทาง — มันไม่ใช่การตัดทิ้ง การปรับสูตรแบบ batched "big-matrix" — การเขียน sweep ใหม่ให้เป็น tensor operation ขนาดใหญ่ข้าม combo หลายพันตัวพร้อมกัน หรือ kernel ที่ไม่มี feedback จริงๆ ที่ batch ได้ตั้งแต่ต้นจนจบ — คือทิศทางที่จริงจังและมีอนาคตที่สมควรได้รับการศึกษาเฉพาะ ไม่ใช่การปัดตก ที่ 80 combo และ 0.23 วินาที มันแค่ยังไม่ได้ตั๋วเที่ยวนั้น หากงานของคุณมีความกว้างขนาดนั้น คณิตศาสตร์จะเปลี่ยนไป และคุณควรทำใหม่เอง ไม่ใช่อ้างเรา
คอขวดที่แท้จริงอยู่ที่ไหน: เอนจินและการจัดออเคสตรา

แปดสิบ combo คือ grid สาธิต การค้นหาพารามิเตอร์จริงคือจุดที่ปัจจัยเหล่านี้เลิกเป็นเรื่องวิชาการ เพราะ grid โตแบบ multiplicative: พารามิเตอร์สี่ตัว ตัวละสิบค่า คือ combo; เพิ่ม walk-forward validation ด้วย fold สิบกว่าตัว แล้วคุณก็มาถึง backtest เต็มรูปแบบก่อนที่จะได้สำรวจอะไรเลยด้วยซ้ำ นี่คือ curse of dimensionality และนี่คือเหตุผลที่ กลยุทธ์การค้นหา — Optuna, coordinate descent, Sobol — ได้รับความสนใจมากขนาดนั้น: การค้นหาที่ฉลาดกว่าเยี่ยมชมจุดน้อยกว่า
แต่บันไดนี้เปิดเผยอีกครึ่งหนึ่งของสมการที่ถูกพูดถึงน้อยกว่า: ต้นทุนต่อจุดที่เยี่ยมชม เมื่อ extrapolate throughput ที่วัดได้แบบ linear (combo เป็นอิสระต่อกัน ดังนั้นนี่คือเลขคณิต ไม่ใช่การสร้างโมเดล):
| ขนาด Grid | ที่ M0 (1.1 combo/s) | ที่ M4 (340.9 combo/s) |
|---|---|---|
| 10,000 combo | ~2.4 ชั่วโมง | ~30 วินาที |
| 100,000 combo | ~24 ชั่วโมง | ~5 นาที |
การทดลองเดียวกันที่เป็น batch job ข้ามคืนบนเอนจินแบบไร้เดียงสา กลับกลายเป็น interactive query บนเอนจินที่ปรับแต่งแล้ว ความแตกต่างนั้นทบต้นกันในแบบที่ตาราง wall-clock พูดน้อยเกินจริงไป: ที่ 5 นาทีต่อ sweep คุณ iterate ได้ — คุณ re-run ด้วย leak ที่แก้แล้ว คุณเพิ่ม fold คุณขยาย grid คุณทดสอบไอเดียที่นึกขึ้นได้ตอนกินข้าวเที่ยง ที่ 24 ชั่วโมงต่อ sweep คุณทำแบบนั้นไม่ได้ ความเร็วของเอนจินกำหนดจังหวะของ research loop และจังหวะของ research loop ก็คือผลิตภัณฑ์ที่แท้จริง
มีการอ่านบันไดทั้งหมดในเชิง Amdahl's law ด้วยเช่นกัน:
การเร่งความเร็ว stage เดียว ด้วยตัวคูณ ถูกจำกัดด้วยทุกอย่างที่คุณปล่อยให้ช้าอยู่ บันไดนี้เคารพลำดับนั้น: การเพิ่มขึ้น 35.3x ของเอนจินโจมตี term ที่ครองอำนาจอยู่ (interpreted iteration ทั้งใน feature stack และใน loop) การเพิ่มขึ้น 8.4x ของการจัดออเคสตราโจมตี term ที่ครองอำนาจหลังจากนั้น (11 core ที่ว่างอยู่เฉยๆ) การแบ่ง feature/loop ก็เป็นบทเรียนเดียวกันในรูปแบบย่อ — เราคงตั้งชื่อรูปร่างจริงของ argument เรื่อง GPU ไม่ได้เลยถ้าไม่ได้วัดว่าเวลาไปอยู่ที่ไหนจริงๆ Profile ก่อน แล้วค่อย optimize — ตามลำดับนั้น logic เดียวกันนี้ควบคุม data layer ที่อยู่เหนือเอนจินขึ้นไปด้วย: Polars vs pandas benchmark ของเราพบรูปแบบเดียวกันเป๊ะ (10–3500x บน grouped rolling pipeline) สำหรับครึ่งหนึ่งของ stack ที่ทำ load-and-transform และข้อสรุปแบบ hybrid เดียวกัน — columnar engine สำหรับ pipeline, compiled kernel สำหรับการจำลองที่ path-dependent
หมายเหตุความซื่อสัตย์สองข้อเพื่อปิด loop เรื่อง generality ข้อแรก การทดลองนี้ตั้งใจให้ self-contained และ synthetic — ข้อมูล seed คงที่, kernel เดียว, เครื่องเดียวที่เปิดเผยไว้ — ดังนั้นใครก็ตามจึงทำซ้ำปรากฏการณ์นี้ได้แบบ deterministic; ตัวเลข wall-clock จะต่างกันบนฮาร์ดแวร์ของคุณ แต่ equivalence และทิศทางของบันไดจะไม่ต่าง ข้อสอง ปรากฏการณ์นี้ไม่ใช่สิ่งประดิษฐ์ของ setup แบบ synthetic: benchmark ของเอนจิน HMA production ของเรา (bench_param_sweep.py รันบนข้อมูล exchange จริงด้วย fee model และ fill model แบบ production เต็มรูปแบบ) แสดงรูปร่างบันไดเดียวกัน โดยเส้นทาง numba อยู่สูงกว่า pandas profile แบบไร้เดียงสาประมาณ 100–200x การทดลองแบบ self-contained นี้มีอยู่เพื่อให้คุณไม่ต้องเชื่อตัวเลข production ของเราแบบไม่มีหลักฐาน
ข้อสรุป
- บันไดคือ 298x และมันแยกตัวประกอบได้: เอนจิน 35.3x × การจัดออเคสตรา 8.4x การย้าย iteration ออกจาก interpreter (pandas → numba) และการกระจาย combo ที่เป็นอิสระต่อกันข้าม core (หนึ่ง → สิบสอง) คูณกันเป็น speedup ที่ใกล้เคียงสามลำดับขนาดบนแล็ปท็อปที่ไม่เปลี่ยนแปลงเลย 69.92 s → 0.23 s; 1.1 → 340.9 combo/s และมันไม่ใช่สิ่งประดิษฐ์จาก baseline ที่ช้า: เมื่อเทียบกับ numpy implementation แบบ vectorized ที่มีความสามารถจริง เอนจินฉบับสมบูรณ์ก็ยังเร็วกว่าประมาณ ~13x
- เรียกร้อง equivalence ก่อนที่จะชื่นชมความเร็ว ทุกขั้นในที่นี้ให้ PnL และจำนวน trade ต่อ combo เหมือนกันทุกประการ ตรวจสอบอัตโนมัติครบทั้ง 80 combo (absolute tolerance บน PnL, ตรงเป๊ะบน trade) เอนจินที่เร็วซึ่งคำนวณบางอย่างที่ต่างไปเล็กน้อยไม่ใช่ของเร็ว — มันคือความผิดพลาดที่ throughput สูง และการเขียนใหม่แบบ vectorized คือจุดที่ความผิดพลาดมักแอบเข้ามา
@njitชนะ vectorization ที่ฉลาดสำหรับ logic ที่มี state ขั้น numpy ต้องการ closed form ที่เจาะจงกับกลยุทธ์ซึ่งตายทันทีที่คุณเพิ่ม stop-loss ขั้น numba compile loop แบบไร้เดียงสาที่ตรวจสอบได้ — ความเร็วระดับเดียวกัน ไม่มีความเปราะบาง และมันคือหน่วยที่ parallelize ได้- คำตอบเรื่อง GPU คือ "ไม่เหมาะกับ sweep นี้" — ด้วยเหตุผลที่คุณควรระบุได้ คณิตศาสตร์ของ feature เป็น compute-ish (10.78 FLOP/byte) และมันคือ 99.3% ของ kernel ที่ compiled แล้ว ดังนั้นทั้ง "backtest เป็น memory-bound" และ "stateful loop ครองอำนาจ" จึงไม่รอดจากการวัดผล เหตุผลที่ซื่อสัตย์คือความกว้างและงบประมาณ: parallelism ที่ใช้ประโยชน์ได้ 80 combo ที่ CPU 12 core ก็ทำให้อิ่มตัวอยู่แล้ว และงานรวม 0.23 วินาทีที่ launch กับ synchronization overhead จะกินไปหมด การปรับสูตรแบบ batched big-matrix ที่ความกว้างจริง ยังคงเป็นทิศทางที่มีอนาคต ไม่ใช่ทิศทางที่ถูกหักล้างแล้ว
- ความเร็วของเอนจินคือจังหวะของ research ที่ throughput ของเอนจินแบบไร้เดียงสา การค้นหา 100,000-backtest คือหนึ่งวัน; ที่ throughput บนสุดของบันไดคือห้านาที ก่อนจะซื้อฮาร์ดแวร์หรือเช่าคลัสเตอร์ ตรวจสอบก่อนว่าคอขวดของคุณเป็นเรื่อง silicon จริงหรือไม่ — ของเราคือ
lambdaภายในrolling.applyและ core ที่ว่างเฉยอยู่ 11 ตัว
การทดลองฉบับเต็ม — ทั้งห้า implementation, equivalence harness, การคำนวณ roofline, และทุกตัวเลขในบทความนี้ที่สร้างซ้ำได้จาก script เดียวที่ deterministic — อยู่ใน companion paper ที่ speed-ladder.marketmaker.cc โค้ดและข้อมูลอยู่ที่ github.com/suenot/backtest-speed-ladder
Sweep ที่เคยใช้เวลา 70 วินาที ตอนนี้ใช้เวลาแค่หนึ่งในสี่ของวินาทีเดียว trade เหมือนเดิม PnL เหมือนเดิม แล็ปท็อปเครื่องเดิม GPU ที่คุณกำลังจะขอซื้อรอได้ interpreter loop ที่คุณกำลังจะ ship ไม่ได้
ผู้เขียน
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.