June 28, 2020

Куда девается память?

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

Для профилирования я использовал xdebug, просмотр дампов делался через qcachegrind074-x86.

Исходная проблема:

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

Данные для анализа:

1) Каждая цель выводится через TargetWidget. Внутри виджета собирается всевозможная информация (запросами к БД через ActiveQuery), форматируется, подключаются ассеты. Количество информации и уровень её анализа зависит от множества факторов (состояние цели, её история и т.д), и, в целом это ОЧЕНЬ затратно как по времени исполнения, так и по памяти.

2) Для экономии ресурсов, TargetWidget обёрнут в кеширование (через наследование от CachedWidget). Таким образом, виджет рассчитывается при первом обращении и сохраняется в кеше (изменение связанной цели вызывает сброс соответствующей записи).

3) Главная страница целеполагания представляет собой произвольный GridView, в ячейки которого выводятся сгенерированные виджеты целей. Количество одновременно отображаемых виджетов зависит от текущих пользовательсикх настроек и фильтров, но ничем не ограничено.

4) В Yii может быть включён внутренний отладчик (включается при определении YII_ENV_DEV).

Первичные гипотезы для проверки:

?) Кривой механизм кеширования: виджеты каждый раз пересчитываются заново.

?) Включение внутреннего отладчика Yii добавляет серьёзный оверхед по памяти.

Для проверки откроем одну и ту же страницу (главную целеполагания) с одними и теми же настройками (вывод 50 строк максимум, все колонки включены) но разными конфигурациями среды и проанализируем профили выполнения.

Конфигурации будут следующие:

а) Кеширование Yii отключено ($config['cache']['class'] => DummyCache::class), внутренний отладчик Yii отключён.

б) Кеширование Yii включено ($config['cache']['class'] => FileCache::class), кеш не прогрет, внутренний отладчик Yii отключён.

в) Кеширование Yii включено ($config['cache']['class'] => FileCache::class), кеш прогрет, внутренний отладчик Yii отключён.

г) Кеширование Yii включено ($config['cache']['class'] => FileCache::class), кеш прогрет, внутренний отладчик Yii включён.

Смотрим:

Вариант а, TMC 276Mb.

Кеширование Yii отключено, внутренний отладчик Yii отключён.

Наиболее "жирный" вызов — обращение к "кешу", роль которого выполняет DummyCache. В "кеше" нужных данных, естественно, нет, и виджеты просчитываются заново, на что уходит 85 мегабайт суммарно. На втором месте (64 мегабайта) — вызов PDOStatement->execute, условно — весь скоп взаимодействия с БД (запросы, ответы, буферизация, связанные данные). На третьем месте (45 мегабайт) — ob_start => включение отдельного буфера под вывод рендеринга любого виджета (не только TargetWidget, но каждого потомка Widget::class).

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

Вариант б, TMC 204Mb.

Кеширование Yii включено, кеш не прогрет, внутренний отладчик Yii отключён.

За счёт включения кеширования часть вызовов уже не выполняется. Речь не только о вызовах, целеполагания непосредственно: кешироваться начинают справочники, некоторые подзапросы и т.д. За счёт этого почти вдвое (до 38 мегабайт) падает стоимость обращения к БД. Часть виджетов не пересчитывается, это видно по десятимегабайтной экономии в вызовах

php::call_user_func:{C:\LIFE\openserver\domains\pmo\vendor\yiisoft\yii2\caching\Cache.php:597}

и семимегабайтной экономии на вызовах ob_start. При этом стоимость генерации колонок грида также падает до 31 мегабайта (против 36 в предыдущем варианте), что также может быть объяснено попаданием в кеш.

Вариант в, TMC 92Mb.

Кеширование Yii включено, кеш прогрет, внутренний отладчик Yii отключён.

С прогретым кешем ререндеринг виджетов и обращения к БД практически не выполняются, все данные, очевидно, получены в готовом виде из кеша. Самая затратная по памяти операция — хранение данных виджетов в буфере (23 мегабайта).

Этот эксперимент исключает гипотезу о кривом кешировании.

Вариант г, TMC 130Mb.

Кеширование Yii включено, кеш прогрет, внутренний отладчик Yii включён.

Измерения в целом аналогичны предыдущему эксперименту, за исключением появления вызова debug_backtrace, занимающего суммарно 22 мегабайта памяти. Можно заметить слегка выросшую стоимость spl_autoload_call (как раз за счёт подключения класса отладчика).

Гипотеза о оверхеде отладчика подтверждается.

Файлы профилей для самостоятельного анализа: https://cloud.mail.ru/public/2d5U/RWGHFZo2s

Выводы:

  1. Виджеты целей — "жадные" в расчётах, но с этим ничего поделать нельзя, по крайней мере — пока.
  2. Кеширование нивелирует эту жадность. Если кеш будет набираться заблаговременно, то не должно быть проблем даже при первом вызове страницы (реализовано в коммите a9db28ff6ca59b9c4f99f9b6d2fd69d2af6f4e56).
  3. Забытая на боевом сервере отладка добавляет оверхед, которого может оказаться достаточно для исчерпания лимита памяти.
  4. Вывод данных из виджетов в Yii дорогостоящий сам по себе. Большое количество "дешёвых" в генерации виджетов всё равно может потребовать большого количества памяти (видимо, для буферизации, для дальнейшего изучения вопроса нужно изучать код фреймворка). Т.е. мы упираемся в лимит памяти, необходимый для непосредственного вывода данных.

Варианты решения:

  1. На бою не использовать отладчик.
  2. Убрать ограничение по памяти процесса (или, хотя бы, увеличить его).
  3. Запретить вывод большого количества элементов TargetWidget => фактически, лимитировать количество строк в GridView стандартными 20.
  4. Ещё как-то упростить вывод. Например, по умолчанию выводить только название цели, а сопутствующую информацию (статусы/состояния/связи) — во всплывающем блоке, получаемом в фоне.
  5. Как-то добавить в GridView динамическую подгрузку данных по мере прокрутки.