Почему ваш софт тормозит: принципы Mechanical Sympathy для разработчиков

от автора

Материал подготовлен а рамках нового потока курса «Инфраструктура высоконагруженных систем».

Современное оборудование работает поразительно быстро, но программное обеспечение часто не умеет этим воспользоваться. «Механическая симпатия» — концепция, заимствованная из автоспорта и популяризированная в разработке ПО Мартином Томпсоном, — это практика создания программ, учитывающих особенности базового аппаратного уровня. Её можно свести к набору прикладных принципов: предсказуемый доступ к памяти, понимание работы строк кэша, принцип одного писателя и естественная пакетная обработка. В совокупности эти принципы позволяют оптимизировать всё — от сервера вывода ИИ-моделей до распределённой платформы обработки данных.

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

И при этом программное обеспечение по-прежнему остаётся медленным: от холодного запуска простых бессерверных функций, занимающего секунды, до ETL-конвейеров, которые часами лишь преобразуют CSV-файлы в строки базы данных.

Ещё в 2011 году инженер в области высокочастотной торговли по имени Мартин Томпсон обратил внимание на эти проблемы и связал их с отсутствием «механической симпатии». Сам термин он позаимствовал у чемпиона «Формулы-1»:

«Вам не обязательно быть инженером, чтобы быть гонщиком, но вам необходима механическая симпатия».

— сэр Джеки Стюарт, чемпион мира «Формулы-1»

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

Вдохновившись работами Мартина, последние десять лет я создавал системы, чувствительные к производительности: от платформ вывода ИИ-моделей, обслуживавших миллионы товаров в Wayfair, до новых бинарных форматов кодирования, превосходящих Protocol Buffers.

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

Не такая уж случайная работа с памятью

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

Рисунок 1. Абстрактная схема организации памяти центрального процессора

Рисунок 1. Абстрактная схема организации памяти центрального процессора

Большинство современных центральных процессоров — от чипов Intel до процессоров Apple — организуют память в виде иерархии регистров, буферов и кэшей, у каждого из которых своя задержка доступа:

  • У каждого процессорного ядра есть собственные высокоскоростные регистры и буферы, которые используются для хранения, например, локальных переменных и инструкций, находящихся в обработке.

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

  • У каждого процессорного ядра есть собственный кэш второго уровня (L2), который ещё больше, чем кэш L1, и служит своего рода буфером между кэшами L1 и L3.

  • Несколько процессорных ядер совместно используют кэш третьего уровня (L3), который намного больше остальных, но при этом заметно медленнее кэшей L1 и L2. Этот кэш нужен для обмена данными между ядрами процессора.

  • Все процессорные ядра имеют общий доступ к основной памяти, то есть к оперативной памяти (RAM). По сравнению со всеми остальными уровнями именно к ней процессору обращаться медленнее всего — на порядок.

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

  • Память, к которой недавно обращались, скорее всего, скоро понадобится снова.

  • Область памяти рядом с недавно использованной, скорее всего, тоже скоро понадобится.

  • Доступ к памяти, скорее всего, будет следовать тому же шаблону.

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

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

Строки кэша и ложное разделение

В кэшах L1, L2 и L3 память обычно хранится «блоками», которые называются строками кэша. Строка кэша всегда представляет собой непрерывный участок памяти длиной, равной степени двойки, и часто составляет 64 байта.

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

Рисунок 2. Абстрактная схема того, как два процессора, обращающиеся к двум разным переменным, всё равно могут конфликтовать, если эти переменные находятся в одной строке кэша.

Рисунок 2. Абстрактная схема того, как два процессора, обращающиеся к двум разным переменным, всё равно могут конфликтовать, если эти переменные находятся в одной строке кэша.

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

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

  • Без такого заполнения ложное разделение строк кэша приводит к почти линейному росту задержки по мере добавления потоков.

  • С таким заполнением задержка при добавлении потоков остаётся почти постоянной.

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

Из-за этого одним из самых частых объектов, страдающих от ложного разделения, становятся атомарные переменные. Это один из немногих типов данных (в большинстве языков), которые можно безопасно разделять между потоками и изменять из разных потоков, а значит, и с разных процессорных ядер.

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

Принцип одного писателя

Ложное разделение — не единственная проблема, возникающая при построении многопоточных систем. Есть вопросы безопасности и корректности, такие как состояния гонки, издержки на переключение контекста, когда потоков становится больше, чем процессорных ядер, и тяжёлые накладные расходы мьютексов («замков»).

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

В основе этот принцип прост: если у приложения есть данные (например, переменная в памяти) или ресурс (например, TCP-сокет), в который оно записывает, все такие записи должен выполнять один поток.

Рассмотрим минимальный пример HTTP-сервиса, который принимает текст и создаёт его векторные представления (или эмбеддинги). Эти векторные представления будут вычисляться внутри самого сервиса с помощью ИИ-модели для эмбеддинга текста. Для примера предположим, что это модель ONNX, хотя здесь подойдут и TensorFlow, и PyTorch, и любая другая среда выполнения для ИИ.

Рисунок 3. Абстрактная схема наивного сервиса для получения текстовых эмбеддингов

Рисунок 3. Абстрактная схема наивного сервиса для получения текстовых эмбеддингов

Такой сервис быстро столкнётся с проблемой: большинство сред выполнения ИИ умеют выполнять только один вызов инференса для модели за раз. В наивной архитектуре выше мы используем мьютекс, чтобы обойти это ограничение. К сожалению, если в сервис одновременно придёт несколько запросов, они выстроятся в очередь к мьютексу и быстро упрётся в блокировку в голове очереди.

Рисунок 4. Абстрактная схема сервиса для получения текстовых эмбеддингов, использующего принцип одного писателя и пакетную обработку

Рисунок 4. Абстрактная схема сервиса для получения текстовых эмбеддингов, использующего принцип одного писателя и пакетную обработку

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

Поскольку актор выступает единственным писателем, он может объединять независимые запросы в один пакетный вызов инференса к базовой модели, а затем асинхронно возвращать результаты обратно в отдельные потоки запросов.

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

Естественная пакетная обработка

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

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

Существует более удачный подход, чем оба этих варианта: естественная пакетная обработка.

Естественная vs «умная» пакетная обработка

В оригинальной статье Мартин Томпсон использует термин «умная пакетная обработка» (Smart Batching) вместо «естественной». Обсуждая черновик этой статьи, он отметил, что теперь предпочитает термин «естественная» — и, учитывая, сколько раз на моих воркшопах по механической симпатии спрашивали, чем именно пакетная обработка может быть «умной», я с ним согласен.

При естественной пакетной обработке актор начинает формировать пакет, как только в очереди появляются запросы, и завершает его, как только достигается максимальный размер пакета или очередь опустеет.

Взяв пример из оригинальной статьи Мартина о естественной пакетной обработке, можно увидеть, как этот подход распределяет задержку на отдельный запрос во времени:

Стратегия

Лучший случай (мкс)

Худший случай (мкс)

По таймауту

200

400

Естественная

100

200

В этом примере предполагается, что каждый пакет обрабатывается с фиксированной задержкой в 100 мкс.

При стратегии пакетирования по таймауту, если задать таймаут в 100 мкс, минимальная задержка составит 200 мкс, когда все запросы приходят одновременно (100 мкс на сам запрос и ещё 100 мкс ожидания перед отправкой пакета). В худшем случае задержка достигнет 400 мкс, если часть запросов придёт с небольшой задержкой.

При стратегии естественной пакетной обработки минимальная задержка составит 100 мкс, если все запросы в пакете поступают одновременно. В худшем случае задержка достигнет 200 мкс, если часть запросов приходит с небольшой задержкой.

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

Если один писатель обрабатывает пакеты записей (или даже чтений), формируйте каждый пакет «жадно»: начинайте его, как только появляются данные, и завершайте, когда очередь данных пуста или достигнут максимальный размер пакета.

Эти принципы хорошо работают для отдельных приложений, но масштабируются и на целые системы. Последовательный, предсказуемый доступ к данным одинаково применим как к озеру данных, так и к массиву в памяти. Принцип одного писателя может повысить производительность приложений с интенсивным вводом-выводом или стать надёжной основой для архитектуры CQRS.

Когда мы пишем программное обеспечение с учётом механической симпатии, высокая производительность становится естественным следствием — на любом масштабе.

Но прежде чем применять эти принципы, важно расставить приоритеты: сначала наблюдаемость, потом оптимизация. Нельзя улучшить то, что нельзя измерить. Перед внедрением этих подходов определите показатели уровня сервиса (SLI), целевые уровни сервиса (SLO) и соглашения об уровне сервиса (SLA), чтобы понимать, куда направлять усилия и где остановиться.

Сначала обеспечьте наблюдаемость, затем оптимизируйте: измерьте производительность и определите цели, прежде чем применять эти принципы.

В продолжение темы — два бесплатных открытых урока по инфраструктуре highload. Это хороший способ вживую посмотреть, как такие задачи разбирают на практике: познакомиться с преподавателями, понять формат обучения, задать свои вопросы и заодно проверить, где у вас ещё остаются пробелы.

  • 27 апреля в 20:00 — «Настройка кластера Elasticsearch». Разберём, как собирать масштабируемый и отказоустойчивый кластер, работать с шардами и репликами, добавлять ноды и не ловить типовые ошибки в конфигурации. Записаться

  • 14 мая в 20:00 — «Оптимизация Nginx и Angie под высокие нагрузки». Рассмотрим, как настраивать сервер под большой поток запросов, оптимизировать TLS, использовать кэширование и адекватно мерить производительность. Записаться

Немного практики в тему — пройдите вступительный тест по инфраструктуре highload и узнаете, есть ли пробелы в знаниях. Бонус за прохождение теста: скидка 15% на курс, а также запись урока «Настройка Nginx для высоких нагрузок и защиты от DoS-атак»

ссылка на оригинал статьи https://habr.com/ru/articles/1025964/