Вероятно, вы уже слышали о соревновании под названием "The One Billion Row Challenge" (1brc), если же нет, то предлагаю ознакомиться с репозиторием 1brc Гуннара Морлинга.

Моё участие в проекте было мотивировано присутствием в нём двух моих коллег, которые достигли лидирующих позиций.
PHP не известен своими выдающимися скоростными показателями. Тем не менее, учитывая, что я работаю над профайлером PHP, я решил исследовать его производительность на примере этого вызова.
Первый наивный подход
Я скопировал содержимое репозитория и сформировал датасет с миллиардом записей в текстовом файле measurements.txt. После этого приступил к разработке первоначальной, простейшей версии алгоритма, который мог бы выполнять поставленную передо мной задачу:
<?php $stations = []; $fp = fopen('measurements.txt', 'r'); while ($data = fgetcsv($fp, null, ';')) { if (!isset($stations[$data[0]])) { $stations[$data[0]] = [ $data[1], $data[1], $data[1], 1 ]; } else { $stations[$data[0]][3] ++; $stations[$data[0]][2] += $data[1]; if ($data[1] < $stations[$data[0]][0]) { $stations[$data[0]][0] = $data[1]; } if ($data[1] > $stations[$data[0]][1]) { $stations[$data[0]][1] = $data[1] } } } ksort($stations); echo '{'; foreach($stations as $k=>&$station) { $station[2]=$station[2]/$station[3]; echo $k, '=', $station[0], '/', $station[2], '/', $station[1], }', '; echo '}';
В этом скрипте нет ничего выдающегося: мы открываем файл и читаем данные с помощью функции fgetcsv(). Если станция ещё не была обнаружена, мы регистрируем её, если же она уже есть в нашем списке, то увеличиваем счётчик, добавляем значения температуры и обновляем минимальные и максимальные показатели при необходимости.
По завершении, я применяю функцию ksort() для сортировки массива $stations, после чего вывожу список станций, а также вычисляю среднюю температуру, разделив общую сумму на количество записей.
Эксперимент с этим базовым кодом на моём ноутбуке показал, что его выполнение занимает 25 минут ?
Теперь самое время приступить к оптимизации и использовать профайлер:

Визуализация временных интервалов позволила мне определить, что задача ограничена возможностями процессора (CPU bound). Начальный этап компиляции файла не оказывает заметного влияния, также отсутствуют существенные операции сбора мусора.

Просмотр flame-графика также полезен, так как он показывает, что я трачу 46% процессорного времени на fgetcsv().
Переход от fgetcsv() к fgets()
Моя первая оптимизация включала замену fgetcsv() на fgets(), чтобы самостоятельно извлечь строку и разделить ее на части, используя символ ";". Мотивацией для этого было то, что fgetcsv() выполняет больше операций, чем требуется для моих целей.
// ... while (" class="formula inline">data = fgets( $pos = strpos($data, ';'); $city = substr($data, 0, $pos); $temp = substr($data, $pos+1, -1); // …
Дополнительно я переименовал $data[0] в $city и $data[1] в $temp везде.
После повторного запуска скрипта с этим изменением, время выполнения сократилось до 19 минут 49 секунд. Хотя это всё ещё довольно продолжительный период, мы наблюдаем уменьшение на 21% по сравнению с предыдущими показателями.

Flame-график отображает внесенные изменения, и переход к просмотру времени выполнения CPU по отдельным строкам кода также демонстрирует процессы, происходящие в основном блоке кода:

Я трачу ~38% времени CPU на строках 18 и 23, которые выглядят следующим образом:
18 | $stations[$city][3] ++; | // ... 23 | if ($temp > $stations[$city][1]) {
Строка 18 отражает первый доступ к массиву
Применение ссылок там, где это возможно
$station = &$stations[$city]; $station[3] ++; $station[2] += $temp; // вместо $stations[$city][3] ++; $stations[$city][2] += $data[1];
Таким образом PHP не будет искать ключ в массиве $stations каждый раз при доступе, можно рассматривать это как кэширование для обращения к "текущей" станции в массиве.
И эта мера оказалась эффективной: время выполнения сократилось до 17 минут 48 секунд, что на 10% быстрее предыдущего результата!
Всего одно сравнение
Проходя по коду, я наткнулся на этот фрагмент:
if ($temp < $station[0]) { $station[0] = $temp; } if ($temp > $station[1]) $station[1] = $temp; }
Учитывая, что если температура ниже установленного минимума, она не может одновременно быть выше максимума, я преобразовал условный оператор в 'elseif', что потенциально экономит процессорное время.
К слову, мне неизвестен порядок следования температур в файле measurements.txt, но в зависимости от него может иметь значение, какое сравнение проводить в первую очередь.
С учётом этой оптимизации, новая версия сценария работает за 17 минут 30 секунд, что на 2% быстрее предыдущего времени. Это небольшое улучшение, но оно превосходит обычные колебания производительности.
Встраивание явного указания типов данных
PHP известен своей динамичностью и гибкостью с типами данных, что я особенно ценил в начале своего пути в программировании, поскольку это означало одну заботу меньше. Однако точное определение типов может значительно помочь интерпретатору кода в принятии оптимальных решений в процессе его выполнения.
$temp = (float)substr($data, $pos+1, -1);
И знаете что? Это незначительное изменение сократило время выполнения скрипта до всего 13 минут 32 секунд, дав улучшение производительности на целых 21%!

18 | $station = &$stations[$city]; | // ... 23 | } elseif ($temp >$station[1]) {
На строке 18 все еще приходится 11% времени CPU из-за доступа к массиву (поиск ключа в хеш-таблице, что является основой для ассоциативных массивов в PHP).
Затраты процессорного времени на выполнение строки 23 уменьшились примерно с 32% до 15%. Это связано с тем, что теперь PHP не нужно проводить операции преобразования типов. До введения явного приведения типов переменные $temp, $station[0] и $station[1] интерпретировались как строки, и PHP вынужден был преобразовывать их в вещественные числа для каждого сравнения.
Давайте рассмотрим JIT
В PHP OPCache по умолчанию не активен при использовании командной строки и для его включения требуется задать параметр opcache.enable_cli как on. В состав OPCache входит JIT, который, хоть и активирован по умолчанию, фактически не функционирует из-за нулевого размера буфера. Я задал для opcache.jit-buffer-size размер в 10М. После внесения этих изменений, я повторно запустил скрипт с включенным JIT и время выполнения сократилось до:
7 минут 19 секунд ?
Что обеспечило снижение длительности выполнения на 45.9%!
Что ещё можно улучшить?
Я уже сократил время выполнения с исходных 25 минут до примерно 7 минут. Одним из удивительных моментов для меня стал тот факт, что функция fgets() использует около 56 Гб/м памяти для обработки файла объемом 13 ГБ. Это выглядит подозрительно, поэтому я решил исследовать реализацию функции fgets() и обнаружил, что могу уменьшить количество выделений памяти, убрав аргумент len из вызова fgets():
while ($data = fgets($fp)){ // вместо while ($data = fgets($fp, 999)){
Сравнение профиля до и после изменений:

Вы можете предположить, что это изменение приведёт к заметному повышению производительности, однако прирост составил всего около 1%. Это объясняется тем, что речь идёт о мелких выделениях памяти, с которыми менеджер памяти ZendMM справляется очень эффективно за счёт их размещения в специальных контейнерах (bins), обеспечивая при этом высокую скорость работы.
Можно ли ускорить процесс ещё сильнее?
Конечно, можно! До сих пор я использовал однопоточный подход, который является традиционным для большинства PHP-скриптов, однако PHP обладает возможностью многопоточной обработки на уровне пользователя с использованием расширения parallel.
Анализ профайлера указывает на то, что чтение данных является слабым местом. Замена fgetcsv() на fgets() с последующим разделением строк вручную оказалась полезной, но процесс всё ещё забирает много времени. Поэтому я предлагаю применить многопоточность для одновременного чтения данных и их обработки, после чего мы можем собрать результаты из всех потоков воедино.
$file = 'measurements.txt'; $threads_cnt = 16; /** * Get the chunks that each thread needs to process with start and end position. * These positions are aligned to \n chars because we use `fgets()` to read * which itself reads till a \n character. * * @return array<int, array{0: int, 1: int}> */ function get_file_chunks(string $file, int $cpu_count): array { $size = filesize($file); if ($cpu_count == 1) { $chunk_size = $size; } else { $chunk_size = (int) ($size / $cpu_count); } $fp = fopen($file, 'rb'); $chunks = []; $chunk_start = 0; while ($chunk_start < $size) { $chunk_end = min($size, $chunk_start + $chunk_size); if ($chunk_end < $size) { fseek($fp, $chunk_end); fgets($fp); // moves fp to next \n char $chunk_end = ftell($fp) } $chunks[] = [ $chunk_start, $chunk_end ]; $chunk_start = $chunk_end+1; } fclose($fp); return $chunks; } /** * This function will open the file passed in `$file` and read and process the * data from `$chunk_start` to `$chunk_end`. * * The returned array has the name of the city as the key and an array as the * value, containing the min temp in key 0, the max temp in key 1, the sum of * all temperatures in key 2 and count of temperatures in key 3. * * @return array<string, array{0: float, 1: float, 2: float, 3: int}> */ $process_chunk = function (string $file, int $chunk_start, int $chunk_end): array{ $stations = []; $fp = fopen($file, 'rb'); fseek($fp, $chunk_start); while ($data = fgets($fp)){ $chunk_start += strlen($data); if ($chunk_start > $chunk_end) { break; } $pos2 = strpos($data, ';'); $city = substr($data, 0, $pos2); $temp = (float)substr($data, $pos2+1, -1); if (isset($stations[$city])){ $station = &$stations[&city]; $station[3] ++; $station[2] += $temp; if ($temp < $station[0]) { $station[0] = $temp; } elseif ($temp > $station[1]) { $station[1] = $temp; } } else { $stations[$city] = [ $temp, $temp, $temp, 1 ]; } } return $stations; }; $chunks = get_file_chunks($file, $threads_cnt); $futures = []; for ($i = 0; $i < $threads_cnt; $i++) { $runtime = new \parallel\Runtime(); $futures[$i] = $runtime->run( $process_chunk, [ $file, $chunks[$i][0], $chunks[$i][1], ] ); } $results = []; for ($i = 0; $i < $threads_cnt; $i++) { // `value()` blocks until a result is available, so the main thread waits // for the thread to finish $chunk_result = $futures[$i]->value(); foreach ($chunk_result as $city => $measurement) { if (isset($results[$city])) { $result = &$results[$city]; $result[2] += $measurement[2]; $result[3] += $measurement[3]; if ($measurement[0] < $result[0]) { $result[0] = $measurement[0]; } if ($measurement[1] < $result[1]) { $result[1] = $measurement[1]; } } else { $results[$city] = $measurement; } } } ksort($results); echo '{', PHP_EOL; foreach($results as $k=>&$station) { echo "\t", $k, '=', $station[0], '/', ($station[2]/$station[3]), '/', $station[1], ',', PHP_EOL; } echo '}', PHP_EOL;
Этот скрипт выполняет несколько операций: во-первых, я провожу разбиение файла на части с учетом границ строк (\n), чтобы позже использовать функцию fgets(). После того как части файла определены, я инициирую $threads_cnt рабочих потоков. Каждый из потоков открывает тот же файл и перемещается к начальной точке своего сегмента данных, затем совершает чтение и обработку данных до завершения сегмента, возвращая промежуточные результаты. В заключение, основной поток объединяет эти результаты, выполняет сортировку и осуществляет их вывод.
Этот многопоточный подход завершается всего за:
1 минуту 35 секунд ?
Это конец?
Ни в коем случае. По поводу этого решения я хочу отметить хотя бы два аспекта:
Этот код был запущен на MacОS с использованием процессора Apple Silicon, где есть проблемы с использованием JIT в версии PHP, скомпилированной с поддержкой ZTS, в следствие чего время выполнения составило 1 минуту 35 секунд без JIT. Возможно, результаты могли бы быть лучше с активированным JIT.
Я осознал, что использовал версию PHP, скомпилированную с флагами CFLAGS="-g -O0 ...", соответствующими моим ежедневным потребностям в отладке ?.
Я должен был проверить и это с самого начала, поэтому я пересобрал PHP 8.3 с флагами CLFAGS="-Os ...", и в результате получил окончательное время выполнения со 16 потоками:
? 27.7 секунд ?
Этот результат значительно отличается от того, что вы могли видеть в таблице лидеров исходной задачи, что объясняется использованием совсем другого аппаратного оборудования.
Вот временной график при использовании 10 потоков:

Нижний поток на графике представляет собой главный поток, который ожидает результатов от рабочих потоков. В момент получения промежуточных результатов от рабочих потоков, главный поток приступает к их агрегации и сортировке. Ясно видно, что главный поток не является узким местом процесса. Следовательно, если цель - достичь ещё большей оптимизации, стоит сфокусироваться на работе рабочих потоков.
Чему я научился?
Каждый уровень абстракции это компромисс между удобством и затратами на циклы центрального процессора или память. fgetcsv() проста в обращении и маскирует множество деталей, однако это имеет свою стоимость. Даже fgets(), хотя и скрывает некоторые моменты, делает процесс чтения данных значительно проще.
Типизация в коде может помочь языку в оптимизации выполнения или исключить невидимые на первый взгляд преобразования типов, за которые приходится платить процессорным временем.
JIT - это действительно мощный инструмент, особенно когда подходит к решению задач, связанных с процессором!
Это может быть несвойственно для большинства PHP-программ, но благодаря параллелизму (используя ext-parallel) мы смогли существенно ускорить работу.
Заключение
Я надеюсь, вам было так же интересно читать эту статью, как и мне было интересно её писать. Если вам захочется углубиться в оптимизацию кода, не стесняйтесь принять участие и делиться своими комментариями о ваших результатах.
