Сайт вебмастера

Профилирование php-кода

24-10-2024Reading time ~ 6 min.PHP 144

Я уже писал об оптимизации PHP-приложений, но не упомянул профилирование кода. Оно и понятно: это довольно муторный процесс, к которому прибегают в особых случаях. При написании кода, предполагается, что нужно просто стремиться к его минимизации и к тому, чтобы он работал без ошибок. Профилирование же нужно для количественной оценки эффективности кода.

Недавно я столкнулся с проблемой в Albireo CMS, которую долго не мог выявить. И когда уже перепробовал все варианты, понял, что нужно заниматься профилированием. Только так получается выявить проблемные места в коде.

Суть проблемы в том, что система стала медленно работать, относительно предыдущих версий. Например раньше система генерировала страницу за 0,1-0,2 секунды, но вдруг стала работать за 0,5-0,9 секунд.

Я это связал со своими работами по обслуживанию лицензии системы. Там достаточно хитрый алгоритм, который завязан на хэширующие функции, которые стойкие по времени — в теории это может приводить к небольшим задержкам выполнения. Поэтому поначалу я не особо этим заморачивался — как алгоритм лицензий будет отлажен, тогда и решу как оптимизировать код.

Тестирую я систему на нескольких сайтах — у них общее ядро, только разные настройки и количество страниц.

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

Основной моей ошибкой было то, что я смотрел только одно время генерации всего сайта. Вывод об эффективности кода, например функции, делается на основе прогона сайта с включенной функцией или она сразу отрубается по return. Уверен, что большинство программистов делают так же.

В итоге я понял, что код лицензии практически не сказывается на скорости выполнения, поэтому начал рыть в другую сторону: Sqlite, кэш, обсчёт каталогов и т.п.

В какой-то момент до меня всё-таки дошло, что это бессмысленно, потому что у меня элементарно нет статистики выполнения по времени тестируемых функций и участков кода.

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

/**
 * Отладка по времени
 * Функция вначале собирает данные в общий массив, а потом выводит его через pr()
 * Работает только если в стоит define('DEBUG_BENCHMARK', '');
 *
 * define('DEBUG_BENCHMARK', ''); // где-то в loader.php
 *
 * debugBenchmarkStart('метка');
 *    любой код
 * debugBenchmarkEnd('метка');
 *
 * debugBenchmarkOut(); // вывод где-то в самом конце кода
 **/

function debugBenchmarkStart($mark)
{
    if (!defined('DEBUG_BENCHMARK')) return;

    // объявляем пременную глобальной, чтобы к ней был доступ у других функций
    // а также потому, что это не требует других зависимостей
    global $debugBenchmark;

    $debugBenchmark[$mark] = hrtime(true);
}

function debugBenchmarkEnd($mark, string $after = '')
{
    if (!defined('DEBUG_BENCHMARK')) return;

    global $debugBenchmark;

    $end = hrtime(true);
    $start = $debugBenchmark[$mark] ?? false;

    if ($start !== false) {
        // наносекунды в миллисекунды
        $time = ($end - $start) / 1e+6;

        if ($after) 
            $debugBenchmark[$mark] = $time . ' # ' . $after;
        else 
            $debugBenchmark[$mark] = $time;
    }
}

function debugBenchmarkOut()
{
    if (!defined('DEBUG_BENCHMARK')) return;

    global $debugBenchmark;

    if ($debugBenchmark) 
        pr($debugBenchmark); // print_r()
}
Функция pr() — это обёртка над print_r(), можно использовать её.

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

Для тестирования функций можно использовать так:

debugBenchmarkStart(__FUNCTION__);

тут тестируемый код

debugBenchmarkEnd(__FUNCTION__);

Если в функции несколько тестируемых участков, то можно делать так:

debugBenchmarkStart(__FUNCTION__);

if (какое-то условие) {
    debugBenchmarkEnd(__FUNCTION__, 'сработало условие');
    return ...
}

debugBenchmarkEnd(__FUNCTION__, 'полная отработка');

Можно спокойно «вкладывать» метки друг в друга:

debugBenchmarkStart(__FUNCTION__);
    
debugBenchmarkStart(__FUNCTION__ . ' 1');
какой-то участок кода
debugBenchmarkEnd(__FUNCTION__ . ' 1');

debugBenchmarkStart(__FUNCTION__ . ' 2');
ещё что-то
debugBenchmarkEnd(__FUNCTION__ . ' 2');

debugBenchmarkStart(__FUNCTION__); // конец функции

Константа DEBUG_BENCHMARK нужна для того, чтобы отключить отладку на рабочем сайте, не удаляя отладочные функции.

На выходе будет что-то вроде такого (время в миллисекундах):

Array
(
   [readPages] => 143.4519 # not cache
   [pageOut] => 149.4376
   [pageOut 1] => 0.163
   [events\stat save] => 110.188
   [pageOut 2] => 0.0123
   [pageOut getContentFile] => 30.4609
   [requireSafe - alpine.php] => 0.2404
   [requireSafe - counter.php] => 0.4861
   [requireSafe - css.php] => 0.6836
   [requireSafe - favicon.php] => 0.3376
   [requireSafe - imfont.php] => 0.1729
   [requireSafe - js.php] => 0.1757
   [requireSafe - splitjs.php] => 0.186
   [requireSafe - tiny-slider.php] => 0.1814
   [requireSafe - wow.php] => 0.1579
   [templateModules - headers] => 0.9585
   [35-nav 1] => 0.4796
   [requireSafe - 40-other-pages.php] => 0.3458
   [requireSafe - 50-comments.php] => 2.9518
   [implodeWrap] => 0.0121
   [templateModules - footers] => 1.4396
   [pageOut processingContent] => 2.3328
   [pageOut compressHtml] => 0.7257
   [pageOut events pageOut-end] => 0.0089
)

Я начал профилирование с блока проверки лицензии и выяснил, что это всего несколько миллисекунд. То есть мои предположения, что этот код может тормозить, оказались ошибочными. Чтобы понять в чём проблема, я начал с проверки основных функций ядра системы. Выяснилось, что основное время это readPages() и pageOut().

Первая функция считывает все файлы в указанном каталоге, включая подкаталоги. У меня есть несколько версий этой функции, крайний вариант работал на glob(), но я решил проверить работу и на RecursiveDirectoryIterator. Оказалось, что они примерно равны, но если запустить их много раз, то glob() показывает примерно стабильные результаты, а RecursiveDirectoryIterator при повторах работает раз в 10 быстрей. Предполагаю, что там используется какой-то вариант кэша из файловой системы ОС. В PHP такое кэширование используется в разных функциях, вроде filemtime(). Таким образом я смог понять где функция тратит своё основное время.

Попутно я выяснил, что можно ускорить INSERT-запросы Sqlite примерно в два раза. При включенной статистике, каждая страница сохраняется в базе, а это INSERT. Зарывшись в доки я поэкспериментировал и выяснил, что если Sqlite указать журналирование в памяти, то это сильно ускоряет транзакцию. Делается это элементарным sql-запросом:

PRAGMA journal_mode=MEMORY;

В итоге это снизило запись в базу примерно до 100 мс. Меньшее значение можно получить только вовсе отключив журнал (OFF), поэтому я не рискнул.

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

Таким образом я подобрался к коду, который получает записи для вывода. Это базовые функции работы с массивом, которые я считал уже отлаженными и быстрыми.

Тестируя на разных сайтах я заметил, что тормозят только те, где много записей — более 100-200 и т.д. С помощью профилирования удалось наконец-то локализовать проблему — это функция сортировки массивов по дате.

Здесь нужно объяснение. В Albireo CMS поле date используется как дата записи, но формат даты может быть произвольным, главное, чтобы его понимала функция strtotime(). Можно было бы конечно зафиксировать какой-то строгий формат даты и времени, но мне хочется больше вольностей для пользователей. Поэтому я и использую эту функцию.

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

Отказываться от strtotime() я не хотел, поэтому сделал немного другой алгоритм. При получении записей все поля дат прогоняются через strtotime() и получается метка времени Unix в дополнительных (скрытых) полях. После этого страница попадает в кэш. Функция сортировки массива сама проверяет поле — и если это дата, то использует уже другое поле метки Unix. Таким образом сортировка работает уже по числовым полям, а это очень быстрая операция. В цифрах — это ускорение примерно в 100 раз.

Такая оптимизация привела к увеличению скорости Albireo CMS. Если бы не профилирование кода, то можно было бы гадать до сих пор где проблема.


Слава Украине! Смерть рашистам!

Related Posts

Leave a comment!