Один и тот же бэктест на Python печатает другой коэффициент Шарпа на новом процессоре
Мы запустили один и тот же код на Python с одной и той же версией numpy на двух машинах и получили
разные числа: у коэффициента Шарпа меняются последние знаки. Причина не в коде и не в данных. На
процессорах с AVX-512 numpy считает np.log и np.exp собственными векторными процедурами, и примерно
каждый двадцатый результат округляется иначе. Оба результата точны, просто биты разные. Ниже — замеры,
скрипт для проверки своих машин и способ получить одинаковые числа везде. Сразу предупреждение:
самый распространённый рецепт в numpy 2.4 перестал работать, причём молча.
Что мы увидели
Проверяя кейс оптимизатора vn.py, мы запустили встроенный оптимизатор vn.py на ряде из 100 000 баров на двух машинах. На обеих стоял numpy 2.5.3:
- AMD Threadripper PRO 5975WX (Zen 3, AVX2);
- AMD Ryzen 9 7950X3D (Zen 4, AVX-512).
Параметры, их порядок, число сделок, доходности и просадки совпали, а два из десяти напечатанных коэффициентов Шарпа — нет:
AVX2: fast_window=31 slow_window=24 sharpe_ratio=0.61673083389321237 ...
AVX-512: fast_window=31 slow_window=24 sharpe_ratio=0.61673083389321259 ...
Минимальное воспроизведение
numpy_log_cpu.py строит 10 млн отношений, близких к 1, — так
выглядит balance[t] / balance[t-1]. Строит только через + и *, поэтому вход на всех машинах один
и тот же до бита. Затем применяет np.log и np.exp и считает годовой коэффициент Шарпа по десяти
годам дневных логарифмических доходностей:
Zen 3 (AVX2): numpy 2.5.3 | CPU features used: X86_V3
input hash: cdbe5b3e6769854d
np.log hash: 758f5856e4acbbb5
np.exp hash: 1e0b3fc690120efb
Sharpe ratio: 0.92814377921504698
Zen 4 (AVX-512): numpy 2.5.3 | CPU features used: X86_V3, X86_V4, AVX512_ICL
input hash: cdbe5b3e6769854d
np.log hash: da531faf4c71aae5
np.exp hash: 82f7841235e8b1b8
Sharpe ratio: 0.9281437792150472
Насколько велика разница
Каждое различающееся значение мы сверили с результатом, посчитанным с 50 значащими цифрами:
| Функция | Различаются | Разница | Наибольшая ошибка AVX2 (glibc) | Наибольшая ошибка AVX-512 (numpy) |
|---|---|---|---|---|
np.log |
460 676 из 10 млн (4,61%) | всегда 1 ulp | 0,502 ulp | 0,620 ulp |
np.exp |
449 797 из 10 млн (4,50%) | всегда 1 ulp | 0,506 ulp | 0,719 ulp |
ulp — единица последнего разряда 64-битного числа. Там, где машины расходятся, результат AVX2 ближе к точному в 99% случаев, но оба отстоят от него меньше чем на один ulp. numpy сознательно допускает расхождение в несколько ulp между своими реализациями ради скорости (numpy issue 23523).
Для торговых решений один ulp далеко ниже шума. Но коэффициент Шарпа складывает тысячи доходностей, и сдвиг на один ulp в части из них меняет его последние знаки. Это ломает всё, что сравнивает результаты точно:
- регрессионные тесты и кэши результатов по хэшу;
- проверки «новая версия совпадает со старой»;
- аудит, который повторяет бэктест на другой машине;
- и любую команду, у которой ноутбук и сервер расходятся на одном и том же прогоне.
Как получить одинаковые числа на всех машинах
numpy позволяет выключить AVX-512-процедуры переменной окружения. В numpy 2.4 названия возможностей
сменились: AVX-512 теперь группа X86_V4, а старые названия игнорируются с предупреждением, которое
Python по умолчанию не показывает. Самый частый рецепт из интернета,
NPY_DISABLE_CPU_FEATURES="AVX512F,AVX512CD,AVX512_SKX,...", на numpy 2.3 ещё работает, а на 2.4 и
новее молча ничего не делает.
Эта строка перечисляет оба написания. На машине с Zen 4 она дала биты AVX2 на numpy 2.2.6, 2.3.5, 2.4.4 и 2.5.3:
NPY_DISABLE_CPU_FEATURES="X86_V4 AVX512_ICL AVX512_SPR AVX512F AVX512CD AVX512_SKX AVX512_CLX AVX512_CNL" \
python your_backtest.py
С ней машина на Zen 4 напечатала хэши Zen 3 и коэффициент Шарпа 0.92814377921504698.
- Проверить, не проигнорировал ли numpy какое-то название:
python -W always -c "import numpy". Проигнорированное название печатаетImportWarningс объяснением. numpy.show_runtime()показывает, какие возможности используются.
Цена — скорость: np.log по 10 млн значений занял на этой машине 30–33 мс вместо 11–12 мс. Для
большинства бэктестов это незаметно на фоне остального прогона.
Как с этим работает TAKT
Наши сборки печатают один и тот же результат на всех процессорах, где мы их проверяли. «Совпадает с оригиналом» мы определяем для платформы из паспорта замера: если ваша эталонная машина с AVX-512, то эталоном служит её вывод. Если вам нужен бэктест, числа которого вообще не зависят от машины, такое мы тоже умеем. Бесплатная проверка скорости.
Измерено 9 октября 2026 года на numpy 2.5.3 (Python 3.12 на Zen 3, Python 3.13 на Zen 4).
Исправление, 9 октября 2026. В первой версии заметки говорилось, что AVX-512-версия np.log в
numpy теряет до 29 бит возле x = 1. Это была наша ошибка измерения: вход строился через np.exp,
который тоже различается между машинами, поэтому мы сравнивали логарифмы немного разных чисел. На
одинаковом входе разница не больше одного ulp. Теперь скрипт строит вход без np.exp.
По желанию. Включая комментарии, вы соглашаетесь на использование сервиса TAKT (ABX DEVELOPMENT LLP) на comments.taktcycles.com, доставляемого через Cloudflare, и его подключения к GitHub. Получающие запросы сервисы получают данные соединения, включая ваш IP-адрес. Наш сервис получает публичный адрес и описание заметки. Функция обращается к сохранённому входу в комментарии и, если вы войдёте, запоминает вход в локальном хранилище этого сайта между посещениями, без установленного срока автоматического удаления. Читать комментарии можно без входа. Публикации и реакции общедоступны на GitHub; выбор предпросмотра также отправляет в GitHub ещё не опубликованный черновик. Отозвать разрешение можно кнопкой «Отключить комментарии и забыть вход». Подробнее, включая международные передачи, — в Privacy Notice и Recipients Schedule. Саму заметку можно читать без включения комментариев.
Обсудить заметку на GitHub. Комментарии — в открытом обсуждении GitHub Discussions. Ссылка ведёт на GitHub, где действуют его условия и политика конфиденциальности; читать можно без аккаунта, писать — с аккаунтом GitHub.