Skip to content
...
Блог
К коллайдеру!

🤔 Задача

Нужно написать функцию, которая посчитает сумму всех чисел от $a до $b.

Например, если $a = 1 и $b = 5, то результат будет 1 + 2 + 3 + 4 + 5 = 15.

  1. Самое простое решение, которое приходит на ум:
    в цикле for итеративно прибавлять к $a значение $i, пока не упрёмся в $b, но слишком банально.
  2. Полёт фантазии не остановить и хочется решить задачу массивами:
    заполнить массив значениями от $a до $b и отправить в функцию sum().

К коллайдеру!

Сравнение решений

PHP очень хорош в синтетических тестах, обгоняет Python и всё такое. А с включённым JIT и некоторыми хитростями может даже догнать C++.

Мы не будем сейчас сравнивать PHP с другими языками, а попробуем просто сравнить эти два варианта решения задачи между собой.

Я рассуждаю так:

Решение на массивах будет медленнее цикла for, ведь дополнительные ресурсы уходят на вычисление хешей в хеш-таблице при создании массива, а ещё нужно больше памяти для промежуточных значений.

Давайте в этом убедимся: напишем функции и добавим атрибут #[Bench]#[Bench(array $callables, array $arguments = [], int $warmup = 1, int $calls = 1_000, int $iterations = 10)]Объявляет бенчмарк для сравнения производительности метода с альтернативными реализациями. на одну из функций.

php
#[Bench(
    callables: [
        'in_array' => [self::class, 'sumInArray'],
    ],
    arguments: [1, 5_000],
    calls: 100,
    iterations: 1,
)]
public static function sumInCycle(int $a, int $b): int
{
    $result = 0;
    for ($i = $a; $i <= $b; ++$i) {
        $result += $i;
    }

    return $result;
}

public static function sumInArray(int $a, int $b): int
{
    return \array_sum(\range($a, $b));
}

С помощью атрибута #[Bench]#[Bench(array $callables, array $arguments = [], int $warmup = 1, int $calls = 1_000, int $iterations = 10)]Объявляет бенчмарк для сравнения производительности метода с альтернативными реализациями. мы говорим Testo, что:

  • хотим сравнить производительность текущей функции (sumInCycle) с другой функцией (sumInArray);
  • в обе функции будут передаваться одинаковые аргументы: 1 и 5_000;
  • для замера времени каждая функция будет запущена 100 раз подряд (calls: 100).

Ещё раз делаем ставки и запускаем.

Summary:
+---+-------------+-------+-------+--------+-------------------+
| # | Name        | Iters | Calls | Memory | Avg Time          |
+---+-------------+-------+-------+--------+-------------------+
| 2 | current     | 1     | 100   | 0      | 38.921ms          |
| 1 | sumInArray  | 1     | 100   | 0      | 21.472ms (-44.8%) |
+---+-------------+-------+-------+--------+-------------------+

sumInArray занимает первое место, справившись с задачей почти в два раза быстрее, чем sumInCycle.

Стоп, что? Функция на массивах выиграла с большим отрывом?!

Статистический артефакт

Статистический артефакт

И действительно, это может быть просто "статистический артефакт".

Каждый перезапуск бенчмарков выдаёт разные результаты, иногда даже сильно отличающиеся от предыдущих. Это может быть связано с фоновыми задачами, действиями пользователя или другими явлениями, которые могут повлиять на производительность в моменте.

⚠️ Нам нужны гарантии, что мы сравниваем не просто случайные выбросы, а действительно стабильные результаты.

На помощь приходит статистика в лице коэффициента вариации, который показывает относительную изменчивость данных. Чем меньше этот коэффициент, тем стабильнее результаты.

Всё, что нам нужно сделать, — это получить больше данных, размазанных во времени, то есть перезапустить бенчмарки несколько раз. В атрибуте #[Bench]#[Bench(array $callables, array $arguments = [], int $warmup = 1, int $calls = 1_000, int $iterations = 10)]Объявляет бенчмарк для сравнения производительности метода с альтернативными реализациями. есть параметр iterations, который и отвечает за количество перезапусков бенчмарков.

Выставим iterations: 10 и перезапустим:

Summary:
+---+-------------+-------+-------+--------+-------------------+---------+
| # | Name        | Iters | Calls | Memory | Avg Time          | RStDev  |
+---+-------------+-------+-------+--------+-------------------+---------+
| 2 | current     | 10    | 100   | 0      | 38.474ms          | ±2.86%  |
| 1 | sumInArray  | 10    | 100   | 0      | 12.501ms (-67.5%) | ±27.20% |
+---+-------------+-------+-------+--------+-------------------+---------+

Теперь sumInArray выполняется в 3 раза быстрее, но коэффициент вариации (столбец RStDev) составляет 27.2%, что довольно много. Чтобы говорить о стабильности результатов, обычно стремятся к RStDev < 2%.

Давайте подумаем, как можно снизить эту вариацию. Наши функции выполняются достаточно быстро, и даже небольшие флуктуации в производительности могут сильно влиять на результаты, особенно при небольшом количестве запусков. Для быстрого кода, как у нас, может помочь увеличение количества calls в каждой итерации. Повышаем до 2000:

Summary:
+---+-------------+-------+-------+--------+-------------------+--------+
| # | Name        | Iters | Calls | Memory | Avg Time          | RStDev |
+---+-------------+-------+-------+--------+-------------------+--------+
| 2 | current     | 10    | 2000  | 0      | 37.888ms          | ±1.38% |
| 1 | sumInArray  | 10    | 2000  | 0      | 11.395ms (-69.9%) | ±1.72% |
+---+-------------+-------+-------+--------+-------------------+--------+

Как вы уже поняли, при такой разнице в производительности (в три раза) даже ±27.2% вариации не спасло бы цикл for от поражения. Но теперь мы можем с уверенностью говорить о стабильности результатов (RStDev < 2%).

Ладно, массивы быстрее

Кстати, вы заметили, что в обоих случаях memory=0? Это значит, что для массивов дополнительная память не была аллоцирована и хватило того, что уже было выделено на момент запуска бенчмарков.

Конечно, можно поиграться с более крупным диапазоном, включить JIT и доказать, что в каких-то случаях цикл будет быстрее, но я хочу обратить ваше внимание на то, как быстро теперь можно что-то забенчить!

Bench

Шок

Бенчим прямо в коде без лишней обвязки. Как встроенные тесты, только бенчмарки.

Когда-то Dragon Code показал, что бенчмарки могут быть простыми и удобными: вместо тонны обвязки достаточно вызвать один класс и передать замыкания для сравнения. Testo вывел это на новый уровень: от намерения до результата всего один атрибут.

Запускается простым кликом в IDE: IDE

Но это ещё не всё. За простотой снаружи скрываются серьёзные алгоритмы, подкреплённые статистикой.

Testo автоматически находит отклонения в данных, отметает выбросы и выдаёт метрики, которые помогают понять, насколько стабильны результаты. Для тех, кому цифры мало о чём говорят, есть саммари с рекомендациями и алертами.

Сейчас это выглядит так:

Results for calcFoo:
+----------------------------+-------------------------------------------------+------------------------------------+--------------------------------------------------------------+
| BENCHMARK SETUP            | TIME RESULTS                                    | FILTERED RESULTS                   | SUMMARY                                                      |
| Name       | Iters | Calls | Mean              | Median            | RStDev  | Rej. | Mean*             | RStDev* | Place | Warnings                                             |
+------------+-------+-------+-------------------+-------------------+---------+------+-------------------+---------+-------+------------------------------------------------------+
| current    | 10    | 20    | 44.03µs           | 43.68µs           |  ±2.35% | 1    | 43.69µs           |  ±0.42% | 3rd   |                                                      |
| calcBar    | 10    | 20    | 13.72µs (-68.8%)  | 13.26µs (-69.6%)  |  ±7.77% | 2    | 13.23µs (-69.7%)  |  ±0.52% | 2nd   |                                                      |
| calcBaz    | 10    | 20    | 110.50ns (-99.7%) | 105.00ns (-99.8%) | ±16.50% | 1    | 106.11ns (-99.8%) | ±12.52% | 1st   | High variance, low iter time. Insufficient iter time |
+------------+-------+-------+-------------------+-------------------+---------+------+-------------------+---------+-------+------------------------------------------------------+
Recommendations:
  ⚠ High variance, low iter time: Measurement overhead may dominate — increase calls per iteration.
  ⚠ Insufficient iter time: Timer jitter exceeds useful signal — increase calls per iteration.

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

Вернёмся к коллайдеру

Конечно же, вы знаете, что ту задачу с суммой диапазона можно решить за O(1) с помощью обычной математической формулы. Лишу вас удовольствия заявить об этом в комментариях.

Вот функция и бенч против предыдущих решений:

php
public static function sumLinearF(int $a, int $b): int
{
    $d = $b - $a + 1;
    return (int) (($d - 1) * $d / 2) + $a * $d;
}
Summary:
+---+-------------+-------+-------+-------------------+--------+
| # | Name        | Iters | Calls | Avg Time          | RStDev |
+---+-------------+-------+-------+-------------------+--------+
| 4 | current     | 10    | 2000  | 40.102ms          | ±1.09% |
| 2 | sumInArray  | 10    | 2000  | 12.232ms (-69.5%) | ±0.93% |
| 1 | sumLinear   | 10    | 2000  | 77.065µs (-99.8%) | ±3.05% |
+---+-------------+-------+-------+-------------------+--------+

Микросекунды вместо миллисекунд. Круто, правда?

И даже здесь есть потенциальные места для оптимизации. Вы, наверное, слышали, что операция деления не всегда самая быстрая.
Деление на 2 можно заменить умножением на 0.5.

Умножение быстрее!

php
public static function multi(int $a, int $b): int
{
    $d = $b - $a + 1;
    return (int) (($d - 1) * $d * 0.5) + $a * $d;
}
+---+---------+-------+---------+--------+------------------+--------+
| # | Name    | Iters | Calls   | Memory | Avg Time         | RStDev |
+---+---------+-------+---------+--------+------------------+--------+
| 1 | current | 10    | 2000000 | 0      | 75.890µs         | ±0.79% |
| 2 | multi   | 10    | 2000000 | 0      | 78.821µs (+3.9%) | ±0.47% |
+---+---------+-------+---------+--------+------------------+--------+

Деление быстрее умножения (╯°□°)╯︵ ┻━━┻

Ожидания не всегда совпадают с реальностью, и оптимизации не всегда работают так, как мы думаем.

А ещё, помня, что вычисления производятся с целыми положительными числами в двоичной системе счисления, мы можем заменить деление на битовый сдвиг, который, в теории, должен отработать ещё быстрее.

WTF

php
public static function shift(int $a, int $b): int
{
    $d = $b - $a + 1;
    return ((($d - 1) * $d) >> 1) + $a * $d;
}
+---+---------+-------+---------+--------+------------------+--------+
| # | Name    | Iters | Calls   | Memory | Avg Time         | RStDev |
+---+---------+-------+---------+--------+------------------+--------+
| 2 | current | 10    | 2000000 | 0      | 75.890µs         | ±0.79% |
| 1 | shift   | 10    | 2000000 | 0      | 70.559µs (-7.0%) | ±0.70% |
+---+---------+-------+---------+--------+------------------+--------+

Ну хоть битовый сдвиг не подвёл.

Обратите внимание, что 7% оптимизации нельзя трактовать так, что битовый сдвиг быстрее именно на 7%. В функции есть ещё несколько математических операций, да и запуск функции занимает какое-то время. Так что 7% — это разница между двумя функциями, а не между двумя конкретными операциями.

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

Бенчи покажут

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