MAATRIX / Блог / Сто тысяч запросов в секунду на одном ядре: считаем стоимость запроса в тактах

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

MAATRIX

Нагрузочный тест упирается в потолок RPS, и первый вопрос обычно звучит как «почему именно столько, а не в полтора раза больше». Ответ редко ищут на уровне процессора — сразу лезут в код, в базу, в сеть. А ведь у каждого запроса есть буквально физическая цена: количество тактов, которое ядро тратит на его обработку. Если знать эту цену и частоту ядра, можно посчитать теоретический потолок пропускной способности ещё до того, как запускать нагрузочный тест — а потом сравнить с реальностью и понять, где именно теряются такты.

Формула: тактов в секунду делим на тактов на запрос

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

RPS_max = f / M

где f — реальная частота ядра в тактах в секунду, M — среднее число тактов на один запрос.

Это именно теоретический потолок, а не прогноз. Он предполагает, что ядро всё время занято именно этим запросом, что нет других процессов, которые его прерывают, и что M измерено честно, а не выдумано. На практике формула нужна не для того, чтобы предсказать цифру RPS с точностью до процента, а для того, чтобы иметь опорную точку: если измеренная пропускная способность в разы ниже этого потолка — точно есть что оптимизировать на уровне того, как код исполняется процессором, а не только на уровне алгоритма.

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

Из чего складывается M: инструкции и IPC

Такты на запрос — не первичная величина, которую вы контролируете напрямую. Первичны две другие вещи:

  • Число инструкций, которое процессор должен выполнить, чтобы обработать один запрос: разбор входных данных, работа с памятью (аллокации), бизнес-логика, сериализация ответа, системные вызовы к ядру ОС.
  • IPC (instructions per cycle) — сколько из этих инструкций реально выполняется за один такт. Современные ядра суперскалярные и умеют исполнять несколько инструкций за такт параллельно, но это возможно только тогда, когда инструкции независимы друг от друга и данные для них уже под рукой.

Отсюда:

M = instructions_per_request / IPC_achieved

Здесь и разворачивается вся суть оптимизации производительности. Число инструкций на запрос — это в основном вопрос алгоритма и языка: меньше лишней работы, меньше аллокаций, меньше слоёв абстракции — меньше инструкций. А IPC — это вопрос того, насколько гладко эти инструкции исполняются: есть ли у процессора данные в кеше прямо сейчас, предсказал ли он ветвление правильно, не ждёт ли он ответа от ядра ОС. Пиковый IPC конкретного ядра — паспортная характеристика микроархитектуры (у современных серверных ядер это обычно единицы инструкций за такт в пике), но в реальном коде достигнутый IPC почти всегда заметно ниже пикового — и разрыв между ними это ровно то, что вы теряете на простоях.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Как измерить M практически: perf stat

Теория без измерения бесполезна, потому что и число инструкций, и достигнутый IPC — вещи, которые нужно снимать с конкретного бинарника на конкретной нагрузке, а не оценивать на глаз. На Linux для этого есть perf — обвязка над аппаратными счётчиками производительности процессора (PMU).

Минимальный снимок метрик работающего процесса:

perf stat -e task-clock,cycles,instructions,cache-references,cache-misses,branch-misses,context-switches -p $(pgrep -n myapp) -- sleep 10

Вывод покажет, помимо прочего, строку вида X insn per cycle — это и есть достигнутый IPC на измеряемом окне. Если запускаете сам бинарник, а не подключаетесь к уже работающему процессу:

perf stat -d ./myapp

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

Дальше нужен способ превратить «тактов за 10 секунд» в «тактов на один запрос». Практический метод: держите приложение под стабильной нагрузкой (например, через wrk или hey), снимайте perf stat на том же окне времени, что и замер нагрузочного инструмента, и берите число обработанных запросов из метрик приложения или access-лога за тот же интервал. Тогда:

M = total_cycles_за_окно / requests_за_то_же_окно

Пара нюансов, которые ломают измерение, если их не учесть:

  • Привяжите процесс к одному ядру через taskset -c 2 ./myapp, иначе планировщик ОС может перекидывать поток между ядрами прямо во время замера, и частота у разных ядер (турбо-буст, троттлинг) окажется разной — цифра «тактов» останется честной, а вот привязка её к конкретной частоте ядра — нет.
  • На части виртуализированных окружений доступ к PMU-счётчикам ограничен гипервизором — тогда perf stat либо не покажет часть событий, либо покажет их с оговоркой <not supported>. Это стоит проверить заранее, а не в разгар инцидента: часть облачных и VPS-хостов пробрасывает базовые счётчики (cycles, instructions), но не все специализированные события — конкретный набор зависит от гипервизора и его настроек.
  • perf_event_paranoid может блокировать доступ к счётчикам без привилегий — на своих серверах это регулируется через sysctl kernel.perf_event_paranoid, но требует прав root и осознанного решения, а не бездумного снижения защиты.

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

Почему реальность всегда хуже теории

Если посчитать оптимистичный M «на бумаге» — по числу инструкций в горячем пути кода и паспортному пиковому IPC ядра — а потом сравнить с M, измеренным через perf stat на реальной нагрузке, разрыв почти всегда будет заметным, а часто и кратным. Причина не в том, что процессор «плохо считает» — она в том, что теоретический IPC предполагает, что у процессора всегда есть чем заняться на каждом такте, а в реальности он регулярно простаивает в ожидании:

  • Промахов кеша. Обращение к данным, которых нет в L1/L2/L3, означает поход в оперативную память — а это на порядки медленнее обращения к кешу. Один случайный промах может «съесть» столько тактов простоя, сколько ушло бы на десятки инструкций при удачном попадании. Как именно устроена эта иерархия и почему порядок обхода памяти решает всё — в статье про кеш процессора и судьбу цикла.
  • Системных вызовов. Каждый переход в режим ядра (чтение сокета, запись в файл, аллокация страниц памяти) — это не «бесплатная» инструкция, а полноценное переключение контекста с сохранением состояния и последующим возвратом. Если запрос дёргает несколько syscall без необходимости, это заметная доля бюджета тактов, которая не видна в бизнес-логике кода.
  • Промахов предсказания ветвлений. Процессор заранее исполняет инструкции по предсказанному пути if/else, и если предсказание ошиблось — конвейер приходится сбрасывать и начинать заново. Непредсказуемые ветвления (например, зависящие от случайных входных данных) обходятся дороже предсказуемых.
  • Конкуренции за ресурсы ядра между потоками. Блокировки, атомарные операции и «ложное разделение» кеш-линий между потоками добавляют простоя, которого не было бы в однопоточном сценарии.
  • Аллокаций памяти. Каждый malloc/new в горячем пути — это не одна инструкция, а обращение к аллокатору, которое само по себе стоит десятков и сотен инструкций, плюс потенциальный промах кеша на новой странице памяти.

На виртуальном сервере к этому добавляется ещё один источник простоя, которого не видно в коде вообще — steal time: доля тактов, которую гипервизор отдаёт соседним виртуальным машинам на том же физическом ядре. Если этот показатель растёт, ваш процесс получает меньше реальных тактов в секунду, чем показывает частота в характеристиках тарифа — и M, измеренный на одной и той же нагрузке, начинает «плыть» без единой правки в коде. Как отличить эту ситуацию от собственно медленного кода — в статье про steal time и то, как понять, что сосед ест ваш CPU.

Пример расчёта — с явной оговоркой про условность цифр

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

Допустим, ядро работает на частоте около 3 ГГц (3 × 10⁹ тактов в секунду — условное значение для иллюстрации). Пусть лёгкий обработчик запроса (разбор небольшого JSON, минимальная бизнес-логика, сериализация ответа) выполняет условно 60 000 инструкций, а достигнутый IPC на этом пути — около 2. Тогда:

M = 60 000 / 2 = 30 000 тактов на запрос
RPS_max = 3 × 10⁹ / 30 000 = 100 000 запросов в секунду на одно ядро

Это и есть тот самый теоретический потолок «сто тысяч запросов в секунду на одном ядре» — но именно теоретический: он посчитан при пиковом достижимом IPC и без единого промаха кеша, syscall'а сверх минимума или чужого потока, отбирающего такты. В реальном нагрузочном тесте с той же логикой измеренный M почти всегда окажется выше расчётного — вопрос в том, во сколько раз, и это уже честно измеряемая, а не теоретическая величина. Если разрыв кратный (условно в разы) — почти наверняка узкое место в стойках (кеш, syscall, память), а не в вычислительной сложности самой бизнес-логики.

Зачем вообще думать в тактах при оптимизации

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

  • Если измеренный M близок к оптимистичной оценке «инструкции / пиковый IPC» — ядро уже работает почти на пределе для этого кода, и дальнейшая оптимизация возможна только за счёт уменьшения числа инструкций (другой алгоритм, меньше аллокаций, меньше слоёв абстракции) или за счёт масштабирования на большее число ядер. Понять, сколько ядер приложение реально использует уже сейчас, а не сколько выделено в тарифе, можно через mpstat -P ALL и профилировщик потоков — это отдельная диагностическая задача.
  • Если разрыв между измеренным M и оптимистичной оценкой большой — оптимизировать алгоритм почти бессмысленно, пока не убраны простои: неудачный доступ к памяти, лишние syscall'ы на запрос, конкуренция потоков за общие структуры данных. Здесь горизонтальное масштабирование (больше ядер, больше инстансов) даёт прирост, но не решает первопричину — оно просто размножает один и тот же неэффективный путь.
  • Такты на запрос — метрика, которая переживает смену железа лучше, чем секунды на запрос: если вы сравниваете две реализации одной и той же функции, сравнение по инструкциям и IPC честнее, чем сравнение по «request/sec», потому что не зависит от того, на каком конкретно сервере вы гоняли тест в этот раз.
  • Эта же логика подсказывает, когда для сервиса вообще не нужен более мощный тариф — если измеренный M уже близок к теоретическому пределу, а нагрузка растёт, разумнее посчитать, сколько ядер нужно под целевой RPS, чем пытаться выжать больше из одного.

Ограничения метода, которые стоит держать в голове

Формула RPS_max = f / M — упрощение с понятными границами применимости:

  • Она предполагает один тип запроса с одинаковой стоимостью. В реальном сервисе запросы разные — GET и POST, кешированный и некешированный путь, — и усреднённый M имеет смысл только на однородной нагрузке или как взвешенное среднее по миксу запросов.
  • Она не учитывает, что при блокирующем ожидании I/O ядро освобождается для другой работы — в асинхронных и многопоточных архитектурах реальная пропускная способность может быть выше «наивного» RPS_max именно потому, что во время ожидания ответа от диска или сети ядро занято другим запросом.
  • Частота f не константа — турбо-буст и троттлинг меняют её на лету, поэтому «частота» в формуле — это эффективная частота на измеряемом окне, а не паспортное число из характеристик процессора.
  • На многоядерных и NUMA-конфигурациях межъядерный трафик когерентности кеша добавляет ещё один источник простоя, который эта формула для одного ядра не описывает вовсе.

Метод не заменяет нагрузочное тестирование — он даёт язык, на котором его результаты можно объяснить, а не просто зафиксировать факт «упёрлись в 40 тысяч RPS и непонятно почему».

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Формула зависит от языка программирования?

Да, но не в формуле дело, а в M. У интерпретируемых языков (Python, Ruby) число инструкций на «условно одну и ту же операцию» обычно на порядок больше, чем у скомпилированных языков или языков с JIT — за счёт байткод-интерпретации, динамической типизации и служебных структур объектов. Методика измерения (perf stat, деление на число запросов) одинаковая для любого языка.

Можно ли снять эти метрики на обычном VPS с виртуальным процессором?

Частично зависит от гипервизора: базовые счётчики (cycles, instructions) на большинстве KVM-хостов доступны, но не все специализированные PMU-события проброшены в гостевую систему. Плюс steal time искажает связь между «тактами» и «секундами» — на нагруженном соседями хосте эффективная частота ниже номинальной.

Турбо-буст ломает расчёт?

Не сами такты — счётчик cycles считает реальные аппаратные такты независимо от того, на какой частоте они шли. Ломается перевод «тактов в секунду» в удобные единицы: если частота на измеряемом окне менялась, эффективная f — это средняя частота за окно, а не число из спецификации процессора. Для чистого замера можно временно зафиксировать частоту через cpupower frequency-set.

Что делать, если измеренный IPC намного ниже пикового для ядра?

Значит, узкое место не в объёме вычислений, а в простоях — искать нужно через perf stat -d долю промахов кеша, через strace -c — частоту и стоимость системных вызовов, и через профилировщик — конкретные строки кода, где ядро чаще всего «встаёт» в ожидании памяти. Оптимизация алгоритма в этой ситуации даст мало — сначала нужно убрать простои.

Стоит ли гнаться за теоретическим потолком RPS на практике?

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

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →