python

О том, как в Instagram отключили сборщик мусора Python и начали жить

  • вторник, 16 мая 2017 г. в 03:14:37
https://habrahabr.ru/company/wunderfund/blog/328404/
  • Ненормальное программирование
  • Высокая производительность
  • Python
  • Блог компании Wunder Fund


Отключив сборщик мусора Python (GC), который освобождает память, отслеживая и удаляя неиспользуемые данные, Instagram стал работать на 10% быстрее. Да-да, вы не ослышались! Отключив сборщик мусора, можно сократить объем потребляемой памяти и повысить эффективность работы кэша процессора. Хотите узнать, почему так происходит? Тогда пристегните ремни!


Как мы запускаем наш веб-сервер


Веб-сервер Instagram работает на Django в мультипроцессном режиме, где мастер-процесс копирует сам себя, создавая десятки рабочих процессов, принимающих запросы от пользователей. В качестве сервера приложений мы используем uWSGI в режиме prefork, чтобы регулировать распределение памяти между мастер-процессом и рабочими процессами.

Чтобы у Django не закончилась память, мастер-процесс uWSGI предоставляет возможность перезапустить рабочий процесс, когда его резидентная память (RSS) превышает заранее установленный предел.

Как работает память


Сперва мы решили выяснить, почему RSS рабочих процессов начинает так быстро расти сразу после того, как их порождает мастер. Мы заметили, что, хотя RSS начинается с 250 МБ, размер используемой разделяемой памяти за несколько секунд сокращается с 250 МБ до почти 140 МБ (размер разделяемой памяти можно посмотреть в /proc/PID/smaps). Числа здесь не слишком интересны, так как они постоянно меняются, но то, насколько быстро освобождается распределяемая память (почти на 1/3 от общего объема памяти), представляет интерес. Затем мы решили выяснить, почему эта разделяемая память становится частной памятью каждого процесса в начале его жизни.

Наше предположение: Copy-on-Read


В ядре Linux существует механизм копирования при записи (Copy-on-Write, CoW), который служит для оптимизации работы дочерних процессов. Дочерний процесс в начале своего существования делит каждую страницу памяти со своим родителем. Страница копируется в собственную память процесса только при записи.

Но в мире Python из-за подсчета ссылок происходят интересные вещи. Каждый раз при чтении Python-объекта интерпретатор увеличит его счетчик ссылок, что в сущности является операцией записи в его внутреннюю структуру данных. Это вызывает CoW. Получается, что с Python мы на самом деле используем Copy-on-Read (CoR)!

#define PyObject_HEAD                   \
    _PyObject_HEAD_EXTRA                \
    Py_ssize_t ob_refcnt;               \
    struct _typeobject *ob_type;
...
typedef struct _object {
    PyObject_HEAD
} PyObject;

Назревает вопрос: выполняем ли мы копирование при записи для immutable объектов, таких, как объекты кода? Так как PyCodeObject на самом деле “подкласс” PyObject, очевидно, да. Нашей первой идеей было отключить подсчет ссылок для PyCodeObject.

Попытка номер 1: отключить подсчет ссылок для объектов кода


Мы в Instagram начинаем с простого. В качестве эксперимента мы добавили небольшой хак в интерпретатор CPython, убедились, что счетчик ссылок не меняется для объектов кода, а затем установили этот CPython на один из рабочих серверов.

Результат нас разочаровал: в использовании разделяемой памяти ничего не изменилось. Когда мы попытались выяснить, почему так происходит, мы поняли, что не можем найти каких-либо надежных метрик, чтобы доказать, что наш хак сработал, а также не можем доказать связь между общей памятью и копией объекта кода. Очевидно, мы что-то упустили из вида. Вывод: прежде, чем следовать вашей теории, докажите ее.

Анализ страничных прерываний


Немного погуглив на тему Copy-on-Write, мы выяснили, что Copy-on-Write связан с ошибками отсутствия страниц в памяти (page faults, или страничными прерываниями). Каждая операция CoW вызывает страничное прерывание в работе процесса. Инструменты для мониторинга производительности, встроенные в Linux, позволяют записывать системные события, включая страничные прерывания, и, когда это возможно, даже выводят стек-трейс!

Мы снова отправились на продакшн-сервер, перезагрузили его, подождали, пока мастер-процесс породит дочерние процессы, узнали PID рабочего процесса, а потом выполнили следующую команду:

perf record -e page-faults -g -p <PID>

С помощью стек-трейса мы получили представление о том, когда в процессе случаются страничные прерывания.



Результаты отличались от того, что мы ожидали. Главным подозреваемым оказалось не копирование объектов кода, а метод collect, принадлежащий gcmodule.c, и вызываемый при запуске сборщика мусора. Почитав, как работает GC в CPython, мы разработали следующую теорию:

Сборщик мусора в CPython вызывается детерминировано на основании порогового значения. Пороговое значение по умолчанию очень низкое, поэтому сборщик мусора запускается на очень ранних стадиях. Он обслуживает связные списки, содержащие информацию о создании объектов, и во время сборки мусора связные списки перемешиваются. Так как структура связного списка существует вместе с самим объектом (совсем как ob_refcount), перемешивание этих объектов в связных списках вызовет CoW соответствующих страниц, что является досадным побочным эффектом.

/* GC information is stored BEFORE the object structure. */
typedef union _gc_head {
    struct {
        union _gc_head *gc_next;
        union _gc_head *gc_prev;
        Py_ssize_t gc_refs;
    } gc;
    long double dummy;  /* force worst-case alignment */
} PyGC_Head;

Попытка номер 2: Попробуем отключить сборщик мусора


Ну что ж, раз сборщик мусора вероломно предал нас, давайте отключим его!

Мы добавили вызов gc.disable() в наш скрипт загрузки. Перезагрузили сервер – и снова неудача! Если снова взглянуть на perf, мы увидим, что gc.collect все еще вызывается, и копирование в память все еще выполняется. После небольшой отладки в GDB, мы обнаружили, что одна из используемых нами внешних библиотек (msgpack) вызывает gc.enable(), чтобы оживить сборщик мусора, так что gc.disable() в загрузочном скрипте был бесполезен.

Патчить msgpack для нас было недопустимо, так как это открывало другим библиотекам возможность сделать то же самое, не ставя нас в известность. Во-первых, необходимо доказать, что отключение сборщика мусора действительно помогает. Ответ снова лежит в gcmodule.c. В качестве альтернативы gc.disable мы выполнили gc.set_threshold(0), и на этот раз ни одна библиотека не вернула это значение на место.

Таким образом мы успешно увеличили объем разделяемой памяти для каждого рабочего процесса с 140 МБ до 225 МБ, и общий объем используемой памяти на хосте упал до 8 ГБ на каждой машине. Это позволило сэкономить 25% ОЗУ на всех серверах Django. С таким запасом свободного пространства мы можем как запустить намного больше процессов, так и повысить порог для резидентной памяти. В результате это увеличивает пропускную способность слоя Django на более чем 10%.

Попытка номер 3: Полностью отключаем сборщик мусора


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

2016-05-02_21:46:05.57499 WSGI app 0 (mountpoint='') ready in 115 seconds on interpreter 0x92f480 pid: 4024654 (default app)

Этот баг был с трудом воспроизводим, так как поведение не было детерминированным. После множества экспериментов удалось определить точные шаги воспроизведения. Когда такое происходило, свободная память на этом хосте падала почти до нуля и прыгала обратно, заполняя весь кэш. Тогда наступал момент, когда весь код или данные приходилось читать с диска (DSK 100%), и все работало медленно.

Это могло сигнализировать о том, что Python выполняет окончательный сбор мусора во время остановки интерпретатора, что может вызывать гигантский скачок количества используемой памяти за очень короткий период времени. И опять я решил сперва доказать это, а потом уже решить, как это исправить. Итак, я закомментировал вызов Py_Finalize в плагине uWSGI для Python, и проблема исчезла.

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

Наконец, нам было необходимо применить наше решение в более крупных масштабах. Мы попробовали применить его на всех серверах, но это снова сломало процесс непрерывного развертывания. Тем не менее, на этот раз пострадали только машины со старыми моделями процессоров (Sandy Bridge), и воспроизвести это было еще сложнее. Вывод: всегда тестируйте старых клиентов/оборудование, так как они легче всего ломаются.

Так как наш процесс непрерывного развертывания достаточно быстр, чтобы понять, что происходит, я добавил отдельную утилиту atop в наш установочный скрипт. Теперь мы могли поймать момент, когда кэш почти полностью заполнялся, и все uWSGI процессы выбрасывали множество MINFLT (минорных ошибок отсутствия страниц в памяти).


И снова, выполняя профилирование производительности, мы встречаем Py_Finalize. При выключении, кроме сборки мусора, Python выполняет несколько операций, связанных с очисткой: таких, как уничтожение объектов типов или выгрузка модулей. И это снова нанесло вред общей памяти.



Попытка номер 4: Последний шаг к выключению сборщика мусора: никакой очистки


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

# gc.disable() doesn't work, because some random 3rd-party library will
# enable it back implicitly.
gc.set_threshold(0)
# Suicide immediately after other atexit functions finishes.
# CPython will do a bunch of cleanups in Py_Finalize which
# will again cause Copy-on-Write, including a final GC
atexit.register(os._exit, 0)

Решение основано на том факте, что функции atexit запускаются из регистра в обратном порядке. Функция atexit завершает остальные очистки, а затем вызывает os._exit(0), чтобы завершить текущий процесс.

Изменив всего две строчки, мы наконец выкатили решение на все наши сервера. Тщательно настроив пороговые значения для памяти, мы получили общий прирост производительности 10%!

Взгляд назад


При осмыслении улучшения производительности у нас возникла пара вопросов:

Во-первых, не должна ли память Python переполниться без сборки мусора, так как она больше не очищается? (Вспомните, что в памяти Python нет настоящего стека, так как все объекты хранятся в куче)

К счастью, это не так. Основной механизм освобождения объектов в Python – это подсчет ссылок. Когда ссылка на объект удаляется (при вызове Py_DECREF), Python всегда проверяет, не достиг ли счетчик ссылок на этот объект нуля. В этом случае будет вызван деаллокатор данного объекта. Главная задача сборки мусора – разрушать циклические зависимости, когда механизм подсчета ссылок не работает.


#define Py_DECREF(op)                                   \
    do {                                                \
        if (_Py_DEC_REFTOTAL  _Py_REF_DEBUG_COMMA       \
        --((PyObject*)(op))->ob_refcnt != 0)            \
            _Py_CHECK_REFCNT(op)                        \
        else                                            \
        _Py_Dealloc((PyObject *)(op));                  \
    } while (0)

Разберем, откуда выигрыш


Второй вопрос: откуда берется прирост производительности?

Выключение сборщика мусора дает двойной выигрыш:

  • Мы освободили почти 8 ГБ оперативной памяти на каждом сервере и смогли использовать их для создания большего количества рабочих процессов на серверах с ограниченной пропускной способностью памяти, или сократить количество перезапусков процессов на серверах с ограничением по мощности ЦП;
  • Пропускная способность ЦП также увеличилась, так как количество инструкций, выполняемых за один такт (IPC) возрастает почти на 10%.

# perf stat -a -e cache-misses,cache-references -- sleep 10
 Performance counter stats for 'system wide':
       268,195,790      cache-misses              #   12.240 % of all cache refs     [100.00%]
     2,191,115,722      cache-references
      10.019172636 seconds time elapsed

С отключенным сборщиком мусора количество неудачных обращений к кэш-памяти (cache-miss rate) падает на 2–3%, что и является главной причиной 10%-ного улучшения IPC. Кэш-промахи дороги, так как они тормозят вычислительный конвейер процессора. Небольшое увеличение рейтинга попаданий в кэш ЦП может значительно улучшить IPC. Чем меньше выполняется операций копирования при записи (CoW), тем больше кэш-линий с различными виртуальными адресами (в разных рабочих процессах) указывают на один и тот же адрес в физической памяти, что приводит к увеличению рейтинга попаданий в кэш.

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

О, а приходите к нам работать? :)
wunderfund.io — молодой фонд, который занимается высокочастотной алготорговлей. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.

Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.

Присоединяйтесь к нашей команде: wunderfund.io