Седьмого октября 2026 года в Праге, на конференции Linux Plumbers Conference, в секции sched_ext прошёл короткий, всего восемнадцатиминутный доклад. Выступал Gavin Guo, соавтором выступил David Dai. Речь шла о том, как планировщик LAVD выбирает процессорное ядро для проснувшейся задачи. Вопрос звучит почти по-детски: если одно ядро простаивает, а другое занято, зачем задаче ждать? Ответ неочевиден. Свободное ядро бесплатно только на бумаге: задача приезжает туда без своих данных в кэшах, и ей приходится заново собирать всё, что она держала под рукой на прежнем месте.

Это не объявление нового стабильного ядра Linux и не очередной рекорд производительности. Готового универсального алгоритма и гарантированного процента ускорения доклад не заявляет. Это обсуждение архитектуры, и полезно оно именно этим: оно показывает, где проходит граница между принципом "не оставлять процессор без дела" и необходимостью беречь накопленное тепло кэша.

Планировщик LAVD из sched_ext при пробуждении задачи в первую очередь ищет полностью свободное ядро процессора

Сначала о действующих лицах. Механизм sched_ext позволяет писать планировщики задач на BPF и загружать их в работающее ядро Linux без пересборки. В основную ветку он попал в версии 6.12. Один из таких планировщиков называется scx_lavd, а LAVD расшифровывается как Latency-criticality Aware Virtual Deadline. Идея в двух шагах: сначала оценить, насколько задача чувствительна к задержке, а затем использовать эту оценку при выборе дедлайна, длины кванта времени и других решений. Начинался он в 2024 году как эксперимент для игровой консоли Steam Deck, где важнее всего плавность, а не пиковая пропускная способность.

У LAVD, как и у многих планировщиков класса sched_ext, есть принцип, который называют сохранением работы (work conserving). Смысл простой: пока в очереди ждёт готовая к выполнению задача, ни одно ядро не должно простаивать. Когда задача просыпается, вызывается процедура выбора процессора select_cpu, и она изо всех сил ищет простаивающее ядро. На системах с SMT предпочтение получает ядро, у которого свободен и соседний аппаратный поток, потому что там доступно больше вычислительной мощности.

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

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

Миграция на холодное ядро заставляет заново наполнять кэши L1 и L2 и буфер трансляции адресов TLB

Чтобы понять масштаб цены, стоит вспомнить, как устроена память процессора. У каждого ядра свои кэши первого и второго уровня: L1 для данных составляет обычно 32-48 КиБ, L2 около 1-2 МиБ на ядро, у разных поколений по-разному. Общий кэш третьего уровня делится между многими ядрами. Рядом с ними работает буфер трансляции адресов TLB, который хранит готовые соответствия виртуальных адресов физическим. Вся эта конструкция похожа на рабочий стол: бумаги, которые лежат под рукой, берутся за секунду, а те, что в шкафу в коридоре, требуют прогулки.

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

Любопытно, что к такому же выводу разработчики пришли и на уровне кода. В конце июля 2026 года в scx_lavd появилась ручка, регулирующая липкость задачи к прежнему процессору по её "теплоте" (task warmth). В описании изменения сказано прямо: для сервисов кэширования, зависящих и от вычислений, и от пропускной способности памяти, чрезмерные миграции вызывают перегрузку TLB и снижение IPC, то есть числа инструкций за такт. Заметная часть лишних промахов TLB приходится на обращения к куче приложения.

Попробуем посчитать порядок величин. Пусть рабочий набор данных задачи занимает 1 МиБ в кэше L2. При размере строки 64 байта это 16 384 строки (1 048 576 / 64). Допустим, что каждая промахнувшаяся строка достаётся из L3 за 40 нс. Если процессор умеет держать в полёте 8 запросов к памяти одновременно, повторное наполнение займёт около 16 384 × 40 / 8 = 81 920 нс, то есть примерно 82 мкс. Если же доступ идёт цепочкой зависимых указателей, как при обходе хеш-таблицы, параллелизма нет, и те же строки обойдутся в 16 384 × 40 = 655 360 нс, около 655 мкс. Эти числа взяты как допущения для расчёта, а не как измерения конкретного процессора, но разброс в восемь раз показывает главное: цена миграции зависит от характера доступа к памяти.

С TLB картина похожая. У типичного серверного ядра второй уровень TLB вмещает порядка 2 000 записей. При страницах по 4 КиБ это покрывает 2 000 × 4 КиБ = 8 МиБ адресного пространства. Если куча кэширующего сервиса занимает 4 ГиБ и доступ к ней случайный, покрытие составляет около 0,2% (8 МиБ / 4 ГиБ), и каждая холодная миграция превращается в серию обходов таблиц страниц. При среднем обходе в 30 нс пустой TLB на 2 000 записей обойдётся примерно в 61 мкс. Прозрачные огромные страницы по 2 МиБ увеличивают покрытие записи в 512 раз, и это один из способов смягчить удар, но он не отменяет самой миграции.

Чувствительные к кэшу нагрузки вроде маршрутизаторов с таблицами в памяти страдают сильнее всего

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

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

Среднее значение хорошо маскирует эту проблему, и стоит увидеть это в цифрах. Допустим, сервис обрабатывает 200 000 запросов в секунду, а миграцию испытывают 2% из них, то есть 4 000 запросов в секунду. Каждая миграция добавляет, скажем, 140 мкс, ориентируясь на оценку выше. Среднее время ответа вырастет всего на 0,02 × 140 = 2,8 мкс, и на графике пропускной способности разницы почти не видно. Но 99-й процентиль выделяет верхний процент запросов, а мигрировавшие запросы составляют целых два процента. Значит, хвост почти целиком состоит из запросов с миграцией, и P99 сместится примерно на сто с лишним микросекунд. Средняя метрика показала бы почти норму, а хвост выглядел бы заметно хуже.

Отдельная тема: пользовательские структуры данных, привязанные к процессору. Аллокаторы памяти держат кэши по ядрам (per-CPU caches) и рассчитывают, что поток будет возвращаться на то же ядро. Ядро Linux для этого даёт приложениям механизм перезапускаемых последовательностей (rseq), с помощью которого поток узнаёт номер текущего процессора. Когда планировщик то и дело переселяет потоки, такие структуры теряют смысл: данные остаются на одном ядре, а работа идёт на другом. Об этом в докладе говорится как об открытом вопросе, а не как о готовом решении.

Решение подождать занятое ядро или мигрировать упирается в оценку времени ожидания и стоимости прогрева

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

В виде формулы: мигрируем, если T_wait больше T_cold, где T_wait равно оставшемуся кванту текущей задачи плюс время обслуживания очереди перед ней, а T_cold складывается из повторного наполнения кэшей, TLB и самой стоимости переноса. Подставим цифры из расчёта выше. Оптимистичный сценарий даёт T_cold примерно 82 + 61 = 143 мкс (кэш плюс TLB). Пессимистичный, без параллелизма в памяти, даёт 655 + 61 = 716 мкс.

Теперь допустим, что у занятого ядра до конца кванта осталось 100 мкс. При оптимистичной оценке ждать выгоднее, потому что 100 меньше 143. При пессимистичной выгода ещё больше, ждать можно почти в семь раз дольше. А если осталось 400 мкс, оптимистичный сценарий уже говорит: мигрировать, а пессимистичный: ждать. Порог меняется в разы в зависимости от того, как задача ходит в память. Единого числа тут нет, поэтому и нельзя вписать в планировщик одну константу и считать вопрос закрытым.

Есть и вторая сложность: само T_wait угадать трудно. Сколько осталось текущей задаче, можно оценить по её кванту. Но очередь за ней, время обслуживания других готовых задач и возможные прерывания добавляют неопределённость. Планировщик решает всё это в момент пробуждения, за доли микросекунды, и не может позволить себе тяжёлых вычислений.

Работает и ещё одно обстоятельство, о котором легко забыть. Соседние аппаратные потоки одного физического ядра делят между собой кэши L1 и L2. Перенос задачи на соседний поток почти не стоит кэшей, но стоит вычислительной мощности, так как оба потока делят одни исполнительные блоки. Выходит, что простой выбор между "своим" и "чужим" ядром на SMT-системах неполон: есть ещё и промежуточная ступень, и у неё собственная цена.

Три открытых вопроса доклада касаются оценки прогретости, ожидания и интерфейса с приложениями

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

Первый вопрос касается оценки теплоты. Как долго состояние L1, L2 и TLB реально живёт на ядре, после того как задача ушла? Ответ зависит от того, что исполнялось там дальше: если следующая задача перебрала мегабайт данных, содержимое L2 уже вытеснено, а L1 на 32-48 КиБ выметается за считаные микросекунды. Отсюда вытекает идея: ядро Linux могло бы показывать аппаратные сигналы, такие как счётчики PMU или регистры занятости кэша, пригодные для использования с частотой принятия планировочных решений. Пока это именно вопрос, а не готовая возможность.

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

Третий вопрос про интерфейс между планировщиком и пользовательским пространством. Должны ли приложения сами подсказывать, насколько им важна локальность, или планировщику лучше открыть наружу информацию о теплоте и миграциях, чтобы аллокаторы и другие библиотеки подстраивались под неё? Каждый вариант тянет за собой компромиссы. Подсказки легко устареют или будут использованы неправильно. Открытое состояние планировщика, наоборот, привязывает приложения к деталям, которые разработчики ядра хотели бы иметь право менять.

Стенд показывает, когда ожидание выгоднее запуска сразу, а читать его результаты нужно по хвостовым задержкам

Говорить о цене миграции можно долго, но надёжнее один раз измерить её на своей машине. Для этого нужен стенд, где контролируются три вещи: нагрузка, расположение потоков на ядрах и счётчики событий. Минимальная конфигурация: ядро Linux 6.12 или новее с включённым CONFIG_SCHED_CLASS_EXT и BTF, планировщик scx_lavd и обычный планировщик ядра (в версиях от 6.6 это EEVDF) как базовая линия.

Проверка, что планировщик sched_ext включён:

cat /sys/kernel/sched_ext/state
sudo scx_lavd
cat /sys/kernel/sched_ext/root/ops

Первая команда должна показать disabled, вторая загружает планировщик, третья подтверждает его имя. Остановка scx_lavd возвращает систему к обычному планировщику.

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

taskset -c 2-5 redis-server --port 6379 --save "" --appendonly no
memtier_benchmark -s 127.0.0.1 -p 6379 --threads 4 --clients 25 \
  --ratio 1:10 --data-size 256 --key-maximum 10000000 \
  --test-time 60 --print-percentiles 50,95,99,99.9

Счётчики снимаются параллельно, пока идёт нагрузка:

perf stat -e cpu-migrations,context-switches,cache-misses,dTLB-load-misses \
  -p $(pidof redis-server) -- sleep 60

Название событий для промахов L2 различается у процессоров, список для конкретной модели выводит perf list. Внутри виртуальных машин аппаратные счётчики нередко недоступны, и perf вместо числа напишет "not supported". Тогда придётся довольствоваться счётчиком миграций cpu-migrations, он программный и работает везде.

Порядок эксперимента такой:

  1. Зафиксировать условия: режим производительности для частоты (cpupower frequency-set -g performance), состояние SMT в /sys/devices/system/cpu/smt/control и один и тот же набор ядер для всех прогонов;
  2. Прогнать нагрузку под обычным планировщиком без привязки, затем с привязкой потоков сервера к фиксированным ядрам, и записать P50, P95, P99 и P99.9;
  3. Повторить оба варианта под scx_lavd, снимая счётчики cpu-migrations, cache-misses и dTLB-load-misses;
  4. Сделать не меньше пяти прогонов каждого варианта, отбросив первый как прогревочный, и сравнивать медианы, а не лучшие результаты;
  5. Менять размер рабочего набора (например, 100 тысяч, 1 миллион и 10 миллионов ключей), чтобы увидеть, где данные перестают помещаться в кэш L2 и L3;
  6. Найти нагрузку, при которой привязка к ядрам даёт лучший хвост, чем свободное перемещение: это и есть область, где ожидание выгоднее немедленного выполнения.

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

Самая частая ошибка при такой проверке состоит в том, что смотрят только на среднюю пропускную способность. Как показал расчёт выше, миграции могут добавить к среднему доли процента, а хвост ухудшить в разы. Поэтому в таблицу результатов стоит вносить перцентили, а рядом счётчики: рост числа миграций вместе с ростом промахов кэша и dTLB говорит о том, что причина именно в холодных стартах, а не в чём-то постороннем.

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

Нужно помнить и о границах. Один стенд на одном процессоре не доказывает, что результат повторится на другом: объём L2, число записей в TLB и топология кэшей различаются от поколения к поколению. Расчёты в этой статье показывают порядок величин, но не заменяют измерений. Ничего в обсуждении не обещает, что липкость всегда выигрывает: для нагрузок, где важнее мгновенный старт, жадный поиск свободного ядра остаётся правильным выбором, и именно поэтому в коде появилась регулируемая ручка, а не жёсткое правило.

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