A Armadilha de Precisão da GPU: Como um Backtest em fp32 na Apple Metal Retorna Lixo Silenciosamente
Parte da série "Backtests Sem Ilusões".
Portamos nosso backtest de varredura de parâmetros para a GPU e ele ficou 2,796× mais rápido. Os números que ele retornava eram plausíveis. Também eram, na primeira versão funcional, lixo completo — errados por um fator de duzentas vezes — e nada travou, nada avisou, nada pareceu errado.
Esta é a armadilha que torna o backtesting em GPU no Apple Silicon perigoso de um jeito que o código em CPU não é: a Metal não tem float64. Todo número que seu backtest toca em uma GPU da Apple é um float de 32 bits, quer você tenha pedido isso ou não. E a forma mais tentadora de vetorizar uma média móvel — o truque de soma de prefixos O(n) ao qual todo quant preocupado com performance recorre — é exatamente a formulação que o fp32 não sobrevive em escala de preço. Ele não gera erro. Roda a toda velocidade e entrega uma curva de patrimônio com aparência plausível, construída sobre uma média móvel que está errada por um fator de 211.
A correção é a parte interessante, porque não é "usar mais bits" (você não pode) e não é "ter mais cuidado" (o código ingênuo já é cuidadoso). A correção é calcular a mesma quantidade matemática por meio de uma soma diferente — uma convolução direta por janela — que mantém cada valor intermediário pequeno o suficiente para o fp32 representar com exatidão. Mesma WMA, mesmo resultado até sete algarismos significativos, e 55.9× mais rápida que o numba de thread única na CPU. Este artigo é a autópsia: por que a Metal força o fp32 sobre você, onde exatamente a formulação óbvia estoura, por que a correta não estoura, e como provamos — contando trades, não olhando curvas de relance — que a versão rápida e a versão honesta em fp64 concordam.
Todos os números aqui foram medidos em um Apple M2 Max, 150,000 barras × 80 combinações de parâmetros, melhor-de-3, a partir de scripts/bench_param_sweep.py do repositório (o método M5 GPU, commit 97eadaf), scripts/engine_multitf_gpu.py (04d71e8), e das notas de design em scripts/GPU_NOTES.md.
A Metal não tem float64

Em uma CPU, um backtest em Python/numpy roda em precisão dupla por padrão. float64 te dá uma mantissa de 52 bits: inteiros são representados com exatidão até 2⁵³ ≈ 9×10¹⁵, e a precisão relativa é de cerca de 1.1×10⁻¹⁶. Você quase nunca pensa nisso, porque em escala de preço — um fechamento de BTC em torno de 30,000, uma soma cumulativa chegando a 10¹⁴ — o fp64 tem margem de sobra.
A GPU da Apple não oferece essa margem, porque simplesmente não oferece float64 de jeito nenhum. A Metal Shading Language não tem tipo double; o MLX, o PyTorch-MPS e todo outro framework voltado para Apple Silicon herda isso. Não existe flag para ativá-lo nem um fallback lento-mas-correto. Se sua computação toca a GPU, ela acontece em fp32:
- Mantissa: 23 bits (24 com o bit inicial implícito). Inteiros são exatos apenas até 2²⁴ = 16,777,216 ≈ 1.6×10⁷.
- Precisão relativa: ~1.2×10⁻⁷. Aproximadamente sete algarismos decimais significativos, e não mais que isso.
Esse teto de 1.6×10⁷ é a história inteira. Parece generoso — dezesseis milhões — até você perceber que um backtest constrói rotineiramente quantidades intermediárias bem maiores que isso, e no momento em que um intermediário ultrapassa 1.6×10⁷, o fp32 nem consegue mais representar inteiros consecutivos, quanto mais a estrutura fracionária de que você precisa. A precisão não se degrada suavemente; ela despenca de um precipício, e o precipício fica em um número que seus dados ultrapassam sem pestanejar.
A armadilha é que o fp32 quase sempre funciona bem. A maior parte de um backtest — preços, retornos, PnL, Sharpe — vive confortavelmente na faixa em que sete dígitos bastam de sobra. Então a versão portada de forma ingênua funciona, passa em um smoke test, produz uma saída com aparência sensata. A falha fica localizada em exatamente uma operação, o único lugar onde um intermediário estoura além de 10⁷ — e essa é justamente a operação que todo mundo vetoriza primeiro.
A formulação tentadora: WMA em uma única varredura de soma de prefixos
Nossa estratégia se apoia nas médias móveis de Hull. Uma HMA é composta por três médias móveis ponderadas combinadas; a variante HMA3 usa quatro. Uma média móvel ponderada com um kernel linear sobre uma janela de comprimento p é
Ao varrer milhares de combinações de parâmetros sobre 150k barras, as convoluções de WMA são o custo. Então o instinto — o instinto correto, em uma CPU — é tornar cada WMA O(n) em vez de O(n·p) usando somas de prefixos. Você pré-computa duas somas cumulativas uma única vez,
e então a soma linearmente ponderada de qualquer janela se reduz a um punhado de diferenças e deslocamentos de índice de S1 e S2. Sem loop por janela, sem redução por janela — duas passadas de cumsum e a matriz inteira de WMAs cai pronta da aritmética vetorial. Isso vetoriza lindamente, mapeia perfeitamente para a primitiva de parallel-scan de uma GPU, e em fp64 é exatamente correto.
É também a pior coisa possível que você pode fazer em fp32, e o motivo está escondido em S2.
S2 = mx.cumsum(j * price) # j is the global bar index: 0, 1, 2, ... , n-1
O termo j · price é o problema. Com j chegando até 150,000 e price em torno de 30,000, só o último termo já vale 4.5×10⁹, e S2 é a soma corrente de 150,000 termos desse tamanho. Ela não permanece em escala de preço. Ela sobe para o território onde o fp32 já parou de conseguir contar.
Onde ela estoura: a aritmética da armadilha

Vamos colocar as ordens de grandeza lado a lado, porque é aí que mora todo o problema.
S2 = cumsum(j · price) chega a aproximadamente price · n²/2 ≈ 30,000 · (150,000)²/2 ≈ 3×10¹⁴. Vamos chamar isso de ~10¹⁴. Agora lembre do teto de inteiro exato do fp32: ~1.6×10⁷. A soma corrente ultrapassa o último inteiro que o fp32 consegue representar com exatidão em sete ordens de grandeza.
O que isso significa na prática? Perto de 10¹⁴, o intervalo entre dois números fp32 representáveis — uma unidade na última casa, o ULP — é de cerca de 2²³ ≈ 8×10⁶. Então, uma vez que S2 está na faixa de 10¹⁴, ela só é conhecida com uma margem de ±8 milhões. Todo valor que ela armazena foi arredondado para o múltiplo mais próximo de ~8×10⁶.
Agora observe o que a recuperação da WMA faz com isso. Para extrair a soma ponderada de uma única janela, você subtrai dois valores vizinhos de S2 (mais correções de S1). Esses dois valores de S2 são cada um ~10¹⁴, cada um carregando ±8×10⁶ de ruído de arredondamento. A diferença verdadeira entre eles — a quantidade de janela que você realmente quer — corresponde, após a normalização, a uma WMA da ordem do próprio preço, ~3×10⁴. Então a aritmética é:
Isso é cancelamento catastrófico em sua forma mais pura: o erro de arredondamento de cada operando (±8×10⁶) é maior que a própria resposta que você está tentando recuperar. O sinal é menor que o piso de ruído dos números dos quais ele é extraído. Não é que você perde alguns dígitos — você perde todos eles, e o que volta é dominado pelo arredondamento acumulado do cumsum.
A consequência medida, tirada de GPU_NOTES.md: para uma WMA calculada dessa forma em 150k barras a um preço de ~30,000, o erro relativo máximo contra o fp64 chega a ~211. Não 211 por cento — 211×. A média móvel calculada pode estar duas ordens de grandeza distante da verdadeira. E aqui está a parte que a torna uma armadilha em vez de um simples bug: ela roda até o fim e retorna números finitos e plausíveis. Sem overflow para infinito, sem NaN, sem exceção. Uma média móvel errada por 211× ainda parece uma média móvel — é suave, é finita, está mais ou menos na faixa certa nas barras em que o cancelamento acontece de ser leve — então ela passa ilesa por qualquer verificação de sanidade que não seja uma comparação direta contra uma referência confiável. Você obtém um backtest completo, uma curva de patrimônio completa, um conjunto completo de parâmetros "ótimos", tudo construído sobre um indicador que é ficção.
A correção não é mais precisão — é uma soma diferente

O reflexo, assim que você vê o erro, é buscar mais precisão — acumular em fp64, ou usar soma compensada (de Kahan). Na Metal, a primeira opção simplesmente não está disponível. Mas você não precisa de nenhuma das duas, porque o problema nunca foi o número de bits. O problema era a formulação. O truque da soma de prefixos fabrica intermediários em escala de 10¹⁴ e depois os subtrai de volta para baixo; as magnitudes que ele cria são um artefato do algoritmo, não da resposta. Escolha uma formulação que nunca as crie e o fp32 fica tranquilo.
Essa formulação é a própria definição: uma convolução direta por janela. Em vez de duas somas cumulativas globais, deslize o kernel linear de comprimento p ao longo da série e some no próprio lugar. Cada saída é uma soma de no máximo p ≈ 200 termos, e cada termo é weight × price, com os pesos normalizados para somar 1 — então cada termo é da ordem de price / p, toda soma parcial permanece em torno da escala de preço (~3×10⁴), e nenhum intermediário jamais chega a seis ordens de grandeza de distância do teto do fp32. Não há nada para cancelar porque nada nunca inflou.
No MLX isso é uma única primitiva — mx.conv1d — que é exatamente o que as GPUs são construídas para fazer rápido:
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)
Mesma WMA, matematicamente idêntica à versão por soma de prefixos e ao vec_wma/nb_wma em fp64 da CPU. Mas agora o erro relativo máximo medido contra o fp64 é 8.2×10⁻⁷ — bem no piso de ruído do fp32, de ~1.2×10⁻⁷, sete algarismos significativos de concordância. A formulação que parece mais lenta no papel (O(n·p) em vez de O(n)) é a única que é correta — e, como é uma convolução densa que a GPU paraleliza simultaneamente entre barras e janelas, ela também é absurdamente rápida. Fomos de um erro relativo de 211 para 8×10⁻⁷ mudando como somamos, não em quantos bits somamos.
Duas notas práticas decorrem de fazer as coisas dessa forma. Primeiro, o MLX não propaga NaNs por um conv1d da forma como o numpy faz, então a região de aquecimento (as primeiras p−1 barras, onde uma média por janela não está definida) não pode ser marcada com NaN na GPU. Não precisamos que seja: o início válido de cada série é conhecido analiticamente, os prefixos inválidos são preenchidos com zeros que nunca são lidos, e o preenchimento com NaN é restaurado na CPU depois — bit a bit a mesma máscara de validade das versões vetorizada e numba. Segundo, toda a varredura compartilha uma única série cand_close e reutiliza intensamente as janelas entre as combinações, então um único conv1d em lote com muitos canais de saída calcula toda WMA única de que a varredura precisa em uma só chamada de GPU, materializada com um único mx.eval().
Provando que você não caiu na armadilha: paridade por contagem de trades
Aqui está a pergunta incômoda que a seção anterior deveria levantar: se uma WMA errada por 211× ainda parece uma WMA, como você sabe que a versão com 8×10⁻⁷ está de fato certa, e não apenas errada de um jeito mais sutil? Você não pode julgar isso de relance. Você precisa de um invariante que uma parte discreta e a jusante do pipeline exponha — e um backtest te entrega um perfeito: os trades.
Os outros métodos GPU na escada (M0–M4) rodam inteiramente em fp64, então nós os submetemos a um assert de equivalência estrito — contagens de trades idênticas, PnL batendo com atol=1e-6. O método M5 GPU em fp32 não pode passar nisso por construção, e afrouxar silenciosamente o assert para todo mundo, só para acomodá-lo, seria exatamente o tipo de desonestidade que esta série existe para combater. Então o M5 ganha seu próprio relatório de paridade quantitativa, report_equiv_fp32, que compara seus trades extraídos contra a referência em fp64.
Vale a pena nomear com precisão o mecanismo de qualquer discordância residual, porque não é a catástrofe do cancelamento — é o arredondamento fp32 comum e minúsculo que você esperaria. A estratégia dispara no cruzamento de duas médias de Hull, h vs h3. Um erro relativo de ~1×10⁻⁶ em um indicador a um preço de ~30,000 é uma oscilação absoluta de ~0.03. Na vasta maioria das barras, as duas curvas estão mais distantes que isso e o cruzamento é inequívoco. Mas em uma barra limítrofe — onde h − h3 já está a menos de 0.03 de zero — essa oscilação pode inverter o sinal da comparação, deslocando um cruzamento em uma única barra, adicionando ou removendo um trade.
É por isso que "fração de combinações que diferem" é uma métrica de saúde inútil, e nossa primeira verificação de paridade se envergonhou ao usá-la. Em 150k barras, cada combinação tem milhares de cruzamentos, então pelo menos uma barra limítrofe aparece em quase toda combinação — 37 de 80 combinações "diferiram", o que soa alarmante e não significa nada. A métrica que importa é por quanto:
- Delta de PnL em todas as 80 combinações: max |Δ| = 1.843 pontos percentuais, max relativo = 1.25×10⁻²; limiar de colapso 5 p.p.
- Deriva na contagem de trades por combinação: max |Δn| = 4 trades de milhares, max relativo = 2.5×10⁻³; limiar de colapso 1%.
- No agregado: 90 trades deslocados de 479,016 — 0.019%.
Noventa trades em quase meio milhão, cada um deles um cruzamento limítrofe empurrado por uma oscilação de arredondamento menor que um tick de preço, nada disso nem perto dos limiares de colapso. Essa é a assinatura de um método fp32 que é correto — discordância pequena, limitada, explicável — e é um animal completamente diferente de um erro relativo de 211. Os limiares existem para pegar uma formulação quebrada se disfarçando de "bem, é só fp32 mesmo"; os deltas reais vêm uma ordem de grandeza abaixo deles. A contagem de trades é o oráculo que a curva de patrimônio se recusou a ser.
O retorno, e onde a GPU para de ajudar
Com a correção estabelecida, vale a pena declarar a velocidade — e então qualificá-la honestamente, porque a vantagem da GPU não é uniforme ao longo do pipeline.
Nas convoluções de WMA puras isoladamente — a operação que todo o método existe para acelerar — o lote conv1d em fp32 roda 55.9× mais rápido que o numba de thread única, com aquele erro relativo de 8.2×10⁻⁷. Esse é o número limpo, comparável ponto a ponto, de GPU contra CPU: mesma matemática, uma thread de código CPU compilado contra a GPU Metal.
Mas uma varredura não é feita só de convoluções. Uma vez que as matrizes HMA/HMA3 são calculadas na GPU, os trades ainda precisam ser extraídos — uma varredura O(n) sobre os cruzamentos de cada combinação — e isso fazemos na CPU em fp64, reaproveitando a semântica exata de trades dos outros métodos em vez de reimplementá-la na GPU. O número timed() de ponta a ponta inclui tudo: o aquecimento do kernel é excluído (simetricamente à exclusão da compilação do numba), mas a transferência GPU→CPU e a extração de trades na CPU estão incluídas. Em 150k barras × 80 combinações, melhor-de-3, M2 Max:
| Method | Wall | Speedup vs baseline | combos/s |
|---|---|---|---|
| M0 pandas + loop em Python* | 287.08s | 1.0× | 0.3 |
| M1 numpy vetorizado | 3.14s | 91.5× | 25.5 |
| M2 numba (serial) | 2.02s | 142.3× | 39.7 |
| M3 multiprocessamento + vetorizado | 0.50s | 570.2× | 158.9 |
| M4 multiprocessamento + numba (12 núcleos) | 0.33s | 882.5× | 245.9 |
| M5 MLX GPU (fp32) | 0.10s | 2796.0× | 779.2 |
*M0 extrapolado a partir de uma amostra uniforme de 5 combinações.
O M5 com o motor completo faz 779 combos/s — 2,796× sobre a linha de base do pandas, 19.6× sobre o numba serial, e 3.2× sobre o pool inteiro de 12 núcleos de CPU rodando numba (M4). Uma única GPU supera todos os núcleos de CPU que a máquina tem, três vezes.
Agora o qualificador honesto: note que a vantagem de ponta a ponta da GPU (19.6× sobre o M2) é menor que a vantagem só de convolução (55.9× sobre o numba). Essa diferença é a lei de Amdahl chegando no horário certo. A GPU aniquila as convoluções de forma tão completa que elas deixam de ser o gargalo; o que sobra — a extração de trades O(n) na CPU, que a GPU em nada acelerou — agora domina o tempo total de execução do M5. Esta é a mesma lição a que os artigos speed-ladder e IPC-tax desta série sempre chegam: passado certo ponto, o ganho não é "fazer a parte rápida ficar mais rápida", é orquestração — onde os dados moram, qual estágio agora é serial, quanto você está pagando para mover dados entre o dispositivo e o host. Perseguir um hipotético M6 que empurrasse a extração de trades para dentro de um kernel Metal customizado só recuperaria a fatia cada vez menor da CPU, e é por isso que não o construímos.
A lição geral: lixo numérico silencioso é o padrão
Dando um passo atrás das médias de Hull e do MLX, porque a armadilha se generaliza muito além deste indicador específico.
O discurso sedutor do backtesting em GPU é "uma grande matriz só": empilhe cada combinação de parâmetros em um tensor, rode a varredura inteira como um punhado de operações densas de array, e deixe o hardware devorar isso. Esse discurso é real — os ganhos de velocidade acima são reais. Mas ele muda silenciosamente o regime numérico debaixo de você, e a mudança é invisível no código. Na CPU, seus padrões te protegiam: fp64, propagação de NaN, um cumsum que podia rodar até 10¹⁴ sem que ninguém notasse. Mova a mesma expressão de array para a Metal e você está em fp32, com um teto rígido de inteiro de 1.6×10⁷, e a linha de código idêntica — cumsum(j * price) — vai de exata a lixo. Nada na sintaxe te avisa. O compilador fica feliz. A saída é finita e plausível. O fp32 não falha alto e claro; ele falha educadamente, com números.
Os três hábitos que realmente te protegem são baratos:
- Saiba onde seus intermediários moram, não só suas entradas e saídas. As entradas (preços ~10⁴) e as saídas (uma WMA ~10⁴) estavam ambas confortavelmente dentro da faixa exata do fp32. O desastre estava inteiramente em um intermediário escondido (
S2~10¹⁴) que nem a API nem os tipos tornavam visível. Antes de confiar em qualquer redução em fp32, pergunte a que valor a maior soma parcial chega — e se ela ultrapassar ~10⁷, mude a formulação. - Prefira formulações que mantêm as magnitudes limitadas. Convolução direta em vez de somas de prefixos; janelas locais em vez de varreduras globais; centralizar/diferenciar antes de somar, em vez de depois. Inflar-e-depois-cancelar é o antipadrão. O algoritmo correto costuma ser aquele que parece pior assintoticamente no papel, mas nunca fabrica uma quantidade que depois precisa cancelar.
- Valide contra um oráculo em fp64 por meio de um invariante discreto. Não compare curvas; compare algo quantizado e a jusante — contagem de trades, número de cruzamentos, eventos de mudança de posição. Um invariante discreto transforma um erro silencioso de 211× em uma falha de assert gritante, e transforma um erro aceitável de 8×10⁻⁷ em um delta pequeno, limitado e explicável. Essa é a mesma disciplina do teste de deslocamento de uma barra para look-ahead bias: um diagnóstico barato que converte uma falha invisível em uma visível.
Nada disso é análise numérica exótica. É a higiene comum de não confiar em um backtest rápido até que um lento e confiável o tenha ratificado — estendida ao único lugar onde a linguagem para de te avisar que a precisão caiu silenciosamente doze ordens de grandeza.
Conclusões

- A GPU da Apple não tem float64 — todo número em GPU no seu backtest é fp32. Inteiros são exatos apenas até ~1.6×10⁷ e a precisão é de ~1.2×10⁻⁷. Não há flag, não há fallback. A maior parte de um backtest sobrevive a isso; exatamente uma operação, em geral, não.
- A WMA por soma de prefixos é a armadilha.
cumsum(j · price)sobe a ~10¹⁴, sete ordens de grandeza além do teto exato do fp32, e recuperar uma janela obriga você a subtrair dois números desses cujo erro de arredondamento (±8×10⁶) já ofusca a resposta. Erro relativo máximo medido: 211×. Nunca trava — devolve lixo plausível. - A correção é uma soma diferente, não mais bits. Uma convolução direta por janela (
mx.conv1d) mantém toda soma parcial perto da escala de preço, então o fp32 sustenta sete dígitos honestos: erro relativo de 8.2×10⁻⁷, e 55.9× mais rápido que o numba de thread única. Você não consegue comprar fp64 na Metal, e não precisa disso. - Verifique com um invariante discreto, nunca com a curva. A paridade de contagem de trades pegou isso: o conv1d em fp32 discordou do fp64 em 90 de 479,016 trades (0.019%), todos cruzamentos limítrofes, todos bem abaixo dos limiares de colapso — a assinatura de um método correto, inconfundivelmente diferente de um erro de 211×. "Fração de combinações que diferem" é uma métrica-armadilha; meça por quanto.
- A varredura completa faz 779 combos/s — 2,796× sobre a linha de base do pandas, 3.2× sobre o pool inteiro de 12 núcleos de CPU — mas o ganho de ponta a ponta (19.6× sobre o numba serial) é menor que o ganho só de convolução (55.9×) porque a extração de trades na CPU agora é o gargalo. Passado certo ponto, velocidade é orquestração, não aritmética.
A migração para GPU ficou 2,796× mais rápida e, na primeira versão funcional, completamente errada — e os dois fatos não tinham nada a ver um com o outro. A velocidade era real. O lixo era um intermediário escondido de 10¹⁴ que o fp32 não conseguia sustentar e nenhuma mensagem de erro mencionaria. Se um backtest fica dramaticamente mais rápido e os números continuam parecendo bons, isso não é confirmação de nada. Na Metal, "parecer bom" é exatamente a cara de um erro relativo de 211.
Este é o degrau da GPU na escada que esta série vem subindo: a escada de velocidade do motor de backtest, a taxa de IPC do multiprocessamento, a taxonomia do look-ahead bias dos vazamentos, e o design da função objetivo que decide o que "bom" sequer significa. Velocidade não vale nada se é rápida calculando o número errado.
Authors
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.