Дрю Галлатин

Летом 2015 года команда Netflix Open Connect CDN решила взяться за амбициозный проект. Цель состояла в том, чтобы использовать новую технологию сетевого интерфейса 100GbE, которая только что выходит на рынок, чтобы иметь возможность обслуживать со скоростью 100 Гбит / с с одного устройства Open Connect (OCA) на базе FreeBSD с использованием хранилища на базе NVM Express (NVMe).

В то время большая часть наших устройств на базе флеш-накопителей была близка к тому, чтобы иметь ограниченный ЦП, обслуживающий 40 Гбит / с с использованием односокетного процессора Xeon E5–2697v2. Первым шагом было найти узкие места ЦП в существующей платформе, пока мы ждали новых ЦП от Intel, новых материнских плат со слотами PCIe Gen3 x16, которые могли бы работать с новыми сетевыми картами Mellanox 100GbE на полной скорости, а также для систем с дисками NVMe.

Поддельный NUMA

Обычно большая часть контента OCA обслуживается с диска, и только 10–20% самых популярных заголовков обслуживаются из памяти (подробности см. В нашем предыдущем блоге Популярность контента для Open Connect). Однако наши ранние прототипы до NVMe были ограничены пропускной способностью диска. Поэтому мы поставили надуманный эксперимент, в ходе которого на тестовом сервере обслуживали только самый популярный контент. Это позволило всему содержимому поместиться в ОЗУ и, следовательно, избежать временного узкого места на диске. Удивительно, но производительность на самом деле упала с ограничения ЦП на 40 Гбит / с до ограничения ЦП всего на 22 Гбит / с!

После выполнения очень простого профилирования с помощью pmcstat и графов пламени мы заподозрили, что у нас проблема с конкуренцией блокировок. Итак, мы запустили инструмент профилирования блокировки lockstat на основе DTrace, который поставляется с FreeBSD. Lockstat сообщил нам, что большую часть времени ЦП мы тратим на ожидание блокировки неактивной очереди страниц FreeBSD. Почему это происходило? Почему стало хуже при обслуживании только по памяти?

Netflix OCA обслуживает большие медиафайлы с помощью NGINX через асинхронный системный вызов sendfile (). (См. NGINX и Netflix вносят новый файл send (2) в FreeBSD). Системный вызов sendfile () извлекает содержимое с диска (если оно уже не находится в памяти) по одной странице размером 4 КБ за раз, помещает его в буфер сетевой памяти (mbuf) и передает его в сетевой стек для дополнительного шифрования и передачи через TCP. После того, как сетевой стек освобождает mbuf, обратный вызов в систему виртуальной машины вызывает освобождение страницы 4K. Когда страница освобождается, она либо освобождается в пул свободных страниц, либо вставляется в список страниц, которые могут понадобиться снова, известный как неактивная очередь. Поскольку мы обслуживали полностью из памяти, NGINX сообщал sendfile (), что большая часть страниц понадобится снова, поэтому почти каждая страница в системе прошла через неактивную очередь.

Проблема здесь в том, что неактивная очередь структурирована как единый список для домена неоднородной памяти (NUMA) и защищена одной блокировкой мьютекса. Обслуживая все из памяти, мы переместили большой процент активности по выпуску страниц из пула свободных страниц (где у нас уже был свободный кеш страниц для каждого процессора, благодаря более ранней работе Рэндалла Стюарта из Netflix и Скотта Лонга, команды Джеффа Роберсона в Isilon и Matt Macy) в неактивную очередь. Очевидным решением было бы добавить неактивный кеш страницы для каждого процессора, но система по-прежнему должна иметь возможность найти страницу, когда она снова понадобится. Страницы хешируются в очереди по NUMA предсказуемым образом.

Окончательное решение, которое мы придумали, - это то, что мы называем «Fake NUMA». Этот подход использует тот факт, что существует один набор очередей страниц для каждого домена NUMA. Все, что нам нужно было сделать, это солгать системе и сказать ей, что у нас есть один поддельный домен NUMA на каждые 2 процессора. После того, как мы это сделали, наша борьба за блокировку почти исчезла, и мы смогли обслуживать со скоростью 52 Гбит / с (ограничено слотом PCIe Gen3 x8) при значительном времени простоя ЦП.

Пбуф

После того, как у нас появились более новые прототипы машин с процессором Intel Xeon E5 2697v3, слотами PCIe Gen3 x16 для сетевой карты 100GbE и дополнительным дисковым хранилищем (4 NVMe или 44 SATA SSD-накопителя), мы столкнулись с еще одним узким местом, также связанным с глобальной блокировкой. список. Мы застряли на скорости около 60 Гбит / с на этом новом оборудовании, и мы были ограничены pbufs.

FreeBSD использует структуру «buf» для управления дисковым вводом-выводом. Буферы, используемые системой подкачки, статически выделяются во время загрузки и хранятся в глобальном связанном списке, который защищен одним мьютексом. Это было сделано давно по нескольким причинам, в первую очередь для того, чтобы избежать необходимости выделять память, когда в системе уже мало памяти, и пытаться выгружать страницы или подкачивать данные, чтобы иметь возможность освободить память. Наша проблема в том, что системный вызов sendfile () использует систему подкачки виртуальной машины для чтения файлов с диска, когда они не находятся в памяти. Следовательно, весь наш дисковый ввод-вывод был ограничен мьютексом pbuf.

Наша первая проблема заключалась в том, что список был слишком маленьким. Мы долго ждали pbufs. Это было легко исправить, увеличив количество pbuf, выделяемых во время загрузки, за счет увеличения параметра kern.nswbuf. Однако это обновление выявило следующую проблему - конфликт блокировок глобального мьютекса pbuf. Чтобы решить эту проблему, мы изменили пейджер vnode (который обрабатывает подкачку файлов, а не раздел подкачки и, следовательно, обрабатывает все операции ввода-вывода sendfile ()), чтобы использовать обычный распределитель зон ядра. Это изменение устранило конфликт блокировок и повысило нашу производительность до диапазона 70 Гбит / с.

Проактивное сканирование страниц ВМ

Как отмечалось выше, мы интенсивно используем очереди страниц виртуальных машин, особенно неактивную очередь. В конце концов, системе не хватает памяти, и эти очереди необходимо сканировать демоном страниц, чтобы освободить память. При полной нагрузке это происходило примерно два раза в минуту. Когда это происходило, все процессы NGINX переходили в спящий режим в vm_wait (), и система перестала обслуживать трафик, пока демон выгрузки работал для сканирования страниц, часто в течение нескольких секунд. Это серьезно повлияло на ключевые показатели, которые мы используем для определения работоспособности OCA, особенно на задержку обслуживания NGINX.

Основное состояние системы можно выразить следующим образом (я бы хотел, чтобы это был мультфильм):

<...>
Time1: 15GB memory free. Everything is fine. Serving at 80 Gbps.
Time2: 10GB memory free. Everything is fine. Serving at 80 Gbps.
Time3: 05GB memory free. Everything is fine. Serving at 80 Gbps.
Time4: 00GB memory free. OH MY GOD!!! We’re all gonna DIE!! Serving at 0 Gbps.
Time5: 15GB memory free. Everything is fine. Serving at 80 Gbps.
<...>

Эта проблема фактически усугубляется по мере добавления доменов NUMA, потому что на каждый домен NUMA приходится один демон выгрузки страницы, но дефицит страниц, который он пытается очистить, рассчитывается глобально. Таким образом, если демон vm pageout решает очистить, скажем, 1 ГБ памяти и имеется 16 доменов, каждый из 16 демонов выгрузки страницы будет индивидуально пытаться очистить 1 ГБ памяти.

Чтобы решить эту проблему, мы решили проактивно сканировать очереди страниц ВМ. В пути к файлу отправки при выделении страницы для ввода-вывода мы запускаем код выгрузки несколько раз в секунду в каждом домене виртуальной машины. Код выгрузки запускается в самом легком режиме в контексте одного неудачного процесса NGINX. Другие процессы NGINX продолжают работать и обслуживать трафик, пока это происходит, поэтому мы можем избежать всплесков активности пейджера, которые блокируют обслуживание трафика. Упреждающее сканирование позволило нам работать со скоростью примерно 80 Гбит / с на прототипе оборудования.

RSS с поддержкой LRO

TCP Large Receive Offload (LRO) - это метод объединения нескольких пакетов, полученных для одного и того же TCP-соединения, в один большой пакет. Этот метод снижает нагрузку на систему за счет уменьшения количества поездок через сетевой стек. Эффективность LRO измеряется скоростью агрегации. Например, если мы можем получить четыре пакета и объединить их в один, то наша скорость агрегирования LRO составляет 4 пакета на агрегирование.

Код FreeBSD LRO по умолчанию будет управлять до 8 агрегациями пакетов одновременно. Это действительно хорошо работает в локальной сети, когда обслуживает трафик через небольшое количество действительно быстрых соединений. Однако у нас есть десятки тысяч активных TCP-соединений на наших машинах 100GbE, поэтому наша скорость агрегации редко была лучше, чем 1,1 пакета на агрегацию в среднем.

Ханс Петтер Селаски, разработчик драйвера 100GbE Mellanox, предложил новаторское решение нашей проблемы. Большинство современных сетевых адаптеров предоставляют хосту результат хеширования масштабирования на стороне приема (RSS). RSS - это стандарт, разработанный Microsoft, в котором трафик TCP / IP хешируется по IP-адресу источника и назначения и / или портам источника и назначения TCP. Результат хеширования RSS почти всегда однозначно идентифицирует TCP-соединение. Идея Ханса заключалась в том, что вместо того, чтобы просто передавать пакеты механизму LRO по мере их поступления из сети, мы должны удерживать пакеты в большом пакете, а затем сортировать пакет пакетов по результату хеширования RSS (и исходному времени прибытия, держать их в порядке). После сортировки пакеты из одного и того же соединения становятся смежными, даже если они прибывают с большим разделением во времени. Следовательно, когда пакеты передаются в подпрограмму FreeBSD LRO, она может их агрегировать.

С помощью этого нового кода LRO мы смогли достичь скорости агрегации LRO более 2 пакетов на агрегацию и впервые смогли обслуживать более 90 Гбит / с на нашем прототипе оборудования для в основном незашифрованного трафика.

Очередь RX, содержащая 1024 пакета из 256 соединений, будет иметь 4 пакета от одного и того же соединения в кольце, но механизм LRO не сможет увидеть, что пакеты принадлежат друг другу, потому что он поддерживает только несколько агрегатов одновременно . После сортировки по хешу RSS пакеты из одного и того же соединения появляются в очереди рядом и могут быть полностью агрегированы механизмом LRO.

Новая цель: TLS на скорости 100 Гбит / с

Итак, работа была сделана. Или это было? Следующей целью было достичь 100 Гбит / с при обслуживании только потоков, зашифрованных TLS.

К этому моменту мы использовали оборудование, очень напоминающее современные OCA на базе флэш-памяти 100GbE: четыре диска NVMe PCIe Gen3 x4, Ethernet 100GbE, ЦП Xeon E5v4 2697A. Благодаря улучшениям, описанным в записи блога Защита конфиденциальности просмотра Netflix в масштабе, мы смогли обслуживать только TLS-трафик со скоростью примерно 58 Гбит / с.

В проблемах с конфликтом блокировок, которые мы наблюдали выше, причина любого увеличения использования ЦП была относительно очевидна из обычных инструментов системного уровня, таких как графики пламени, DTrace или lockstat. Предел в 58 Гбит / с был сравнительно странным. Как и раньше, использование ЦП увеличивалось линейно по мере приближения к пределу в 58 Гбит / с, но затем, когда мы приближались к пределу, использование ЦП увеличивалось почти экспоненциально. Графики пламени просто показали, что все занимает больше времени, без явных горячих точек.

Наконец-то у нас возникло подозрение, что мы ограничены пропускной способностью памяти нашей системы. Мы использовали Инструменты Intel® Performance Counter Monitor Tools для измерения пропускной способности памяти, которую мы использовали при пиковой нагрузке. Затем мы написали простой тест производительности памяти, в котором использовался один поток на ядро ​​для копирования между большими фрагментами памяти, которые не помещались в кеш. Согласно инструментам PCM, этот тест потреблял такой же объем пропускной способности памяти, что и рабочая нагрузка нашего OCA, обслуживающая TLS. Итак, было ясно, что у нас ограниченная память.

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

Чтение Изменить Запись

Поскольку мы используем sendfile () для обслуживания данных, шифрование выполняется из кэша страниц виртуальной памяти в буферы шифрования для конкретного соединения. Это сохраняет нормальный кеш страниц FreeBSD, чтобы можно было обслуживать горячие данные из памяти для множества подключений. Одна из первых вещей, которые нам бросились в глаза, заключалась в том, что библиотека шифрования ISA-L использовала вдвое меньшую полосу пропускания памяти для чтения, чем для записи в память. Изучив информацию о профилировании VTune, мы увидели, что ISA-L каким-то образом считывает как исходный, так и целевой буферы, а не просто записывает в целевой буфер.

Мы поняли, что это произошло потому, что инструкции AVX, используемые ISA-L для шифрования на наших процессорах, работали с 256-битными (32-байтовыми) величинами, тогда как размер строки кэша был 512-битным (64 байта) - таким образом, система запускала выполнять чтение-изменение-запись при записи данных. Проблема в том, что ЦП обычно обращается к системе памяти в виде фрагментов размером с строку кэша размером 64 байта, считывая все 64 байта для доступа даже к одному байту. В этом случае ЦП необходимо было записать 32 байта строки кэша, но использование чтения-изменения-записи для обработки этих записей означало, что он читал всю 64-байтовую строку кэша, чтобы иметь возможность чтобы записать первые 32 байта. Это было особенно глупо, потому что в ближайшее время должна была быть записана вторая половина строки кэша.

После быстрого обмена сообщениями электронной почты с командой ISA-L они предоставили нам новую версию библиотеки, которая использовала невременные инструкции при сохранении результатов шифрования. Невременные файлы обходят кеш и позволяют ЦП прямой доступ к памяти. Это означало, что ЦП больше не считывал данные из буферов назначения, и это увеличило нашу полосу пропускания с 58 Гбит / с до 65 Гбит / с.

Параллельно с этой оптимизацией спецификации для наших конечных производственных машин были изменены с использования более дешевой памяти DDR4–1866 на использование памяти DDR4–2400, которая была самой быстрой памятью из поддерживаемых для нашей платформы. С более быстрой памятью мы смогли работать со скоростью 76 Гбит / с.

Оптимизация на основе VTune

Мы потратили много времени на изучение информации о профилировании VTune, переработку многочисленных структур данных ядра для лучшего согласования и использование типов минимального размера, чтобы иметь возможность представлять возможные диапазоны данных, которые могут быть там выражены. Примеры этого подхода включают перестановку полей структур ядра, связанных с TCP, и изменение размера многих полей, которые первоначально были выражены в 1980-х как «длинные», которые должны содержать 32 бита данных, но которые теперь имеют 64 бита. 64-битные платформы.

Еще одна уловка, которую мы используем, - избегать доступа к редко используемым строкам кэша больших структур. Например, структура данных mbuf FreeBSD невероятно гибкая и позволяет ссылаться на множество различных типов объектов и упаковывать их для использования в сетевом стеке. Одним из основных источников промахов в кэше при нашем профилировании был код для освобождения страниц, отправленных sendfile (). Соответствующая часть структуры данных mbuf выглядит так:

struct m_ext {
volatile u_int  *ext_cnt;       /* pointer to ref count info */
caddr_t          ext_buf;       /* start of buffer */
uint32_t         ext_size;      /* size of buffer, for ext_free */
uint32_t         ext_type:8,    /* type of external storage */
                 ext_flags:24;  /* external storage mbuf flags */
void            (*ext_free)     /* free routine if not the usual */
                (struct mbuf *, void *, void *);
void            *ext_arg1;      /* optional argument pointer */
void            *ext_arg2;      /* optional argument pointer */
};

Проблема в том, что arg2 попал в 3-ю строку кэша mbuf и был единственным, что было доступно в этой строке кэша. Хуже того, в нашей рабочей нагрузке arg2 почти всегда был NULL. Таким образом, мы платили за чтение 64 байта данных за каждые 4 КБ, которые мы отправили, причем этот указатель был ПУСТО (NULL) практически все время. После того, как не удалось сжать mbuf, мы решили увеличить ext_flags, чтобы сохранить достаточно состояния в первой строке кэша mbuf, чтобы определить, было ли ext_arg2 NULL. Если это так, то мы просто явно передали NULL, вместо того, чтобы разыменовать ext_arg2 и получить промах в кэше. Это позволило увеличить пропускную способность почти на 1 Гбит / с.

Уходим с нашего собственного пути

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

Первый пример - это показатель, отслеживаемый нашим балансировщиком нагрузки: количество TCP-соединений. Эта метрика необходима для того, чтобы программа балансировки нагрузки могла определить, перегружена или недогружена система. Ядро не экспортирует количество подключений, но предоставляет способ экспортировать всю информацию о TCP-подключениях, что позволяет инструментам пользовательского пространства вычислять количество подключений. Это было хорошо для серверов меньшего размера, но с десятками тысяч подключений накладные расходы были заметны на наших OCA 100GbE. Когда его попросили экспортировать соединения, ядро ​​сначала заблокировало хэш-таблицу TCP-соединений, скопировало ее во временный буфер, сбросило блокировку, а затем скопировало этот буфер в пользовательское пространство. Затем пользовательскому пространству пришлось перебирать таблицу, подсчитывая соединения. Это вызвало как промахи в кэше (большое количество ненужной активности памяти), так и конфликт блокировок для хеш-таблицы TCP. Исправить было довольно просто. Мы добавили счетчики без блокировки для каждого процессора, которые отслеживали изменения состояния TCP, и экспортировали количество подключений в каждом состоянии TCP.

Другой пример: мы собирали подробную статистику TCP для каждого TCP-соединения. Цель этой статистики - отслеживать качество сеансов клиентов. Детальная статистика была довольно дорогой, как с точки зрения промахов кеша, так и с точки зрения ЦП. На полностью загруженном сервере 100GbE с несколькими десятками тысяч активных подключений статистика TCP занимала 5–10% ЦП. Решением этой проблемы было ведение подробной статистики только по небольшому проценту подключений. Это снизило использование ЦП статистикой TCP до уровня ниже 1%.

Эти изменения привели к увеличению скорости на 3–5 Гбит / с.

Массивы страниц Mbuf

Система FreeBSD mbuf - это рабочая лошадка сетевого стека. Каждый пакет, который проходит по сети, состоит из одного или нескольких буферов буферов, связанных вместе в списке. Система FreeBSD mbuf очень гибкая и может оборачивать почти любой внешний объект для использования сетевым стеком. Системный вызов FreeBSD sendfile (), используемый для обслуживания основной части нашего трафика, использует эту функцию, помещая каждую страницу размером 4K медиафайла в mbuf, каждая со своими собственными метаданными (бесплатная функция, аргументы бесплатной функции, счетчик ссылок и т. д.).

Недостатком такой гибкости является то, что она приводит к тому, что большое количество буферов буферизации связывается вместе. Один запрос диапазона HTTP размером 1 МБ, проходящий через файл sendfile, может ссылаться на 256 страниц виртуальной машины, и каждая из них будет заключена в mbuf и объединена в цепочку. Это быстро становится грязным.

При скорости 100 Гбит / с мы пропускаем через нашу систему около 12,5 ГБ / с страниц размером 4K в незашифрованном виде. Добавление шифрования удваивает это значение до 25 ГБ / с для страниц размером 4K. Это примерно 6,25 миллиона мегабайт в секунду. Когда вы добавляете дополнительные 2 мегабайта буфера, используемые криптографическим кодом для метаданных TLS в начале и в конце каждой записи TLS, получается еще 1,6 миллиона буферов в секунду, что в сумме составляет около 8 миллионов буферов в секунду. При примерно двух обращениях к строке кэша на mbuf это 128 байт * 8M, что составляет 1 ГБ / с (8 Гбит / с) данных, доступ к которым осуществляется на нескольких уровнях стека (alloc, free, crypto, TCP, буферы сокетов, драйверы, так далее).

Чтобы уменьшить количество передаваемых mbuf, мы решили увеличить mbuf, чтобы можно было переносить несколько страниц одного типа в одном mbuf. Мы разработали новый тип mbuf, который может содержать до 24 страниц для файла отправки, а также может содержать заголовок TLS и конечную информацию в строке (сокращение записи TLS с 6 mbuf до 1). Это изменение уменьшило указанные выше 8 млн буферов в секунду до менее 1 млн буферов в секунду. Это привело к увеличению скорости примерно на 7 Гбит / с.

Это не обошлось без некоторых проблем. В частности, сетевой стек FreeBSD был разработан с учетом того, что он может напрямую обращаться к любой части mbuf с помощью макроса mtod () (mbuf to data). Учитывая, что страницы не отображаются, любой доступ к mtod () вызовет панику в системе. Нам пришлось расширить довольно много функций в сетевом стеке, чтобы использовать функции доступа для доступа к mbuf, обучить систему отображения DMA (busdma) нашему новому типу mbuf и написать несколько средств доступа для копирования mbuf в uios и т. Д. чтобы проверить каждый драйвер сетевой карты, используемый в Netflix, и убедиться, что они использовали busdma для сопоставлений DMA и не обращались к частям mbuf с помощью mtod (). На данный момент у нас есть новые mbuf, включенные для большей части нашего парка, за исключением нескольких очень старых платформ хранения, которые ограничены дисками, а не ЦП.

Мы уже на месте?

На данный момент мы можем комфортно обслуживать 100% TLS-трафик со скоростью 90 Гбит / с, используя стек TCP FreeBSD по умолчанию. Однако стойки ворот продолжают двигаться. Мы обнаружили, что когда мы используем более продвинутые алгоритмы TCP, такие как RACK и BBR, мы все еще немного далеки от своей цели. У нас есть несколько идей, которые мы сейчас реализуем, которые варьируются от оптимизации нового TCP-кода до повышения эффективности LRO до попыток выполнить шифрование ближе к передаче данных (либо с диска, либо на NIC), чтобы Воспользуйтесь преимуществами Intel DDIO и сэкономьте пропускную способность памяти.