MAATRIX / Блог / Процессор загружен на 100%, но не считает: как поймать упор в память

Процессор загружен на 100%, но не считает: как поймать упор в память

MAATRIX

Вы открываете top, видите столбец %CPU под сотней, делаете логичный вывод «процессор — узкое место» и идёте искать более мощный тариф. А после апгрейда на более быстрый CPU задача обрабатывается ровно с той же скоростью, что и раньше. Дело в том, что «100% загрузки» — это метрика времени, в течение которого ядро не простаивало, а не метрика полезной работы за это время. Процессор мог всё это время не считать, а стоять и ждать данные из оперативной памяти. Разберёмся, как это увидеть и что с этим делать.

Что на самом деле означают %us и %sy

Столбцы top, vmstat и mpstatus (user), sy (system), id (idle), wa (iowait), st (steal) — показывают долю времени интервала, которую ядро провело в том или ином режиме исполнения. Источник данных — счётчики /proc/stat, которые ядро инкрементирует на каждом тике планировщика в зависимости от того, что выполнялось на CPU в этот момент: код пользовательского процесса, код ядра, простаивающий idle-поток или ожидание ввода-вывода.

Ключевой нюанс: пока на ядре исполняется поток вашего приложения (а не idle-поток), это время засчитывается как us, даже если сам поток буквально ничего полезного не делает — например, стоит в ожидании данных из DRAM после промаха мимо кеша. Стадия конвейера, застрявшая в ожидании операнда (stalled cycle), физически всё ещё принадлежит потоку: планировщик не переключает его на кого-то другого, потому что поток формально runnable, просто исполняет команды медленно. Отсюда парадокс: %us = 100%, а полезных инструкций за секунду в разы меньше, чем при работе с данными, которые целиком помещаются в кеш.

Настоящая метрика полезной работы — IPC (instructions per cycle, сколько инструкций процессор доводит до завершения за такт). Здоровый CPU-bound код без частых промахов кеша обычно держит IPC заметно выше единицы. Когда конвейер регулярно стоит в ожидании памяти, IPC проседает в несколько раз — и ни top, ни vmstat этого не покажут: они меряют занятость ядра, а не эффективность такта. Отдельно стоит %st (steal) — на арендованном сервере это доля времени, отобранная гипервизором в пользу соседей; путать steal с упором в память не стоит, это отдельная причина «CPU занят, а не считает», характерная для виртуализации с переподпиской.

Три сценария, которые выглядят одинаково в top

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

ПризнакРеальный CPU-boundСвоп-трэшингУпор в пропускную способность памяти
%us в topВысокий, стабильныйМожет быть высоким или среднимВысокий, стабильный
%sy в topНизкийЧасто повышен (обработка page fault)Низкий-средний
%wa (iowait)Около нуляЗаметный, если swap на медленном дискеОколо нуля
vmstat si/so0Больше нуля, растёт под нагрузкой0
majflt (major faults)ЕдиничныеМассовыеЕдиничные
IPC (perf stat)Близко к норме для кодаНизкий, но причина — ожидание диска, а не DRAMНизкий из-за промахов кеша/DRAM-латентности
Что лечитБольше ядер, оптимизация алгоритмаБольше RAM, снижение потребления памятиБольше RAM-каналов, локальность данных, NUMA-тюнинг

Дальше — как отличить эти три ситуации инструментами, а не на глаз.

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

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

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

Как поймать своп-трэшинг: vmstat, free, sar -W

Первое, что стоит проверить — не участвует ли в игре подкачка. free -h покажет, использован ли своп вообще (Swap: used), но не покажет динамику. Динамику даёт vmstat 1:

procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 4  2 812000  98304  12000 240000  512  380   600   420 8200 15400 55 30  0 15  0

Колонки si/so — сколько килобайт в секунду ядро читает со свопа (swap-in) и пишет на своп (swap-out). Если эти числа стабильно больше нуля под нагрузкой — это живое пейджирование, а не разовый всплеск при старте процесса: ядро постоянно выталкивает страницы одного процесса, чтобы освободить место для другого, и тут же вынуждено подкачивать их обратно.

Профиль своп-трэшинга: заметный %sy (ядро тратит время на page fault handler) и, если своп на вращающемся диске или перегруженном сетевом томе, ощутимый %wa. На быстром NVMe-свопе wa почти незаметен, картина сводится к высокому sy и постоянным si/so — тогда без vmstat отличить это от упора в память по одному top действительно сложно.

Более развёрнутую картину по времени даёт sar из пакета sysstat:

sar -B 1 5    # статистика пейджинга: pgpgin/s, pgpgout/s, majflt/s
sar -W 1 5    # именно swap: pswpin/s, pswpout/s
sar -r 1 5    # память: kbmemfree, kbcached, kbcommit, %commit

majflt/s — количество мажорных page fault в секунду, случаев, когда странице пришлось идти на диск (в файл или в своп), а не просто получить новую физическую страницу. Рост majflt/s вместе с ростом pswpin/s/pswpout/s из sar -W — однозначный признак, что часть «загрузки CPU» уходит на обслуживание подкачки, а не на вычисления приложения. Если si/so и majflt близки к нулю, а загрузка CPU остаётся высокой — своп можно вычеркнуть и переходить к следующему шагу.

perf: считаем IPC и промахи кеша напрямую

Когда своп не виноват, вопрос в том, тратит ли процессор такты на реальные вычисления или простаивает в ожидании памяти. Прямой ответ даёт perf stat:

perf stat -e cycles,instructions,cache-references,cache-misses,LLC-load-misses -p <PID> -- sleep 10

Из вывода интересны две вещи: IPC (перф сам печатает его как «insn per cycle») и доля промахов кеша — cache-misses относительно cache-references, отдельно LLC-load-misses — промахи мимо кеша последнего уровня, после которых данные приходится тащить прямо из DRAM с латентностью на порядок выше, чем из кеша. Если IPC заметно ниже ожидаемого для вашего типа кода и доля LLC-промахов высокая — процессор большую часть времени не считает, а ждёт память.

Для поиска конкретных «горячих» мест в коде полезен sampling по событию промахов, а не по времени:

perf record -e cache-misses -c 100000 -p <PID> -- sleep 10
perf report

Это покажет функции, где промахи концентрируются — чаще всего произвольный доступ по указателям (pointer chasing), обход больших хеш-таблиц или связных списков, разбросанных по памяти, либо работа с массивами, которые целиком не помещаются в L2/L3. Если «горячая» функция несложная арифметически, но занимает львиную долю времени — дело в паттерне доступа к памяти, а не в объёме вычислений.

Если perf недоступен в контейнере или урезанном ядре (частая история с некоторыми VPS без проброса PMU-счётчиков) — это тоже сигнал: точную IPC-картину снаружи гостевой ОС не получить, остаётся судить косвенно по mpstat -P ALL и поведению приложения под разной нагрузкой.

Что такое упор в пропускную способность памяти

У памяти есть физический потолок — сколько байт в секунду контроллер способен прокачать через себя в оба направления. Этот потолок общий на процессор (а в двухсокетных системах — на сокет, см. следующий раздел) и делится между всеми ядрами и потоками, которые одновременно обращаются к DRAM. Это принципиально иная величина, чем частота CPU или число ядер: можно наращивать количество вычислительных потоков сколько угодно, но если все они молотят по памяти с низкой локальностью данных, суммарная пропускная способность упрётся в потолок контроллера — и дальнейшее добавление потоков не увеличит throughput, а иногда снизит его из-за конкуренции за шину.

Внешне это снова «CPU 100%, а толку мало»: ядра непрерывно выставляют запросы к памяти и ждут ответа, планировщик не переключает их в idle, поток технически runnable. Но полезная работа блокирована скоростью, с которой данные физически успевают доехать от DRAM до регистров.

Классический пример — обработка данных с низкой arithmetic intensity (мало арифметики на каждый прочитанный байт): построчный проход по огромному массиву, поиск по неотсортированным структурам, инференс языковых моделей на CPU, где на каждый token нужно перечитать все веса модели из памяти. Похожая логика разбирается в статье про пропускную способность памяти как потолок локальной LLM — там тот же эффект на GPU-инференсе: вычислительные блоки заняты на треть, а узкое место — скорость чтения весов.

Точный потолок пропускной способности (число каналов памяти, их частота, топология сокетов) — характеристика конкретного процессора и платы, не буду называть здесь цифры ГБ/с, чтобы не вводить в заблуждение — ориентируйтесь на спецификацию своего CPU и на то, сколько каналов памяти реально заполнено модулями (заполнение не всех доступных каналов — частая причина, по которой сервер «на бумаге» мощный, а по факту упирается в память раньше, чем должен). Оценить собственный потолок можно синтетическим тестом вроде STREAM benchmark — он не даёт ответа «хватает или нет», но показывает порядок величины реальной пропускной способности именно на вашем железе.

NUMA-эффекты: когда память «не своя»

На многосокетных серверах архитектура NUMA (Non-Uniform Memory Access) добавляет ещё один слой: у каждого процессора свой контроллер и «своя» физически ближняя память, а доступ к памяти другого сокета идёт через межпроцессорную шину (QPI/UPI у Intel, Infinity Fabric у AMD) — медленнее и с дополнительной нагрузкой на эту шину. Подробно механика разобрана в статье о том, что такое NUMA и почему два процессора иногда медленнее одного; здесь — диагностический угол.

Если процесс запланирован на ядро одного сокета, а память ему выделена (или мигрировала) на другом — каждое обращение становится «удалённым», с более высокой латентностью. Внешне это снова «CPU занят, throughput низкий», но причина не в общем потолке DRAM, а в том, что трафик идёт через более узкое и медленное соединение, чем мог бы.

Проверяется так:

numactl --hardware        # топология: сколько нод, сколько памяти на каждой
numastat -p <PID>         # локальные/удалённые обращения к памяти для процесса
numastat -m                # общая картина по нодам: numa_hit, numa_miss, numa_foreign

Рост numa_miss и numa_foreign в numastat -m — признак, что аллокатор регулярно промахивается мимо «правильной» ноды. Лечится привязкой процесса к CPU и памяти одного сокета (numactl --cpunodebind=0 --membind=0 ./app), либо, для приложений, которые сознательно работают со всеми нодами сразу, — равномерным чередованием выделения памяти (numactl --interleave=all). Универсального ответа нет: для однопоточного сервиса с большим потреблением памяти чаще выигрывает жёсткая привязка к одной ноде, для многопоточного приложения, размазанного по всем ядрам сервера, — interleave.

Практическая диагностика и что чинить

Соберём последовательность действий, которая на практике быстрее всего разводит три сценария из таблицы выше.

  1. Проверить своп. free -h и vmstat 1 минуту под нагрузкой. Если si/so стабильно больше нуля и растёт majflt/s в sar -B — к пункту 2. Если нет — к пункту 3.
  2. Если своп активен, найти причину. Часто это либо реальный дефицит памяти (рабочий набор больше физической RAM), либо утечка в процессе, либо агрессивный page cache. Проверьте dmesg | grep -i "oom\|killed process" — если ядро уже убивало процессы через OOM killer, память была исчерпана всерьёз. Дальше: добавить RAM, снизить память приложения (лимиты в конфигах БД, воркеров) или перераспределить нагрузку. Механика подкачки и разумные значения vm.swappiness разобраны в статье как работает своп и почему его не надо отключать — коротко: снижать swappiness с дефолтных 60 до 10-20 для БД и латентно-чувствительных сервисов обычно оправдано, полностью убирать своп — почти никогда:
sysctl vm.swappiness=10
echo 'vm.swappiness=10' > /etc/sysctl.d/99-swappiness.conf
sysctl --system
  1. Если своп ни при чём — считать IPC и промахи кеша. perf stat -e cycles,instructions,cache-misses,LLC-load-misses -p PID -- sleep 10. Низкий IPC и высокая доля LLC-промахов — упор в память.
  2. Проверить многосокетность. numactl --hardware, затем numastat -p PID под нагрузкой. Заметная доля numa_miss/numa_foreign — повод привязать процесс к ноде или включить interleave.
  3. Оптимизировать доступ к памяти в коде, если perf report указывает на конкретные функции: группировать данные с совместным доступом рядом (data locality), обрабатывать массивы блоками, помещающимися в L2/L3 (tiling), заменять pointer chasing на массивы, где можно.
  4. Пересчитать число параллельных воркеров. Если throughput перестаёт расти (или падает) при увеличении потоков сверх порога — прямой симптом упора в контроллер памяти. Оптимум для memory-bound задачи часто заметно меньше числа физических ядер, подбирается экспериментально.
  5. Рассмотреть железо, если приложение не оптимизировать: заполнение всех каналов памяти на сокет, платформа с большим числом каналов, либо просто больше RAM, если проблема в объёме и связанной с ним подкачке.

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

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

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

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

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

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

top показывает 100% CPU и высокий %sy — это точно своп?

Не обязательно. Высокий %sy бывает и без свопа: много системных вызовов, частые переключения контекста, работа с сетевым стеком под нагрузкой. Проверяйте si/so в vmstat отдельно — это точный признак именно подкачки, а не системного времени вообще.

Можно ли определить упор в память вообще без perf, только через top/vmstat?

Косвенно да: своп исключается через si/so=0 и majflt≈0, а дальше, если throughput приложения не растёт при добавлении потоков и ядер, а CPU при этом занят на 100% — это сильный намёк на упор в bandwidth или NUMA, даже без точной IPC-цифры. Но точное подтверждение и локализация — только через perf или профилировщик уровня приложения.

Добавление RAM поможет, если проблема в пропускной способности, а не в объёме?

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

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

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

%st (steal time) — это тоже про память?

Нет, это отдельная причина «CPU занят, а не считает»: гипервизор на переподписанном хосте забирает такты в пользу соседних виртуалок. Диагностируется через тот же top/mpstat (столбец st), лечится не тюнингом памяти, а сменой тарифа или провайдера с меньшей переподпиской CPU.

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

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

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