Skip to content

Как честно мерить скорость

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.

Считать время руками не нужно — в стандартной библиотеке есть модуль, который сам делает разогрев, повторяет замер нужное число раз и показывает разброс.

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-10
Total: 0.120906694
Iters: 367799924
Warmup Total: 4.3e-08
Fastest Mean: 3.287295241529196e-10
Slowest Mean: 3.287295241529196e-10

Здесь всё, что нужно: среднее время одного вызова, сколько раз вызвали, сколько ушло на разогрев, и — самое полезное — быстрейшее и медленнейшее средние по партиям.

Обратите внимание на значения по умолчанию: один прогон на разогрев, не меньше 0,1 секунды суммарно, не больше 60 секунд. Их можно менять аргументами num_warmup_iters, min_runtime_secs, max_runtime_secs.

Как заставить компилятор считать по-настоящему

Заголовок раздела «Как заставить компилятор считать по-настоящему»

Первое, что приходит в голову, — подсунуть значение, которого при сборке не существует: длину списка аргументов, время, ввод пользователя. Идея разумная, но её недостаточно, и вот почему.

Возьмём цикл, где seed честно берётся из argv:

var total = 0
for 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 делает разогрев сам. Выбросы дают внешние помехи — соседи по машине, миграция процесса между ядрами, изменение частоты.

Раз уж курс для питонистов — вот сравнение на той же задаче: суммирование списка из 4096 чисел.

🐍 Python
total = 0
for i in range(len(data)):
total += data[i]
🔥 Mojo
var total = 0
for i in range(len(data)):
total += data[i]

Код совпадает почти дословно — разница только в var.

Одно число здесь было бы обманом, поэтому замер сделан на двух разных машинах, с одним и тем же Python 3.14 и одной и той же сборкой Mojo 1.0.0. Наносекунд на элемент, лучшее из трёх запусков:

МашинаPython 3.14MojoОтношение
Intel Xeon 2,8 ГГц, облачный контейнер28,91,3122×
AMD Ryzen 7 9700X12,90,6420×

Смотреть тут нужно на два разных факта.

Отношение устойчиво. Двадцать и двадцать два — по сути одно и то же число: обе реализации ускорились примерно одинаково при переезде на более быстрый процессор. Порядок «примерно в двадцать раз» пережил смену машины, и на это можно опираться.

Абсолютные числа — нет. Они различаются более чем вдвое. Любая фраза вида «Mojo делает это за 1,3 наносекунды» без указания машины не значит ничего.

Программа печатает не наносекунды — они у вас будут свои, — а вердикт о правдоподобии.

honest.mojo
# Два замера одного и того же цикла. Первый врёт, второй — нет.
#
# Программа печатает не сами наносекунды (они у вас будут свои),
# а вердикт: правдоподобен результат или нет.
from std.benchmark import run, keep
from 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 нс на операцию взят с большим запасом — это около сотой доли такта. Обычный скалярный код держится в районе одного такта, векторный доходит до десятых долей; сотую не пробивает ничто. Если ваш замер её пробил — измеряется не то, что вы думаете.

Перед тем как поверить своему замеру:

  1. Хватает ли тактов? Переведите время в такты. Меньше сотой доли такта на операцию — замера не было. (Меньше одного такта — нормально: бывает и десятая доля, если код векторный.)
  2. Есть ли в данных неизвестное при сборке? Если нет — компилятор всё посчитал сам.
  3. Не сворачивается ли формула? Суммы, произведения, многочлены оптимизатор берёт символически. Читайте память.
  4. Стоит ли keep() на результате? Без неё вычисление могут выбросить целиком.
  5. Собрано ли с оптимизацией? mojo run компилирует на лету; для серьёзных замеров пользуйтесь mojo build и меряйте бинарник.
  6. Взят ли минимум, а не первый запуск и не среднее?
  7. Названы ли условия рядом с числом: машина, размер данных, версия?

🎯 Проверь себя

Замер показал, что миллион сложений занял около 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 с нуля: как процессор считает по нескольку чисел за одну операцию — и как проверить замером, что это действительно произошло.

Примеры проверены на Mojo 1.1.0

Тексты курса — CC BY-NC-SA 4.0, код примеров — Apache 2.0