Как честно мерить скорость
This content is not available in your language yet.
Эта глава стоит первой в разделе не случайно. Дальше пойдут утверждения вида «так быстрее» — и вам понадобится способ их проверить. Своими руками, на своей машине, не поверив на слово ни автору, ни документации.
Заодно выяснится, что обмануть себя при замере гораздо легче, чем кажется.
Начнём с провала
Заголовок раздела «Начнём с провала»Померим самое простое — сумму миллиона чисел:
from std.time import perf_counter_ns
def main(): var start = perf_counter_ns() var total = 0 for i in range(1000000): total += i var elapsed = perf_counter_ns() - start print("сумма:", total, "| нс:", elapsed)сумма: 499999500000 | нс: 31
Пять запусков подряд дали 33, 32, 31, 24 и 32 — у вас будет своё число того же порядка. Важен именно порядок: миллион сложений примерно за 30 наносекунд. На процессоре с частотой 2,8 ГГц это около 87 тактов — то есть по одной десятитысячной такта на сложение.
Такого не бывает. Мы померили не цикл, а его отсутствие: компилятор
увидел, что все слагаемые известны заранее, посчитал сумму сам
и подставил в программу готовое число. Замерили мы работу двух вызовов
perf_counter_ns.
Инструмент: std.benchmark
Заголовок раздела «Инструмент: std.benchmark»Считать время руками не нужно — в стандартной библиотеке есть модуль, который сам делает разогрев, повторяет замер нужное число раз и показывает разброс.
from std.benchmark import run, keep
def work(): var total = 0 for i in range(1000): total += i * i keep(total)
def main() raises: run(work).print()Что печатается:
--------------------------------------------------------------------------------Benchmark Report (s)--------------------------------------------------------------------------------Mean: 3.287295241529196e-10Total: 0.120906694Iters: 367799924Warmup Total: 4.3e-08Fastest Mean: 3.287295241529196e-10Slowest Mean: 3.287295241529196e-10Здесь всё, что нужно: среднее время одного вызова, сколько раз вызвали, сколько ушло на разогрев, и — самое полезное — быстрейшее и медленнейшее средние по партиям.
Обратите внимание на значения по умолчанию: один прогон на разогрев,
не меньше 0,1 секунды суммарно, не больше 60 секунд. Их можно менять
аргументами num_warmup_iters, min_runtime_secs, max_runtime_secs.
Как заставить компилятор считать по-настоящему
Заголовок раздела «Как заставить компилятор считать по-настоящему»Первое, что приходит в голову, — подсунуть значение, которого при сборке не существует: длину списка аргументов, время, ввод пользователя. Идея разумная, но её недостаточно, и вот почему.
Возьмём цикл, где seed честно берётся из argv:
var total = 0for i in range(1000): total += (i + seed) * (i + seed)Замер: 0,0003 наносекунды на итерацию. Свернулось. Сумма квадратов — многочлен, у оптимизатора для неё замкнутая формула: неизвестное он вынес за скобки, а цикл посчитал символически. Неизвестное значение не помогло.
Работает другое — чтение из кучи:
def main() raises: var data = List[Int](capacity=4096) for i in range(4096): data.append((i * 2654435761) % 1000)
# вот это свернуть не выйдет var total = 0 for i in range(len(data)): total += data[i]Оптимизатор не протаскивает содержимое динамической памяти через границу замера — и цикл остаётся циклом.
Разброс
Заголовок раздела «Разброс»Пять серий по три запуска одного и того же собранного бинарника, наносекунд на элемент:
серия 1: 1.289 1.255 1.326серия 2: 1.267 1.252 1.859серия 3: 1.277 1.852 1.267серия 4: 1.277 1.893 1.255серия 5: 1.260 1.291 1.231От 1,231 до 1,893 — разброс в полтора раза на одной и той же программе. Достаточно поверить одному неудачному запуску, чтобы «доказать», что оптимизация ничего не дала.
Обратите внимание, куда попадают выбросы: во вторую позицию, во вторую,
в третью, в третью. Не в первую. Это важно — привычное объяснение
«первый запуск медленный из-за холодного кеша» здесь не работает:
std.benchmark делает разогрев сам. Выбросы дают внешние помехи —
соседи по машине, миграция процесса между ядрами, изменение частоты.
Сравнение с Python
Заголовок раздела «Сравнение с Python»Раз уж курс для питонистов — вот сравнение на той же задаче: суммирование списка из 4096 чисел.
total = 0for i in range(len(data)): total += data[i]var total = 0for i in range(len(data)): total += data[i]Код совпадает почти дословно — разница только в var.
Одно число здесь было бы обманом, поэтому замер сделан на двух разных машинах, с одним и тем же Python 3.14 и одной и той же сборкой Mojo 1.0.0. Наносекунд на элемент, лучшее из трёх запусков:
| Машина | Python 3.14 | Mojo | Отношение |
|---|---|---|---|
| Intel Xeon 2,8 ГГц, облачный контейнер | 28,9 | 1,31 | 22× |
| AMD Ryzen 7 9700X | 12,9 | 0,64 | 20× |
Смотреть тут нужно на два разных факта.
Отношение устойчиво. Двадцать и двадцать два — по сути одно и то же число: обе реализации ускорились примерно одинаково при переезде на более быстрый процессор. Порядок «примерно в двадцать раз» пережил смену машины, и на это можно опираться.
Абсолютные числа — нет. Они различаются более чем вдвое. Любая фраза вида «Mojo делает это за 1,3 наносекунды» без указания машины не значит ничего.
Всё вместе
Заголовок раздела «Всё вместе»Программа печатает не наносекунды — они у вас будут свои, — а вердикт о правдоподобии.
# Два замера одного и того же цикла. Первый врёт, второй — нет.## Программа печатает не сами наносекунды (они у вас будут свои),# а вердикт: правдоподобен результат или нет.
from std.benchmark import run, keepfrom std.sys import argv
comptime N = 4096
# Порог правдоподобия, с большим запасом.# Одна операция быстрее сотой доли такта не выполняется никогда: даже# векторный код на широком процессоре доходит лишь до десятых долей.# 0.003 нс — это примерно сотая такта на машине в 3 ГГц.comptime IMPOSSIBLE_NS = 0.003
def verdict(name: String, ns_per_item: Float64): if ns_per_item < IMPOSSIBLE_NS: print(name, "— подозрительно: столько не бывает, цикл свёрнут") else: print(name, "— правдоподобно")
def main() raises: # Значение из argv известно только при запуске. Само по себе это # свёртку не предотвращает (проверено), но лишним не будет. var unknown = len(argv())
# Главное — данные лежат в куче: содержимое динамической памяти # оптимизатор через границу замера не протаскивает. var data = List[Int](capacity=N) for i in range(N): data.append((i * 2654435761) % 1000 + unknown)
# ЛОВУШКА: и границы цикла, и все слагаемые известны при сборке. # keep() не спасает — он мешает выбросить результат, но не мешает # посчитать его заранее. def folded() {}: var total = 0 for i in range(N): total += i * i keep(total)
# ЧЕСТНО: числа читаются из кучи. Вот это свернуть не выйдет. def honest() {imm data}: var total = 0 for i in range(len(data)): total += data[i] keep(total)
# .min(), а не .mean(): шум только добавляет время, никогда не убавляет. var folded_ns = run(folded, max_runtime_secs=0.3).min() * 1e9 var honest_ns = run(honest, max_runtime_secs=0.3).min() * 1e9
verdict(String("константы "), folded_ns / Float64(N)) verdict(String("данные "), honest_ns / Float64(N))
print() print("вывод: мерить нужно то, что лежит в памяти, а не в исходнике")константы — подозрительно: столько не бывает, цикл свёрнут данные — правдоподобно вывод: мерить нужно то, что лежит в памяти, а не в исходнике
Порог 0,003 нс на операцию взят с большим запасом — это около сотой
доли такта. Обычный скалярный код держится в районе одного такта,
векторный доходит до десятых долей; сотую не пробивает ничто. Если ваш
замер её пробил — измеряется не то, что вы думаете.
Чек-лист
Заголовок раздела «Чек-лист»Перед тем как поверить своему замеру:
- Хватает ли тактов? Переведите время в такты. Меньше сотой доли такта на операцию — замера не было. (Меньше одного такта — нормально: бывает и десятая доля, если код векторный.)
- Есть ли в данных неизвестное при сборке? Если нет — компилятор всё посчитал сам.
- Не сворачивается ли формула? Суммы, произведения, многочлены оптимизатор берёт символически. Читайте память.
- Стоит ли
keep()на результате? Без неё вычисление могут выбросить целиком. - Собрано ли с оптимизацией?
mojo runкомпилирует на лету; для серьёзных замеров пользуйтесьmojo buildи меряйте бинарник. - Взят ли минимум, а не первый запуск и не среднее?
- Названы ли условия рядом с числом: машина, размер данных, версия?
🎯 Проверь себя
Замер показал, что миллион сложений занял около 30 наносекунд. Процессор — 2,8 ГГц. Что не так?
Всё. 30 нс на 2,8 ГГц — это около 87 тактов, то есть одна десятитысячная такта на сложение. Физически невозможно: компилятор посчитал сумму при сборке, а замерили мы два вызова таймера. Проверка «хватает ли тактов» ловит такие случаи мгновенно.
Вы поставили `keep(total)`, а результат всё равно неправдоподобно быстрый. Почему?
keep() защищает от удаления мёртвого кода, но не от свёртки
констант. Компилятор не выбросил вычисление — он выполнил его
заранее, и keep сохранил уже готовое число. Нужно, чтобы в данных
было что-то, неизвестное при сборке.
В цикл добавили `seed` из `argv`, но `total += (i + seed) * (i + seed)` всё равно считается мгновенно. В чём дело?
Сумма квадратов — многочлен, и у оптимизатора для неё есть замкнутая
формула: неизвестное он вынес за скобки, а цикл свернул символически.
Надёжнее мерить обращение к памяти — суммировать data[i], где
data заполнена во время работы.
Три запуска дали 1,679, 1,336 и 1,295 нс. Какое число писать в отчёт?
Наименьшее — 1,295. Шум только добавляет время и никогда не убавляет: посторонняя нагрузка может замедлить ваш код, но ускорить не может. Поэтому минимум ближе всего к истине, а первый запуск обычно испорчен холодным кешем.
Можно ли из «в двадцать раз быстрее Python» на суммировании списка заключить, что Mojo вообще в двадцать раз быстрее?
Нет. Это число про одну задачу — примитивный цикл по списку целых, то есть про самое слабое место интерпретатора. Если работа уходит в NumPy, Python не проиграет вовсе: там уже выполняется скомпилированный код. Цифру нельзя отрывать ни от задачи, ни от машины, ни от версии интерпретатора.
В статье написано: «Mojo в 30 раз быстрее Python». Чего не хватает, чтобы этому поверить?
Как минимум четырёх вещей: какая задача, какая машина, какая версия Python и как именно мерили. Мы проверили на двух машинах — отношение вышло 20 и 22, а абсолютные числа отличались вдвое. И отдельно: на одном и том же процессоре Python 3.10 против 3.14 дал разницу в 1,6 раза, так что сравнение со старым интерпретатором завышает результат само по себе.
Что дальше
Заголовок раздела «Что дальше»SIMD с нуля: как процессор считает по нескольку чисел за одну операцию — и как проверить замером, что это действительно произошло.
Тексты курса — CC BY-NC-SA 4.0, код примеров — Apache 2.0