← Quay lại danh sách bài viết
July 6, 2026
5 phút đọc

Cái bẫy độ chính xác GPU: Vì sao một backtest fp32 trên Apple Metal âm thầm trả về rác

Cái bẫy độ chính xác GPU: Vì sao một backtest fp32 trên Apple Metal âm thầm trả về rác
#giao dịch thuật toán
#backtest
#gpu
#apple-silicon
#dấu phẩy động
#ổn định số học
#mlx
Part 8 of 10 · Collection
High-Performance Backtest Engines

Một phần của loạt bài "Backtest không ảo tưởng".

Chúng tôi đã chuyển backtest quét tham số của mình sang GPU và nó nhanh hơn 2.796×. Những con số nó trả về trông có vẻ hợp lý. Chúng cũng, ở phiên bản chạy được đầu tiên, hoàn toàn là rác — sai lệch tới hai trăm lần — mà không có gì bị crash, không có cảnh báo nào, không có gì trông sai cả.

Đây là cái bẫy khiến backtest trên GPU của Apple Silicon nguy hiểm theo một cách mà mã CPU không hề có: Metal không có float64. Mọi con số mà backtest của bạn chạm tới trên một GPU của Apple đều là một số dấu phẩy động 32-bit, dù bạn có muốn điều đó hay không. Và cách hấp dẫn nhất để vector hóa một đường trung bình động — thủ thuật prefix-sum O(n) mà mọi quant chú trọng hiệu năng đều tìm đến — chính xác là công thức mà fp32 không thể sống sót ở thang giá. Nó không báo lỗi. Nó chạy hết tốc lực và trao cho bạn một đường vốn (equity curve) trông có vẻ hợp lý, được xây trên một đường trung bình động sai lệch tới 211 lần.

Cách sửa mới là phần thú vị, vì nó không phải là "dùng nhiều bit hơn" (bạn không thể) và nó cũng không phải là "cẩn thận hơn" (đoạn mã ngây thơ vốn đã cẩn thận rồi). Cách sửa là tính toán cùng một đại lượng toán học bằng một phép tổng khác — một tích chập cửa sổ trực tiếp — để giữ cho mọi giá trị trung gian đủ nhỏ để fp32 biểu diễn được chính xác. Cùng một WMA, cùng một kết quả tới bảy chữ số có nghĩa, và nhanh hơn 55,9× so với numba đơn luồng trên CPU. Bài viết này là một cuộc khám nghiệm: vì sao Metal ép fp32 lên bạn, chính xác chỗ nào công thức hiển nhiên bị tràn, vì sao công thức đúng lại không, và làm thế nào chúng tôi chứng minh — bằng cách đếm giao dịch, chứ không phải nhìn bằng mắt các đường cong — rằng phiên bản nhanh và phiên bản fp64 trung thực khớp với nhau.

Tất cả các con số ở đây được đo trên một Apple M2 Max, 150.000 bar × 80 tổ hợp tham số, tốt nhất trong 3 lần, từ scripts/bench_param_sweep.py của repo (phương pháp GPU M5, commit 97eadaf), scripts/engine_multitf_gpu.py (04d71e8), và các ghi chú thiết kế trong scripts/GPU_NOTES.md.

Metal không có float64

Đường ống GPU Metal của Apple với float64 bị gạch bỏ — mọi làn (lane) bị ép vào 32-bit, số nguyên chỉ chính xác tới 1,6×10⁷

Trên một CPU, một backtest Python/numpy chạy ở độ chính xác kép theo mặc định. float64 cho bạn một phần định trị (mantissa) 52-bit: số nguyên được biểu diễn chính xác tới 2⁵³ ≈ 9×10¹⁵, và độ chính xác tương đối vào khoảng 1,1×10⁻¹⁶. Bạn gần như không bao giờ phải nghĩ về nó, bởi vì ở thang giá — một giá đóng cửa BTC quanh 30.000, một tổng lũy kế chạm tới 10¹⁴ — fp64 vẫn còn thừa biên độ.

GPU của Apple không cung cấp biên độ đó, bởi vì nó hoàn toàn không cung cấp float64. Metal Shading Language không có kiểu double; MLX, PyTorch-MPS, và mọi framework khác nhắm tới Apple Silicon đều thừa hưởng điều này. Không có cờ nào để bật nó lên và không có phương án dự phòng chậm-nhưng-đúng nào. Nếu phép tính của bạn chạm tới GPU, nó xảy ra ở fp32:

  • Phần định trị: 23 bit (24 với bit dẫn đầu ngầm định). Số nguyên chỉ chính xác tới 2²⁴ = 16.777.216 ≈ 1,6×10⁷.
  • Độ chính xác tương đối: ~1,2×10⁻⁷. Xấp xỉ bảy chữ số thập phân có nghĩa, không hơn.

Cái trần 1,6×10⁷ đó là toàn bộ câu chuyện. Nghe có vẻ hào phóng — mười sáu triệu — cho tới khi bạn nhận ra rằng một backtest thường xuyên xây dựng những đại lượng trung gian lớn hơn nhiều con số đó, và khoảnh khắc một đại lượng trung gian vượt qua 1,6×10⁷, fp32 thậm chí không còn biểu diễn được các số nguyên liên tiếp, chứ đừng nói tới cấu trúc phân số mà bạn cần. Độ chính xác không suy giảm một cách êm ả; nó rơi khỏi một vách đá, và vách đá nằm ở một con số mà dữ liệu của bạn vượt qua mà chẳng cần đắn đo.

Cái bẫy là fp32 gần như luôn ổn. Phần lớn một backtest — giá, lợi suất, PnL, Sharpe — sống thoải mái trong khoảng mà bảy chữ số là quá đủ. Vậy nên bản chuyển đổi ngây thơ hoạt động, vượt qua kiểm tra sơ bộ, cho ra đầu ra trông tỉnh táo. Sự thất bại được khu trú vào đúng một phép tính, cái nơi duy nhất mà một đại lượng trung gian phóng vượt 10⁷, và phép tính đó lại là phép mà ai cũng vector hóa đầu tiên.

Công thức đầy cám dỗ: WMA trong một lượt quét prefix-sum

Chiến lược của chúng tôi dựa vào các đường trung bình động Hull. Một HMA là ba đường trung bình động có trọng số được ghép lại với nhau; biến thể HMA3 là bốn. Một đường trung bình động có trọng số với một nhân (kernel) tuyến tính trên một cửa sổ độ dài 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}.

Khi quét hàng nghìn tổ hợp tham số trên 150k bar, các phép tích chập WMA chính là chi phí. Vậy nên bản năng — bản năng đúng, trên một CPU — là làm mỗi WMA thành O(n) thay vì O(n·p) bằng cách dùng prefix-sum. Bạn tính trước hai tổng lũy kế một lần,

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,

và rồi tổng có trọng số tuyến tính của bất kỳ cửa sổ nào cũng thu gọn lại thành một nhúm phép hiệu và dịch chỉ số của S1S2. Không có vòng lặp theo cửa sổ, không có phép rút gọn theo cửa sổ — hai lượt cumsum và toàn bộ ma trận các WMA rơi ra khỏi phép số học mảng. Nó vector hóa rất đẹp, nó ánh xạ hoàn hảo lên nguyên hàm quét song song (parallel-scan) của một GPU, và trên fp64 nó chính xác tuyệt đối.

Nó cũng là điều tệ nhất mà bạn có thể làm trong fp32, và lý do đang ẩn trong S2.

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

Số hạng j · price là vấn đề. Với j chạy lên tới 150.000 và price quanh 30.000, riêng số hạng cuối cùng đã là 4,5×10⁹, và S2 là tổng đang chạy của 150.000 số hạng như vậy. Nó không ở lại thang giá. Nó leo vào lãnh địa mà fp32 đã ngừng khả năng đếm.

Nơi nó tràn: phép số học của cái bẫy

Hai tổng fp32 khổng lồ ~10¹⁴ bị trừ cho nhau để khôi phục một giá trị cửa sổ nhỏ, giá trị này biến mất bên dưới sai số làm tròn của chúng — triệt tiêu thảm khốc

Hãy đặt các bậc độ lớn cạnh nhau, bởi vì đó là nơi toàn bộ sự thất bại cư trú.

S2 = cumsum(j · price) đạt xấp xỉ price · n²/2 ≈ 30.000 · (150.000)²/2 ≈ 3×10¹⁴. Gọi nó là ~10¹⁴. Giờ hãy nhớ lại trần số nguyên chính xác của fp32: ~1,6×10⁷. Tổng đang chạy vượt qua số nguyên cuối cùng mà fp32 biểu diễn chính xác được tới bảy bậc độ lớn.

Điều đó cụ thể có nghĩa là gì? Gần 10¹⁴, khoảng cách giữa hai số fp32 biểu diễn được — một đơn vị ở vị trí cuối cùng, ULP — vào khoảng 2²³ ≈ 8×10⁶. Vậy nên một khi S2 đã lên tới khoảng 10¹⁴, nó chỉ được biết trong phạm vi ±8 triệu. Mọi giá trị nó lưu đã bị làm tròn tới bội số gần nhất của ~8×10⁶.

Giờ hãy xem việc khôi phục WMA làm gì với con số đó. Để trích ra tổng có trọng số của một cửa sổ duy nhất, bạn trừ hai giá trị S2 lân cận nhau (cộng với các hiệu chỉnh S1). Hai giá trị S2 đó mỗi cái ~10¹⁴, mỗi cái mang ±8×10⁶ nhiễu làm tròn. Hiệu thật của chúng — đại lượng cửa sổ mà bạn thực sự muốn — tương ứng, sau khi chuẩn hóa, với một WMA cỡ chính giá đó, ~3×10⁴. Vậy phép số học là:

(1014±8×106)fp32(1014±8×106)fp32=một đại lượng bạn caˆˋn chıˊnh xaˊc tới vaˋi phaˆˋn trong 104đaˊaˊn thật\underbrace{(10^{14} \pm 8\times10^6)}_{\text{fp32}} - \underbrace{(10^{14} \pm 8\times10^6)}_{\text{fp32}} = \underbrace{\text{một đại lượng bạn cần chính xác tới vài phần trong } 10^4}_{\text{đáp án thật}}

Đây là triệt tiêu thảm khốc (catastrophic cancellation) ở dạng thuần khiết nhất của nó: sai số làm tròn của mỗi toán hạng (±8×10⁶) lớn hơn đáp án mà bạn đang cố khôi phục. Tín hiệu nhỏ hơn sàn nhiễu của những con số mà nó được trích ra từ đó. Không phải là bạn mất vài chữ số — bạn mất tất cả chúng, và cái quay trở lại bị chi phối bởi sai số làm tròn tích lũy của phép cumsum.

Hậu quả đo được, từ GPU_NOTES.md: với một WMA được tính theo cách này trên 150k bar ở giá ~30.000, sai số tương đối lớn nhất so với fp64 đạt tới ~211. Không phải 211 phần trăm — 211×. Đường trung bình động được tính ra có thể cách đường thật hai bậc độ lớn. Và đây là phần biến nó thành một cái bẫy chứ không phải một lỗi thông thường: nó chạy tới khi hoàn tất và trả về những con số hữu hạn, trông hợp lý. Không tràn thành vô cực, không NaN, không ngoại lệ. Một đường trung bình động sai lệch tới 211× vẫn trông giống một đường trung bình động — nó mượt, nó hữu hạn, nó nằm gần đúng khoảng giá ở những bar mà việc triệt tiêu tình cờ nhẹ nhàng — nên nó lọt qua mọi kiểm tra tỉnh táo nào không phải là một phép so sánh trực tiếp với một tham chiếu đáng tin cậy. Bạn có được một backtest đầy đủ, một đường vốn đầy đủ, một bộ tham số "tối ưu" đầy đủ, tất cả được xây trên một chỉ báo là hư cấu.

Cách sửa không phải là thêm độ chính xác — mà là một phép tổng khác

Một cửa sổ trượt ngắn gồm ~200 số hạng cùng thang được cộng trực tiếp bằng tích chập — mọi tổng riêng phần ở lại gần thang giá, bên trong khoảng chính xác của fp32

Phản xạ, một khi bạn thấy sai số, là tìm đến nhiều độ chính xác hơn — tích lũy trong fp64, hoặc dùng phép tổng bù (Kahan). Trên Metal, phương án đầu đơn giản là không có sẵn. Nhưng bạn không cần cái nào, bởi vì vấn đề chưa bao giờ là số lượng bit. Vấn đề là công thức. Thủ thuật prefix-sum chế tạo ra các đại lượng trung gian ở thang 10¹⁴ rồi trừ chúng xuống lại; các độ lớn mà nó tạo ra là sản phẩm phụ của thuật toán, chứ không phải của đáp án. Hãy chọn một công thức không bao giờ tạo ra chúng và fp32 sẽ ổn.

Công thức đó chính là định nghĩa bản thân: một tích chập cửa sổ trực tiếp. Thay vì hai tổng lũy kế toàn cục, hãy trượt nhân tuyến tính độ dài p dọc theo chuỗi và cộng nó tại chỗ. Mỗi đầu ra là một tổng của nhiều nhất p ≈ 200 số hạng, và mỗi số hạng là weight × price với các trọng số được chuẩn hóa để có tổng bằng 1 — nên mỗi số hạng cỡ price / p, mọi tổng riêng phần ở lại quanh thang giá (~3×10⁴), và không có đại lượng trung gian nào từng đến gần trần fp32 trong phạm vi sáu bậc độ lớn. Không có gì để triệt tiêu vì không có gì từng bị thổi phồng.

Trong MLX đây là một nguyên hàm — mx.conv1d — đó chính xác là thứ mà các GPU được xây để làm nhanh:

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)

Cùng một WMA, giống hệt về mặt toán học với phiên bản prefix-sum và với vec_wma/nb_wma fp64 của CPU. Nhưng giờ đây sai số tương đối lớn nhất đo được so với fp64 là 8,2×10⁻⁷ — ngay tại sàn nhiễu fp32 ~1,2×10⁻⁷, bảy chữ số có nghĩa khớp nhau. Công thức trông chậm hơn trên giấy (O(n·p) thay vì O(n)) lại là công thức duy nhất đúng, và — vì nó là một tích chập dày đặc mà GPU song song hóa trên cả các bar và các cửa sổ cùng một lúc — nó cũng nhanh chóng mặt. Chúng tôi đi từ sai số tương đối 211 xuống 8×10⁻⁷ bằng cách thay đổi cách chúng tôi cộng, chứ không phải bao nhiêu bit chúng tôi cộng trong đó.

Hai lưu ý thực tiễn nảy ra từ việc làm theo cách này. Thứ nhất, MLX sẽ không lan truyền NaN qua một conv1d theo cách numpy làm, nên vùng khởi động (các bar p−1 đầu tiên, nơi một trung bình cửa sổ chưa được định nghĩa) không thể được đánh dấu bằng NaN trên GPU. Chúng tôi không cần nó phải như vậy: điểm bắt đầu hợp lệ của mỗi chuỗi đã biết theo phương pháp giải tích, các tiền tố không hợp lệ được điền bằng số 0 mà không bao giờ được đọc, và phần đệm NaN được khôi phục trên CPU về sau — giống hệt từng bit mặt nạ hợp lệ (validity mask) như các phiên bản vector hóa và numba. Thứ hai, toàn bộ lượt quét dùng chung một chuỗi cand_close và tái sử dụng các cửa sổ rất nhiều xuyên suốt các tổ hợp, nên một conv1d gộp lô (batched) duy nhất với nhiều kênh đầu ra tính mọi WMA duy nhất mà lượt quét cần trong một lệnh gọi GPU, được vật chất hóa bằng một mx.eval().

Chứng minh bạn không rơi vào: sự tương đương qua số lượng giao dịch

Đây là câu hỏi khó chịu mà phần trước lẽ ra nên đặt ra: nếu một WMA sai lệch 211× vẫn trông giống một WMA, làm sao bạn biết phiên bản 8×10⁻⁷ thực sự đúng chứ không phải chỉ sai một cách tinh vi hơn? Bạn không thể nhìn bằng mắt. Bạn cần một bất biến (invariant) mà một phần rời rạc, ở hạ nguồn của đường ống bộc lộ ra — và một backtest trao cho bạn một cái hoàn hảo: các giao dịch.

Các phương pháp GPU khác trong bậc thang (M0–M4) chạy hoàn toàn trong fp64, nên chúng tôi giữ chúng ở một khẳng định (assert) tương đương nghiêm ngặt — số lượng giao dịch giống hệt nhau, PnL khớp tới atol=1e-6. Phương pháp GPU fp32 (M5) không thể vượt qua điều đó về mặt cấu trúc, và việc âm thầm nới lỏng khẳng định cho tất cả để dung nạp nó sẽ chính xác là loại bất trung thực mà loạt bài này tồn tại để chống lại. Vậy nên M5 có báo cáo tương đương định lượng riêng của nó, report_equiv_fp32, so sánh các giao dịch nó trích ra với tham chiếu fp64.

Cơ chế của bất kỳ bất đồng còn dư nào đáng được gọi tên chính xác, bởi vì nó không phải là thảm họa triệt tiêu — nó là sự làm tròn fp32 thông thường, nhỏ bé mà bạn sẽ mong đợi. Chiến lược khai hỏa khi hai đường trung bình Hull giao nhau, h so với h3. Một sai số tương đối ~1×10⁻⁶ trên một chỉ báo ở giá ~30.000 là một dao động tuyệt đối ~0,03. Trên đại đa số các bar, hai đường cong cách nhau xa hơn thế và điểm giao là rõ ràng. Nhưng trên một bar ranh giới — nơi bản thân h − h3 nằm trong phạm vi 0,03 quanh số 0 — dao động đó có thể lật dấu của phép so sánh, dịch chuyển một điểm giao đi một bar, thêm hoặc bớt một giao dịch.

Đây là lý do "tỷ lệ các tổ hợp khác biệt" là một thước đo sức khỏe vô giá trị, và phép kiểm tra tương đương đầu tiên của chúng tôi đã tự làm bẽ mặt vì dùng nó. Ở 150k bar, mỗi tổ hợp có hàng nghìn điểm giao, nên ít nhất một bar ranh giới xuất hiện trên gần như mọi tổ hợp — 37 trong 80 tổ hợp "khác biệt", nghe có vẻ đáng báo động mà chẳng có ý nghĩa gì. Thước đo có ý nghĩa là khác biệt bao nhiêu:

  • Chênh lệch PnL trên tất cả 80 tổ hợp: max |Δ| = 1,843 điểm phần trăm, max tương đối = 1,25×10⁻²; ngưỡng crash 5 điểm phần trăm.
  • Trôi số lượng giao dịch mỗi tổ hợp: max |Δn| = 4 giao dịch trên hàng nghìn, max tương đối = 2,5×10⁻³; ngưỡng crash 1%.
  • Tổng hợp: 90 giao dịch bị dịch chuyển trên 479.016 — 0,019%.

Chín mươi giao dịch trên gần nửa triệu, mỗi cái là một điểm giao ranh giới bị đẩy nhẹ bởi một dao động làm tròn nhỏ hơn một tick giá, không cái nào đến gần các ngưỡng crash. Đó là dấu hiệu của một phương pháp fp32 đúng — bất đồng nhỏ, có giới hạn, giải thích được — và nó là một con thú hoàn toàn khác với một sai số tương đối 211. Các ngưỡng tồn tại để bắt một công thức hỏng đang giả trang thành "à, chỉ là fp32 thôi mà"; các chênh lệch thật đến với độ lớn thấp hơn chúng cả một bậc. Số lượng giao dịch là nhà tiên tri mà đường vốn từ chối trở thành.

Phần thưởng, và nơi GPU ngừng giúp ích

Với tính đúng đắn đã được xác lập, tốc độ đáng được nêu ra — và rồi được đủ điều kiện một cách trung thực, bởi vì lợi thế của GPU không đồng đều xuyên suốt đường ống.

Trên các phép tích chập WMA thuần túy khi tách riêng — chính là phép tính mà cả phương pháp tồn tại để tăng tốc — lô conv1d fp32 chạy nhanh hơn 55,9× so với numba đơn luồng, ở sai số tương đối 8,2×10⁻⁷ đó. Đó là con số GPU-so-với-CPU sạch, so-sánh-ngang-hàng: cùng phép toán, một luồng mã CPU đã biên dịch đấu với GPU Metal.

Nhưng một lượt quét không chỉ là các phép tích chập. Một khi các ma trận HMA/HMA3 được tính trên GPU, các giao dịch vẫn phải được trích xuất — một lượt đi qua O(n) trên các điểm giao của mỗi tổ hợp — và điều đó chúng tôi làm trên CPU trong fp64, tái sử dụng chính xác ngữ nghĩa giao dịch của các phương pháp khác thay vì cài lại chúng trên GPU. Con số timed() đầu-cuối bao gồm mọi thứ: đã loại trừ khởi động nhân (kernel warm-up) (đối xứng với việc loại trừ biên dịch của numba), nhưng chuyển GPU→CPU và trích xuất giao dịch trên CPU thì được tính vào. Trên 150k bar × 80 tổ hợp, tốt nhất trong 3, M2 Max:

Phương pháp Wall Tăng tốc so với đường cơ sở tổ hợp/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 lõi) 0,33s 882,5× 245,9
M5 MLX GPU (fp32) 0,10s 2796,0× 779,2

*M0 được ngoại suy từ một mẫu đồng đều 5 tổ hợp.

M5 toàn-engine làm 779 tổ hợp/s — 2.796× so với đường cơ sở pandas, 19,6× so với numba đơn luồng, và 3,2× so với toàn bộ nhóm CPU 12 lõi chạy numba (M4). Một GPU đánh bại mọi lõi CPU mà máy có, gấp ba lần.

Giờ đến điều kiện trung thực: hãy để ý rằng lợi thế GPU đầu-cuối (19,6× so với M2) nhỏ hơn lợi thế chỉ-tích-chập (55,9× so với numba). Khoảng cách đó là định luật Amdahl đến đúng lịch. GPU tiêu diệt các phép tích chập trọn vẹn đến mức chúng ngừng là nút thắt cổ chai; cái còn lại — việc trích xuất giao dịch O(n) trên CPU mà GPU chẳng tăng tốc gì cả — giờ chi phối thời gian wall của M5. Đây là cùng một bài học mà các bài bậc thang tốc độ (speed-ladder)thuế IPC (IPC-tax) trong loạt bài này liên tục đáp xuống: qua một điểm nào đó, chiến thắng không phải là "làm phần nhanh nhanh hơn", mà là điều phối (orchestration) — dữ liệu sống ở đâu, giai đoạn nào giờ là tuần tự, bạn đang trả gì để di chuyển giữa thiết bị và máy chủ. Đuổi theo một M6 giả định đẩy việc trích xuất giao dịch vào một nhân Metal tùy chỉnh sẽ chỉ giành lại được lát CPU đang co lại, đó là lý do chúng tôi không xây nó.

Bài học tổng quát: rác số học âm thầm là mặc định

Lùi lại khỏi các đường trung bình Hull và MLX, bởi vì cái bẫy tổng quát hóa vượt xa chỉ một chỉ báo này.

Lời chào mời quyến rũ của backtest trên GPU là "một ma trận lớn": xếp mọi tổ hợp tham số vào một tensor, chạy toàn bộ lượt quét như một nhúm phép toán mảng dày đặc, để phần cứng nuốt nó. Lời chào mời đó là thật — các mức tăng tốc phía trên là thật. Nhưng nó âm thầm thay đổi chế độ số học bên dưới bạn, và sự thay đổi là vô hình trong mã. Trên CPU, các mặc định của bạn bảo vệ bạn: fp64, lan truyền NaN, một cumsum có thể chạy tới 10¹⁴ mà không hề hấn gì. Chuyển cùng biểu thức mảng đó sang Metal và bạn đang ở fp32 với một trần số nguyên cứng 1,6×10⁷, và dòng mã giống hệtcumsum(j * price) — đi từ chính xác thành rác. Không có gì trong cú pháp cảnh báo bạn. Trình biên dịch hài lòng. Đầu ra hữu hạn và hợp lý. fp32 không thất bại ầm ĩ; nó thất bại lịch sự, với những con số.

Ba thói quen thực sự bảo vệ bạn thì rẻ:

  1. Biết các đại lượng trung gian của bạn sống ở đâu, không chỉ đầu vào và đầu ra. Đầu vào (giá ~10⁴) và đầu ra (một WMA ~10⁴) đều nằm thoải mái bên trong khoảng chính xác của fp32. Thảm họa hoàn toàn nằm trong một đại lượng trung gian ẩn (S2 ~10¹⁴) mà cả API lẫn các kiểu (types) đều không làm cho thấy được. Trước khi tin bất kỳ phép rút gọn fp32 nào, hãy hỏi tổng riêng phần lớn nhất đạt tới đâu — và nếu nó vượt qua ~10⁷, hãy đổi công thức.
  2. Ưu tiên các công thức giữ độ lớn có giới hạn. Tích chập trực tiếp thay vì prefix-sum; cửa sổ cục bộ thay vì quét toàn cục; định tâm (centering)/lấy hiệu trước khi cộng chứ không phải sau. Lớn-rồi-triệt-tiêu là mẫu-phản-diện (anti-pattern). Thuật toán đúng thường là thuật toán trông tệ hơn về mặt tiệm cận trên giấy nhưng không bao giờ chế tạo ra một đại lượng mà nó phải triệt tiêu đi.
  3. Xác thực so với một nhà tiên tri fp64 qua một bất biến rời rạc. Đừng so sánh các đường cong; hãy so sánh cái gì đó được lượng tử hóa và ở hạ nguồn — số lượng giao dịch, số điểm giao, các sự kiện thay đổi vị thế. Một bất biến rời rạc biến một sai số 211× âm thầm thành một lỗi khẳng định (assertion) gào thét, và biến một sai số 8×10⁻⁷ chấp nhận được thành một chênh lệch nhỏ, có giới hạn, giải thích được. Đây là cùng một kỷ luật như phép kiểm tra dịch một-bar để phát hiện thiên lệch nhìn-trước (look-ahead bias): một chẩn đoán rẻ tiền biến một thất bại vô hình thành một thất bại hữu hình.

Không có gì trong đây là giải tích số học kỳ lạ. Đó là vệ sinh thông thường của việc không tin một backtest nhanh cho tới khi một backtest chậm, đáng tin cậy đã phê chuẩn nó — mở rộng tới cái nơi duy nhất mà ngôn ngữ ngừng cảnh báo bạn rằng độ chính xác đã âm thầm rớt mười hai bậc độ lớn.

Những điều rút ra

Cú xoay một dòng: cumsum(j·price) bị gạch bỏ ở sai số 211×, mx.conv1d bên cạnh nó ở 8×10⁻⁷ — cùng một WMA, phép tổng khác nhau

  1. GPU của Apple không có float64 — mọi con số GPU trong backtest của bạn là fp32. Số nguyên chỉ chính xác tới ~1,6×10⁷ và độ chính xác là ~1,2×10⁻⁷. Không có cờ, không có dự phòng. Phần lớn một backtest sống sót qua điều này; đúng một phép tính thường thì không.
  2. WMA bằng prefix-sum là cái bẫy. cumsum(j · price) leo tới ~10¹⁴, bảy bậc vượt qua trần chính xác của fp32, và việc khôi phục một cửa sổ buộc bạn trừ hai con số như vậy mà sai số làm tròn của chúng (±8×10⁶) đã lấn át đáp án. Sai số tương đối lớn nhất đo được: 211×. Nó không bao giờ crash — nó trả về rác trông hợp lý.
  3. Cách sửa là một phép tổng khác, không phải nhiều bit hơn. Một tích chập cửa sổ trực tiếp (mx.conv1d) giữ mọi tổng riêng phần gần thang giá, nên fp32 giữ được bảy chữ số trung thực: sai số tương đối 8,2×10⁻⁷, và nhanh hơn 55,9× so với numba đơn luồng. Bạn không thể mua fp64 trên Metal, và bạn không cần.
  4. Xác minh bằng một bất biến rời rạc, không bao giờ bằng đường cong. Sự tương đương về số lượng giao dịch đã bắt được nó: conv1d fp32 bất đồng với fp64 ở 90 trong 479.016 giao dịch (0,019%), tất cả đều là các điểm giao ranh giới, tất cả đều thấp hơn xa các ngưỡng crash — dấu hiệu của một phương pháp đúng, khác hẳn một cách không thể nhầm lẫn với một sai số 211×. "Tỷ lệ các tổ hợp khác biệt" là một thước đo mồi nhử; hãy đo khác biệt bao nhiêu.
  5. Lượt quét đầy đủ làm 779 tổ hợp/s — 2.796× so với đường cơ sở pandas, 3,2× so với toàn bộ nhóm CPU 12 lõi — nhưng chiến thắng đầu-cuối (19,6× so với numba tuần tự) nhỏ hơn chiến thắng chỉ-tích-chập (55,9×) bởi vì việc trích xuất giao dịch trên CPU giờ là nút thắt cổ chai. Qua một điểm nào đó, tốc độ là điều phối, không phải số học.

Bản chuyển đổi GPU nhanh hơn 2.796× và, ở phiên bản chạy được đầu tiên của nó, hoàn toàn sai — và hai sự thật đó chẳng liên quan gì tới nhau. Tốc độ là thật. Rác là một đại lượng trung gian 10¹⁴ ẩn mà fp32 không thể giữ và không thông báo lỗi nào sẽ đề cập. Nếu một backtest trở nên nhanh hơn đáng kể mà các con số vẫn trông ổn, đó không phải là sự xác nhận. Trên Metal, "trông ổn" chính xác là cái mà một sai số tương đối 211 trông giống.

Đây là nấc GPU của bậc thang mà loạt bài này đã và đang leo: bậc thang tốc độ của backtest-engine, thuế IPC của đa tiến trình, phân loại nhìn-trước của các rò rỉ, và thiết kế hàm mục tiêu quyết định "tốt" thực ra nghĩa là gì. Tốc độ vô giá trị nếu nó nhanh trong việc tính ra con số sai.

Tuyên bố miễn trừ trách nhiệm: Thông tin được cung cấp trong bài viết này chỉ nhằm mục đích giáo dục và thông tin, không cấu thành lời khuyên về tài chính, đầu tư hoặc giao dịch. Giao dịch tiền mã hóa tiềm ẩn rủi ro thua lỗ đáng kể.

Tác Giả

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

Đi Trước Thị Trường

Đăng ký nhận bản tin của chúng tôi để có những thông tin chuyên sâu độc quyền về AI trading, phân tích thị trường và các cập nhật nền tảng.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất kỳ lúc nào.