← กลับไปยังบทความ
July 6, 2026
อ่าน 5 นาที

กับดักความแม่นยำของ GPU: เมื่อ Backtest แบบ fp32 บน Apple Metal คืนค่าขยะออกมาอย่างเงียบๆ

กับดักความแม่นยำของ GPU: เมื่อ Backtest แบบ fp32 บน Apple Metal คืนค่าขยะออกมาอย่างเงียบๆ
#algotrading
#backtest
#gpu
#apple-silicon
#จุดทศนิยม
#ความเสถียรเชิงตัวเลข
#mlx
Part 8 of 10 · Collection
High-Performance Backtest Engines

ส่วนหนึ่งของซีรีส์ "Backtests Without Illusions"

เรา port parameter-sweep backtest ของเราไปยัง GPU แล้วมันเร็วขึ้น 2,796 เท่า ตัวเลขที่มันคืนกลับมาดูสมเหตุสมผลดี แต่ในเวอร์ชันแรกที่รันได้จริง มันคือขยะล้วนๆ — คลาดเคลื่อนไปถึงสองร้อยเท่า — และไม่มีอะไร crash ไม่มีคำเตือน ไม่มีอะไรดูผิดปกติเลย

นี่คือกับดักที่ทำให้ GPU backtesting บน Apple Silicon อันตรายในแบบที่โค้ด CPU ไม่เป็น: Metal ไม่มี float64 ตัวเลขทุกตัวที่ backtest ของคุณแตะต้องบน GPU ของ Apple เป็น float 32-bit ไม่ว่าคุณจะขอแบบนั้นหรือไม่ก็ตาม และวิธีที่น่าดึงดูดที่สุดในการทำ moving average ให้เป็น vectorized — เทคนิค prefix-sum แบบ O(n) ที่ quant สายประสิทธิภาพทุกคนจะเอื้อมมือไปหยิบ — คือสูตรเดียวกันเป๊ะกับที่ fp32 ไม่สามารถรอดที่ระดับราคาจริงได้ มันไม่ error ออกมา มันรันเต็มความเร็วแล้วยื่น equity curve ที่ดูสมเหตุสมผลให้คุณ ซึ่งสร้างขึ้นจาก moving average ที่ผิดไปถึง 211 เท่า

ส่วนที่น่าสนใจคือทางแก้ เพราะมันไม่ใช่ "ใช้ bit เพิ่ม" (คุณทำไม่ได้) และไม่ใช่ "ระวังให้มากขึ้น" (โค้ดแบบ naive ก็ระวังอยู่แล้ว) ทางแก้คือคำนวณปริมาณทางคณิตศาสตร์เดียวกันด้วยผลรวมที่ต่างออกไป — windowed convolution โดยตรง — ที่ทำให้ค่ากลางทุกค่าเล็กพอที่ fp32 จะแทนค่าได้อย่างแม่นยำ WMA เดียวกัน ผลลัพธ์เดียวกันถึงเลขนัยสำคัญเจ็ดตัว และเร็วกว่า numba แบบ single-thread บน CPU ถึง 55.9 เท่า บทความนี้คือการชันสูตร: ทำไม Metal ถึงบังคับให้คุณใช้ fp32 ตรงไหนที่สูตรตรงไปตรงมา overflow ทำไมสูตรที่ถูกต้องถึงไม่ overflow และเราพิสูจน์ได้อย่างไร — ด้วยการนับ trade ไม่ใช่การมองตา curve — ว่าเวอร์ชันที่เร็วกับเวอร์ชัน fp64 ที่ซื่อสัตย์ให้ผลตรงกัน

ตัวเลขทั้งหมดในที่นี้วัดบน Apple M2 Max, 150,000 บาร์ × 80 parameter combination, best-of-3 จาก scripts/bench_param_sweep.py ของ repo (M5 GPU method, commit 97eadaf), scripts/engine_multitf_gpu.py (04d71e8) และ design note ใน scripts/GPU_NOTES.md

Metal ไม่มี float64

Metal GPU pipeline ของ Apple กับ float64 ที่ถูกขีดฆ่า — ทุก lane ถูกบังคับให้เป็น 32-bit จำนวนเต็มแม่นยำเฉพาะถึง 1.6×10⁷

บน CPU, backtest แบบ Python/numpy รันด้วย double precision เป็นค่า default float64 ให้ mantissa 52-bit: จำนวนเต็มถูกแทนค่าได้แม่นยำจนถึง 2⁵³ ≈ 9×10¹⁵ และความแม่นยำสัมพัทธ์อยู่ที่ราว 1.1×10⁻¹⁶ คุณแทบไม่ต้องคิดถึงเรื่องนี้เลย เพราะที่ระดับราคา — BTC close ราว 30,000, cumulative sum ที่ไปถึง 10¹⁴ — fp64 มี margin เหลือเฟือ

GPU ของ Apple ไม่มี margin แบบนั้นให้ เพราะมันไม่มี float64 เลย Metal Shading Language ไม่มี type double; MLX, PyTorch-MPS และทุก framework อื่นที่เจาะจง Apple Silicon ล้วนสืบทอดข้อจำกัดนี้มา ไม่มี flag ให้เปิดใช้งาน และไม่มี fallback แบบช้าแต่ถูกต้อง ถ้าการคำนวณของคุณแตะ GPU มันจะเกิดขึ้นใน fp32:

  • Mantissa: 23 bit (24 บิตถ้านับ implicit leading bit) จำนวนเต็มแม่นยำเฉพาะถึง 2²⁴ = 16,777,216 ≈ 1.6×10⁷
  • ความแม่นยำสัมพัทธ์: ~1.2×10⁻⁷ เลขนัยสำคัญราวเจ็ดตัว ไม่มากไปกว่านั้น

เพดาน 1.6×10⁷ นั้นคือเรื่องราวทั้งหมด มันฟังดูใจกว้าง — สิบหกล้าน — จนกว่าคุณจะสังเกตว่า backtest มักสร้าง intermediate quantity ที่ใหญ่กว่านั้นมากเป็นประจำ และในวินาทีที่ intermediate ข้าม 1.6×10⁷ ไป fp32 จะไม่สามารถแทนค่าจำนวนเต็มติดกันได้อีกต่อไป ไม่ต้องพูดถึงโครงสร้างเศษส่วนที่คุณต้องการ ความแม่นยำไม่ได้ค่อยๆ เสื่อมลง มันตกหน้าผา และหน้าผานั้นอยู่ที่ตัวเลขที่ข้อมูลของคุณแล่นผ่านไปโดยไม่คิดอะไรเลย

กับดักคือ fp32 นั้นเกือบจะโอเคเสมอ ส่วนใหญ่ของ backtest — ราคา, ผลตอบแทน, PnL, Sharpe — อยู่สบายๆ ในช่วงที่เลขนัยสำคัญเจ็ดตัวก็เหลือเฟือแล้ว ดังนั้นการ port แบบ naive จึงทำงานได้ ผ่าน smoke test ให้ output ที่ดูสมเหตุสมผล ความล้มเหลวเกิดขึ้นเฉพาะจุดในการดำเนินการเดียวเท่านั้น จุดเดียวที่ intermediate พุ่งเกิน 10⁷ ไป และนั่นคือการดำเนินการที่ทุกคน vectorize เป็นอันดับแรก

สูตรที่น่าดึงดูด: WMA ในการกวาด prefix-sum เดียว

กลยุทธ์ของเราอิงกับ Hull moving average HMA คือ weighted moving average สามตัวที่ประกอบกัน ส่วน HMA3 คือสี่ตัว weighted moving average ด้วย linear kernel เหนือ window ความยาว p คือ

WMAt=k=1pkxtp+kp(p+1)/2.\mathrm{WMA}_t = \frac{\sum_{k=1}^{p} k \cdot x_{t-p+k}}{p(p+1)/2}.

เมื่อกวาด parameter combination หลายพันชุดบน 150k บาร์ WMA convolution คือ ต้นทุนหลัก ดังนั้นสัญชาตญาณ — สัญชาตญาณที่ถูกต้องบน CPU — คือทำให้แต่ละ WMA เป็น O(n) แทนที่จะเป็น O(n·p) โดยใช้ prefix sum คุณคำนวณ cumulative sum สองตัวล่วงหน้าเพียงครั้งเดียว

S1[t]=jtxj,S2[t]=jtjxj,S_1[t] = \sum_{j \le t} x_j, \qquad S_2[t] = \sum_{j \le t} j \cdot x_j,

แล้ว weighted sum แบบ linear ของ window ใดๆ ก็ยุบลงเหลือแค่ผลต่างและ index shift ของ S1 กับ S2 เพียงไม่กี่ตัว ไม่มี loop ต่อ window ไม่มี reduction ต่อ window — แค่ cumsum สองรอบ แล้ว matrix ของ WMA ทั้งหมดก็หลุดออกมาจาก array arithmetic มัน vectorize ได้สวยงาม มันแมปเข้ากับ parallel-scan primitive ของ GPU ได้พอดี และบน fp64 มันถูกต้องเป๊ะ

มันยังเป็นสิ่งที่แย่ที่สุดสิ่งเดียวที่คุณทำได้ใน fp32 และเหตุผลซ่อนอยู่ใน S2

S2 = mx.cumsum(j * price)     # j is the global bar index: 0, 1, 2, ... , n-1

เทอม j · price คือปัญหา เมื่อ j วิ่งไปถึง 150,000 และ price อยู่ราว 30,000 เทอมสุดท้ายเพียงตัวเดียวก็เป็น 4.5×10⁹ แล้ว และ S2 คือ running sum ของเทอมแบบนี้ 150,000 เทอม มันไม่ได้อยู่ที่ระดับราคาเลย มันปีนขึ้นไปในดินแดนที่ fp32 หยุดนับได้แล้ว

จุดที่มัน overflow: เลขคณิตของกับดัก

ผลรวม fp32 ขนาดมหึมาสองตัวราว 10¹⁴ ถูกลบกันเพื่อกู้ค่า windowed value เล็กๆ ที่จมหายไปใต้ rounding error ของมัน — catastrophic cancellation

ลองวางระดับขนาดไว้เทียบกัน เพราะนั่นคือจุดที่ความล้มเหลวทั้งหมดอาศัยอยู่

S2 = cumsum(j · price) ไปถึงประมาณ price · n²/2 ≈ 30,000 · (150,000)²/2 ≈ 3×10¹⁴ เรียกมันว่า ~10¹⁴ ทีนี้นึกถึงเพดานจำนวนเต็มแม่นยำของ fp32: ~1.6×10⁷ running sum เกินจำนวนเต็มสุดท้ายที่ fp32 แทนค่าได้แม่นยำไปถึง เจ็ด order of magnitude

นั่นหมายความว่ายังไงในทางปฏิบัติ ใกล้ๆ 10¹⁴ ช่องว่างระหว่างตัวเลข fp32 ที่แทนค่าได้สองตัว — หนึ่งหน่วยในตำแหน่งสุดท้าย หรือ ULP — อยู่ที่ราว 2²³ ≈ 8×10⁶ ดังนั้นเมื่อ S2 ขึ้นไปอยู่ในช่วง 10¹⁴ มันจะรู้ค่าได้แค่ในขอบเขต ±8 ล้านเท่านั้น ทุกค่าที่มันเก็บถูกปัดเศษไปที่ตัวคูณที่ใกล้ที่สุดของ ~8×10⁶

ทีนี้มาดูว่าการกู้ WMA คืนทำอะไรกับสิ่งนั้น เพื่อดึง weighted sum ของ window เดียวออกมา คุณต้องลบค่า S2 สองค่าที่อยู่ติดกัน (บวก correction จาก S1) ค่า S2 ทั้งสองนั้นแต่ละตัวอยู่ที่ ~10¹⁴ แต่ละตัวแบก rounding noise ±8×10⁶ ไว้ ผลต่างที่แท้จริงของมัน — ปริมาณ windowed ที่คุณต้องการจริงๆ — เมื่อ normalize แล้วสอดคล้องกับ WMA ที่มีขนาดเทียบเท่าราคาเอง ~3×10⁴ ดังนั้นเลขคณิตคือ

(1014±8×106)fp32(1014±8×106)fp32=a quantity you need to a few parts in 104true answer\underbrace{(10^{14} \pm 8\times10^6)}_{\text{fp32}} - \underbrace{(10^{14} \pm 8\times10^6)}_{\text{fp32}} = \underbrace{\text{a quantity you need to a few parts in } 10^4}_{\text{true answer}}

นี่คือ catastrophic cancellation ในรูปแบบบริสุทธิ์ที่สุด: rounding error ของแต่ละ operand (±8×10⁶) ใหญ่กว่าคำตอบที่คุณพยายามจะกู้คืนเสียอีก สัญญาณเล็กกว่า noise floor ของตัวเลขที่มันถูกดึงออกมา ไม่ใช่แค่คุณเสียเลขไปสองสามหลัก — คุณเสียมันไปทั้งหมด และสิ่งที่ได้กลับมาถูกครอบงำโดย rounding สะสมของ cumsum

ผลลัพธ์ที่วัดได้ จาก GPU_NOTES.md: สำหรับ WMA ที่คำนวณด้วยวิธีนี้บน 150k บาร์ ที่ราคา ~30,000 ค่าความคลาดเคลื่อนสัมพัทธ์สูงสุดเทียบกับ fp64 ไปถึง ~211 ไม่ใช่ 211 เปอร์เซ็นต์ — 211 เท่า moving average ที่คำนวณได้อาจห่างจากค่าจริงถึงสอง order of magnitude และนี่คือส่วนที่ทำให้มันเป็นกับดักมากกว่าจะเป็นแค่ bug: มันรันจนจบและคืนตัวเลขจำกัดที่ดูสมเหตุสมผล ไม่ overflow ไปเป็น infinity ไม่มี NaN ไม่มี exception moving average ที่ผิดไป 211 เท่ายังคงดูเหมือน moving average — มันเรียบ มันเป็นเลขจำกัด มันอยู่ในช่วงที่ใกล้เคียงพอสมควรบนบาร์ที่ cancellation เกิดขึ้นไม่รุนแรงนัก — ดังนั้นมันจึงผ่าน sanity check ทุกตัวที่ไม่ใช่การเทียบตรงกับ reference ที่เชื่อถือได้ คุณจะได้ backtest เต็มรูปแบบ equity curve เต็มรูปแบบ ชุด parameter ที่ "optimal" เต็มรูปแบบ ทั้งหมดสร้างขึ้นบน indicator ที่เป็นเรื่องแต่ง

ทางแก้ไม่ใช่ความแม่นยำที่มากขึ้น — แต่คือผลรวมที่ต่างออกไป

sliding window สั้นๆ ของเทอมขนาดเดียวกันราว 200 เทอม ถูกรวมโดยตรงด้วย convolution — partial sum ทุกตัวอยู่ใกล้ระดับราคา ภายในช่วงแม่นยำของ fp32

สัญชาตญาณแรกเมื่อคุณเห็น error คือเอื้อมไปหาความแม่นยำที่มากขึ้น — สะสมใน fp64 หรือใช้ compensated (Kahan) summation บน Metal อย่างแรกนั้นไม่มีให้ใช้เลย แต่คุณไม่ต้องการทั้งสองอย่าง เพราะปัญหาไม่เคยอยู่ที่จำนวน bit ปัญหาคือสูตรคำนวณ เทคนิค prefix-sum สร้าง intermediate ขนาด 10¹⁴ ขึ้นมาแล้วลบมันกลับลงมา ขนาดที่มันสร้างขึ้นคือสิ่งประดิษฐ์ของ algorithm ไม่ใช่ของคำตอบ เลือกสูตรที่ไม่สร้างมันขึ้นมาเลย แล้ว fp32 ก็จะโอเค

สูตรนั้นคือนิยามของมันเอง: windowed convolution โดยตรง แทนที่จะใช้ cumulative sum ระดับ global สองตัว ให้เลื่อน linear kernel ความยาว p ไปตามซีรีส์แล้วรวมมันตรงจุด แต่ละ output คือผลรวมของอย่างมาก p ≈ 200 เทอม และแต่ละเทอมคือ weight × price โดยที่ weight ถูก normalize ให้รวมกันเป็น 1 — ดังนั้นแต่ละเทอมมีขนาดเทียบเท่า price / p ทุก partial sum อยู่ใกล้ระดับราคา (~3×10⁴) และไม่มี intermediate ตัวไหนเข้าใกล้เพดาน fp32 ภายในหก order of magnitude เลย ไม่มีอะไรให้ cancel เพราะไม่มีอะไรถูก inflate ตั้งแต่แรก

ใน MLX นี่คือ primitive ตัวเดียว — mx.conv1d — ซึ่งเป็นสิ่งที่ GPU ถูกสร้างมาให้ทำได้เร็วพอดี:

def _mx_wma_valid(x, period):
    w = mx.arange(1, period + 1, dtype=mx.float32) / (period * (period + 1) / 2.0)
    return mx.conv1d(x.reshape(1, -1, 1), w.reshape(1, period, 1), padding=0).reshape(-1)

WMA เดียวกัน ตรงกันทางคณิตศาสตร์กับเวอร์ชัน prefix-sum และกับ vec_wma/nb_wma แบบ fp64 บน CPU แต่ตอนนี้ ค่าความคลาดเคลื่อนสัมพัทธ์สูงสุดเทียบกับ fp64 คือ 8.2×10⁻⁷ — อยู่ตรง noise floor ของ fp32 ที่ ~1.2×10⁻⁷ พอดี ตรงกันเจ็ดเลขนัยสำคัญ สูตรที่ดูช้ากว่าบนกระดาษ (O(n·p) แทนที่จะเป็น O(n)) กลับเป็นสูตรเดียวที่ถูกต้อง และ — เพราะมันคือ dense convolution ที่ GPU parallelize ได้ทั้งข้าม bar และข้าม window พร้อมกัน — มันจึงเร็วอย่างบ้าคลั่งด้วย เราไปจาก relative error 211 มาเป็น 8×10⁻⁷ ด้วยการเปลี่ยนวิธีรวม ไม่ใช่จำนวน bit ที่เรารวม

มีข้อสังเกตเชิงปฏิบัติสองข้อที่หลุดออกมาจากการทำแบบนี้ ข้อแรก MLX จะไม่ propagate NaN ผ่าน conv1d แบบที่ numpy ทำ ดังนั้นช่วง warm-up (บาร์ p−1 ตัวแรก ที่ windowed average ยังไม่ถูกนิยาม) จึงไม่สามารถ mark ด้วย NaN บน GPU ได้ แต่เราไม่จำเป็นต้องทำแบบนั้น: จุดเริ่มต้นที่ valid ของแต่ละซีรีส์รู้ได้จากการวิเคราะห์ prefix ที่ invalid ถูกเติมด้วยศูนย์ที่ไม่มีใครอ่านอยู่แล้ว และ NaN padding ถูกคืนกลับมาบน CPU ทีหลัง — เหมือน validity mask ทุกบิตกับเวอร์ชัน vectorized และ numba ข้อสอง sweep ทั้งหมดใช้ cand_close ซีรีส์เดียวร่วมกัน และใช้ window ซ้ำอย่างหนักข้าม combo ดังนั้น conv1d แบบ batched เดียวที่มี output channel จำนวนมากจึงคำนวณ WMA ที่ไม่ซ้ำกันทุกตัวที่ sweep ต้องการได้ใน GPU call เดียว materialize ด้วย mx.eval() เพียงครั้งเดียว

พิสูจน์ว่าคุณไม่ได้ตกหลุมพราง: parity ด้วยจำนวน trade

นี่คือคำถามที่ไม่สบายใจที่ section ที่แล้วควรจะทำให้เกิดขึ้น: ถ้า WMA ที่ผิดไป 211 เท่ายังดูเหมือน WMA อยู่ แล้วคุณจะรู้ได้อย่างไรว่าเวอร์ชัน 8×10⁻⁷ นั้นถูกต้องจริงๆ ไม่ใช่แค่ผิดในแบบที่แนบเนียนกว่า คุณมองด้วยตาไม่ได้ คุณต้องการ invariant ที่ส่วนปลายทางที่เป็น discrete ของ pipeline เปิดเผยออกมา — และ backtest ก็ยื่นสิ่งที่สมบูรณ์แบบให้คุณ: trade

GPU method อื่นๆ ใน ladder (M0–M4) รันด้วย fp64 ทั้งหมด ดังนั้นเราจึงยึดมันไว้กับ equivalence assert ที่เข้มงวด — จำนวน trade ตรงกันเป๊ะ, PnL ตรงกันถึง atol=1e-6 GPU method แบบ fp32 (M5) ไม่สามารถผ่านแบบนั้นได้โดยธรรมชาติของมัน และการผ่อนปรน assert อย่างเงียบๆ ให้ทุกคนเพื่อรองรับมันจะเป็นความไม่ซื่อสัตย์แบบเดียวกับที่ซีรีส์นี้มีไว้ต่อสู้พอดี ดังนั้น M5 จึงมี parity report เชิงปริมาณเป็นของตัวเอง report_equiv_fp32 ที่เปรียบเทียบ trade ที่ดึงออกมาของมันกับ fp64 reference

กลไกของความไม่ตรงกันที่เหลืออยู่ (ถ้ามี) ควรค่าแก่การระบุให้ชัดเจน เพราะมันไม่ใช่ cancellation catastrophe — มันคือ fp32 rounding เล็กๆ ธรรมดาที่คุณคาดว่าจะเจอ กลยุทธ์นี้ยิงที่จุด crossover ของ Hull average สองตัว h กับ h3 relative error ราว ~1×10⁻⁶ บน indicator ที่ราคา ~30,000 คือความสั่นไหวสัมบูรณ์ราว ~0.03 บนบาร์ส่วนใหญ่ curve ทั้งสองห่างกันมากกว่านั้น และ crossover ก็ไม่คลุมเครือ แต่บนบาร์ที่borderline — ที่ h − h3 เองอยู่ในระยะ 0.03 จากศูนย์ — ความสั่นไหวนั้นสามารถพลิกเครื่องหมายของการเปรียบเทียบ ทำให้ crossover หนึ่งขยับไปหนึ่งบาร์ เพิ่มหรือลด trade หนึ่งรายการ

นี่คือเหตุผลที่ "สัดส่วนของ combo ที่แตกต่างกัน" เป็น health metric ที่ไร้ค่า และ parity check แรกของเราก็ทำให้ตัวเองอับอายด้วยการใช้มัน ที่ 150k บาร์ แต่ละ combo มี crossoverหลายพันครั้ง ดังนั้นอย่างน้อยหนึ่งบาร์ borderline จะปรากฏบนแทบทุก combo — 37 จาก 80 combo "แตกต่างกัน" ซึ่งฟังดูน่าตกใจแต่ไม่มีความหมายอะไรเลย metric ที่สำคัญคือแตกต่างกันมากแค่ไหน:

  • PnL delta ข้าม 80 combo ทั้งหมด: max |Δ| = 1.843 percentage point, max relative = 1.25×10⁻²; crash threshold 5 p.p.
  • Trade-count drift ต่อ combo: max |Δn| = 4 trade จากหลายพัน, max relative = 2.5×10⁻³; crash threshold 1%
  • โดยรวม: trade ที่ shift ไป 90 รายการ จากทั้งหมด 479,016 รายการ — 0.019%

เก้าสิบ trade จากเกือบครึ่งล้าน แต่ละรายการคือ borderline crossover ที่ถูก rounding wobble ที่เล็กกว่า price tick ผลักไปนิดหน่อย ไม่มีอะไรใกล้เคียง crash threshold เลย นั่นคือลายเซ็นของ fp32 method ที่ถูกต้อง — ความไม่ตรงกันเล็กน้อย มีขอบเขต อธิบายได้ — และมันเป็นสัตว์คนละชนิดโดยสิ้นเชิงจาก relative error 211 threshold เหล่านั้นมีไว้เพื่อจับ formulation ที่พังแล้วปลอมตัวเป็น "อ้าว มันก็แค่ fp32 นี่" delta ที่แท้จริงมาต่ำกว่านั้นเป็น order of magnitude จำนวน trade คือ oracle ที่ equity curve ปฏิเสธที่จะเป็น

ผลตอบแทน และจุดที่ GPU หยุดช่วย

เมื่อความถูกต้องถูกยืนยันแล้ว ความเร็วก็ควรค่าแก่การพูดถึง — แล้วก็ต้องกำกับด้วยความซื่อสัตย์ เพราะข้อได้เปรียบของ GPU ไม่ได้สม่ำเสมอตลอดทั้ง pipeline

บน WMA convolution ล้วนๆ ที่แยกออกมา — การดำเนินการที่ทั้ง method นี้มีไว้เพื่อเร่งความเร็ว — conv1d แบบ fp32 batch รันเร็วกว่า numba แบบ single-thread ถึง 55.9 เท่า ที่ relative error 8.2×10⁻⁷ นั่นคือตัวเลข GPU-vs-CPU ที่สะอาดและเทียบกันแบบ apples-to-apples: คณิตศาสตร์เดียวกัน หนึ่ง thread ของ compiled CPU code เทียบกับ Metal GPU

แต่ sweep ไม่ได้มีแค่ convolution เมื่อ matrix ของ HMA/HMA3 ถูกคำนวณบน GPU แล้ว trade ก็ยังต้องถูกดึงออกมา — การเดิน O(n) ข้าม crossover ของแต่ละ combo — และเราทำสิ่งนั้นบน CPU ด้วย fp64 โดยใช้ trade semantics เดียวกันกับ method อื่นๆ ซ้ำ แทนที่จะ re-implement มันบน GPU ตัวเลข timed() แบบ end-to-end รวมทุกอย่างไว้: kernel warm-up ไม่รวม (สมมาตรกับการไม่รวม compile ของ numba) แต่ GPU→CPU transfer และ CPU trade extraction รวมอยู่ด้วย บน 150k บาร์ × 80 combo, best-of-3, M2 Max:

Method Wall Speedup vs baseline combos/s
M0 pandas + Python loop* 287.08s 1.0× 0.3
M1 vectorized numpy 3.14s 91.5× 25.5
M2 numba (serial) 2.02s 142.3× 39.7
M3 multiprocess + vectorized 0.50s 570.2× 158.9
M4 multiprocess + numba (12 cores) 0.33s 882.5× 245.9
M5 MLX GPU (fp32) 0.10s 2796.0× 779.2

*M0 คำนวณประมาณจาก sample แบบ uniform 5 combo

M5 แบบ full-engine ทำได้ 779 combo/s — เร็วกว่า pandas baseline 2,796 เท่า, เร็วกว่า numba แบบ serial 19.6 เท่า และ เร็วกว่า CPU pool ทั้ง 12 core ที่รัน numba (M4) 3.2 เท่า GPU ตัวเดียวเอาชนะทุก core ของ CPU ที่เครื่องมี ถึงสามเท่า

ทีนี้มาดูตัวกำกับที่ซื่อสัตย์: สังเกตว่าข้อได้เปรียบของ GPU แบบend-to-end (19.6 เท่าเทียบกับ M2) เล็กกว่าข้อได้เปรียบแบบconvolution-only (55.9 เท่าเทียบกับ numba) ช่องว่างนั้นคือกฎของ Amdahl ที่มาถึงตามกำหนด GPU ทำลาย convolution จนหมดสิ้นจนมันหยุดเป็น bottleneck สิ่งที่เหลืออยู่ — CPU trade extraction แบบ O(n) ที่ GPU ไม่ได้ช่วยเร่งเลยแม้แต่น้อย — ตอนนี้ครองเวลา wall time ของ M5 นี่คือบทเรียนเดียวกับที่บทความ speed-ladder และ IPC-tax ในซีรีส์นี้ตกลงมาซ้ำแล้วซ้ำเล่า: เลยจุดหนึ่งไปแล้ว ชัยชนะไม่ใช่ "ทำส่วนที่เร็วให้เร็วขึ้นอีก" แต่คือ orchestration — ข้อมูลอยู่ที่ไหน stage ไหนตอนนี้เป็น serial คุณกำลังจ่ายอะไรเพื่อย้ายข้อมูลระหว่าง device กับ host การไล่ตาม M6 สมมติที่ผลัก trade extraction เข้าไปใน custom Metal kernel จะกู้คืนได้แค่ CPU slice ที่กำลังหดตัวลงเท่านั้น นั่นคือเหตุผลที่เราไม่ได้สร้างมันขึ้นมา

บทเรียนทั่วไป: ขยะเชิงตัวเลขแบบเงียบๆ คือค่า default

ถอยออกมาจาก Hull average และ MLX เพราะกับดักนี้ทั่วไปกว่า indicator ตัวเดียวนี้มาก

ข้อเสนอที่ล่อตาล่อใจของ GPU backtesting คือ "matrix ใหญ่ก้อนเดียว": stack ทุก parameter combination เข้าไปใน tensor เดียว รัน sweep ทั้งหมดเป็น dense array op หยิบมือเดียว ให้ hardware กินมันไป ข้อเสนอนั้นเป็นจริง — speedup ข้างต้นเป็นจริง แต่มันเปลี่ยน numerical regime ที่อยู่ข้างใต้อย่างเงียบๆ และการเปลี่ยนนั้นมองไม่เห็นในโค้ด บน CPU ค่า default ของคุณปกป้องคุณไว้: fp64, NaN propagation, cumsum ที่รันไปถึง 10¹⁴ ได้โดยไม่มีใครสังเกต ย้าย array expression เดียวกันไปที่ Metal แล้วคุณอยู่ใน fp32 พร้อมเพดานจำนวนเต็มแข็งๆ ที่ 1.6×10⁷ และโค้ดบรรทัดเดียวกันเป๊ะcumsum(j * price) — เปลี่ยนจากแม่นยำไปเป็นขยะ ไม่มีอะไรใน syntax เตือนคุณเลย compiler พอใจ output เป็นเลขจำกัดและดูสมเหตุสมผล fp32 ไม่ล้มเหลวเสียงดัง มันล้มเหลวอย่างสุภาพ ด้วยตัวเลข

สามนิสัยที่ปกป้องคุณได้จริงนั้นถูกมาก:

  1. รู้ว่า intermediate ของคุณอยู่ที่ไหน ไม่ใช่แค่ input กับ output input (ราคา ~10⁴) และ output (WMA ~10⁴) ทั้งคู่อยู่สบายๆ ภายในช่วงแม่นยำของ fp32 หายนะทั้งหมดอยู่ในintermediate ที่ซ่อนอยู่(S2 ~10¹⁴) ที่ทั้ง API และ type ไม่ได้ทำให้มองเห็น ก่อนจะเชื่อ fp32 reduction ใดๆ ให้ถามว่า partial sum ที่ใหญ่ที่สุดไปถึงเท่าไหร่ — และถ้ามันข้าม ~10⁷ ไป ให้เปลี่ยนสูตร
  2. เลือกสูตรที่ทำให้ magnitude มีขอบเขต convolution โดยตรงแทน prefix sum; local window แทน global scan; centering/differencing ก่อน sum แทนที่จะทำทีหลัง ใหญ่-แล้ว-cancel คือ anti-pattern algorithm ที่ถูกต้องมักเป็นตัวที่ดูแย่กว่าในเชิง asymptotic บนกระดาษ แต่ไม่เคยสร้างปริมาณที่ต้อง cancel ทิ้งเลย
  3. validate กับ fp64 oracle ผ่าน discrete invariant อย่าเทียบ curve เทียบสิ่งที่ quantize แล้วและอยู่ปลายทาง — จำนวน trade, จำนวน crossover, position-change event discrete invariant เปลี่ยน error 211 เท่าที่เงียบให้กลายเป็น assertion failure ที่กรีดร้อง และเปลี่ยน error 8×10⁻⁷ ที่ยอมรับได้ให้กลายเป็น delta เล็กๆ มีขอบเขต อธิบายได้ นี่คือวินัยเดียวกับ one-bar shift test สำหรับ look-ahead bias: diagnostic ที่ถูกมากที่เปลี่ยนความล้มเหลวที่มองไม่เห็นให้มองเห็นได้

ไม่มีอะไรในนี้เป็น numerical analysis แบบพิสดาร มันคือสุขอนามัยธรรมดาของการไม่เชื่อ backtest ที่เร็วจนกว่า backtest ที่ช้าแต่น่าเชื่อถือจะรับรองมัน ขยายไปถึงจุดเดียวที่ภาษาโปรแกรมหยุดเตือนคุณว่าความแม่นยำหล่นลงไปเงียบๆ ถึงสิบสอง order of magnitude

บทสรุป

จุดพลิกบรรทัดเดียว: cumsum(j·price) ถูกขีดฆ่าที่ error 211 เท่า, mx.conv1d อยู่ข้างๆ ที่ 8×10⁻⁷ — WMA เดียวกัน ผลรวมต่างกัน

  1. GPU ของ Apple ไม่มี float64 — ตัวเลข GPU ทุกตัวใน backtest ของคุณคือ fp32 จำนวนเต็มแม่นยำเฉพาะถึง ~1.6×10⁷ และความแม่นยำอยู่ที่ ~1.2×10⁻⁷ ไม่มี flag ไม่มี fallback backtest ส่วนใหญ่รอดจากสิ่งนี้ได้; มักมีแค่การดำเนินการเดียวที่ไม่รอด
  2. prefix-sum WMA คือกับดัก cumsum(j · price) ปีนขึ้นไปถึง ~10¹⁴ เจ็ด order of magnitude เกินเพดานแม่นยำของ fp32 และการกู้ window คืนบังคับให้คุณลบตัวเลขแบบนั้นสองตัวที่ rounding error (±8×10⁶) ของมันใหญ่กว่าคำตอบไปแล้ว ค่าความคลาดเคลื่อนสัมพัทธ์สูงสุดที่วัดได้: 211 เท่า มันไม่เคย crash — มันคืนขยะที่ดูสมเหตุสมผล
  3. ทางแก้คือผลรวมที่ต่างออกไป ไม่ใช่ bit ที่มากขึ้น windowed convolution โดยตรง (mx.conv1d) ทำให้ partial sum ทุกตัวอยู่ใกล้ระดับราคา ดังนั้น fp32 จึงรักษาเลขนัยสำคัญที่ซื่อสัตย์ไว้ได้เจ็ดตัว: relative error 8.2×10⁻⁷ และเร็วกว่า numba แบบ single-thread 55.9 เท่า คุณซื้อ fp64 บน Metal ไม่ได้ และคุณก็ไม่จำเป็นต้องซื้อ
  4. verify ด้วย discrete invariant ไม่ใช่ curve trade-count parity จับมันได้: fp32 conv1d ไม่ตรงกับ fp64 บน 90 จาก 479,016 trade (0.019%) ทั้งหมดเป็น borderline crossover ทั้งหมดต่ำกว่า crash threshold มาก — ลายเซ็นของ method ที่ถูกต้อง แตกต่างชัดเจนจาก error 211 เท่า "สัดส่วนของ combo ที่แตกต่างกัน" คือ metric หลอกลวง วัดแตกต่างกันมากแค่ไหนต่างหาก
  5. sweep เต็มรูปแบบทำได้ 779 combo/s — เร็วกว่า pandas baseline 2,796 เท่า เร็วกว่า CPU pool ทั้ง 12 core 3.2 เท่า — แต่ชัยชนะแบบ end-to-end (19.6 เท่าเทียบกับ numba serial) เล็กกว่าชัยชนะแบบ convolution-only (55.9 เท่า) เพราะ CPU trade extraction ตอนนี้คือ bottleneck เลยจุดหนึ่งไปแล้ว ความเร็วคือ orchestration ไม่ใช่ arithmetic

GPU port เร็วขึ้น 2,796 เท่า และในเวอร์ชันแรกที่รันได้ มันผิดโดยสิ้นเชิง — และสองข้อเท็จจริงนี้ไม่เกี่ยวข้องกันเลย ความเร็วเป็นเรื่องจริง ขยะคือ intermediate ขนาด 10¹⁴ ที่ซ่อนอยู่ ที่ fp32 ไม่สามารถเก็บได้ และไม่มี error message ไหนจะพูดถึงมัน ถ้า backtest เร็วขึ้นอย่างมากและตัวเลขยังดูดีอยู่ นั่นไม่ใช่การยืนยัน บน Metal "ดูดี" คือสิ่งที่ relative error 211 หน้าตาเป็นแบบนั้นเป๊ะ

นี่คือขั้น GPU ของ ladder ที่ซีรีส์นี้กำลังไต่ขึ้นมา: backtest-engine speed ladder, IPC tax ของ multiprocessing, look-ahead taxonomy ของการรั่วไหล และ objective-function design ที่ตัดสินว่า "ดี" หมายถึงอะไร ความเร็วไร้ค่าถ้ามันเร็วในการคำนวณตัวเลขที่ผิด

ข้อจำกัดความรับผิดชอบ: ข้อมูลที่ให้ไว้ในบทความนี้มีไว้เพื่อการศึกษาและให้ข้อมูลเท่านั้น และไม่ถือเป็นคำแนะนำทางการเงิน การลงทุน หรือการเทรด การเทรดสกุลเงินดิจิทัลมีความเสี่ยงสูงที่จะขาดทุน

ผู้เขียน

Eugen Soloviov
Eugen Soloviov

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.

Newsletter

ก้าวนำหน้าตลาด

สมัครรับจดหมายข่าวของเราเพื่อรับข้อมูลเชิงลึกการเทรดด้วย AI เฉพาะ การวิเคราะห์ตลาด และการอัปเดตแพลตฟอร์ม

เราเคารพความเป็นส่วนตัวของคุณ ยกเลิกการสมัครได้ทุกเมื่อ