Может ли языковая модель работать в Linux eBPF?
Может ли Linux eBPF выполнить настоящую языковую модель? Эксперимент с Qwen3-0.6B показывает, какая именно работа идет в ядре, насколько это быстро и почему INT4 и память arena остаются открытыми компромиссами.
Программы Linux eBPF обычно следят за работой, которая делается в другом месте, или влияют на нее: трассируют системный вызов, фильтруют пакет или выбирают политику планирования. qwen3-ebpf задает другой вопрос. Может ли верифицированная BPF-программа выполнить численную работу настоящей языковой модели, а не просто следить за сервером модели?
Эксперимент прогоняет 28 слоев декодера Qwen3-0.6B в Linux eBPF, считает оценку для каждого из 151 936 токенов словаря и выбирает следующий токен внутри ядра. Еще он держит KV-кэш для последующих токенов. Это работающий прямой проход, а не годная замена движку инференса на CPU или GPU: один токен в замере на тестовом хосте все еще занимает около 1,2 секунды, а загрузка модели, работа с текстом и вызов BPF-операторов остаются на C.
Начнем с одного токена
Языковая модель не получает текст напрямую. Сначала токенизатор превращает текст в целочисленные ID. Модель находит эмбеддинг по каждому ID, прогоняет этот вектор через стек слоев декодера и выставляет оценку, или логит, каждому возможному следующему ID. Максимальная оценка дает следующий токен, а декодирование его ID - текст.
Вот как в этом прототипе поделена работа:
flowchart TD
A["C: токенизировать текст, загрузить веса BF16"] --> B["C: эмбеддинг токена и запуск BPF-программ"]
B --> C
subgraph L["Один слой декодера eBPF, повтор 28 раз"]
C["RMSNorm · qwen3_norm"] --> D["матрицы Q/K/V · qwen3_batch"]
D --> E["норма Q/K и RoPE · qwen3_norm + qwen3_rope"]
E --> F["каузальное внимание · qwen3_attention ↔ KV-карта"]
F --> G["выходная проекция и residual · qwen3_batch + qwen3_vector"]
G --> H["MLP · qwen3_norm + qwen3_batch + qwen3_silu + qwen3_vector"]
end
H -- "следующий слой" --> C
H -- "после слоя 28" --> I["финальная норма, проекция на словарь и argmax · qwen3_norm + qwen3_batch"]
I --> J["C: декодировать ID токена в текст"]
Каждый блок - ограниченный оператор, а не стадия одной гигантской BPF-программы. C вызывает их по очереди, программа внимания пишет в BPF KV-карту и читает из нее. Матричная программа обрабатывает до 128 строк за вызов, так что один блок все равно может означать много входов в ядро.
Драйвер на C запускает BPF-программы типа socket-filter через bpf_prog_test_run_opts. Это точки входа тестового прогона, а не фильтры, прикрепленные к интерфейсу, и проект не поднимает постоянный сервис модели. BPF считает произведения матрицы на вектор, RMSNorm, SiLU, векторные операции, поворот RoPE, каузальное внимание и финальный argmax. C отдает эмбеддинг, веса модели и тригонометрические значения RoPE, а затем вызывает программы в том порядке, которого требует модель. На более длинном промпте каждая входная позиция проходит все 28 слоев. Когда генерируется следующий токен, векторы K/V, записанные BPF, позволяют вниманию переиспользовать прошлые позиции, а не пересчитывать их.
Вот что здесь значит «инференс в ядре»: арифметика прямого прохода и выбор токена идут в верифицированных BPF-программах. Это не значит, что текстовый промпт входит в ядро, а из одного вызова выходит готовое предложение.
Почему вычисления разбиты на куски
Программа eBPF должна пройти верификатор Linux, который до запуска разбирает пути выполнения и обращения к памяти. Реализация ограничивает свою работу и переносит промежуточное состояние через BPF-карты. Вместо исходной плавающей арифметики BF16 тут целые с фиксированной точкой: активации Q16, веса матриц Q24, масштабы нормализации Q20. SiLU и softmax внимания используют целочисленные приближения. Тригонометрические входы считает C, а векторы RoPE поворачивает BPF.
Важный цикл - bpf_loop. Один вызов матрицы считает до 128 строк. Другие вызовы закрывают все 24 головы нормализации query/key, все 16 голов query на ограниченном куске истории внимания или 256 прошлых позиций. Новые пары K/V программа внимания пишет в свою KV-карту. Так удалось сократить повторные переходы user/kernel, не делая вид, что все 28 слоев стали одной огромной верифицированной программой. Прогон одного токена теперь делает около 4 169 вызовов bpf, против десятков тысяч в ранних версиях с более мелким батчем. Эта цифра - замер реализации, а не заявление о пропускной способности.
Границу объясняют две неудачные попытки. Ранний монолитный RMSNorm превысил бюджет обработки инструкций верификатора на тестовом ядре. Перестройка вокруг ограниченных колбэков позволила его загрузить. Конвертация BF16 в Q24 прямо в BPF-цикле на 3 072 столбца наткнулась на ошибку сложности верификатора. Это результаты конкретных программ на Linux 6.17 arm64, а не доказательство, что любая BPF-программа покрупнее уже невозможна.
Где живут веса
Официальная модель - файл Safetensors в BF16 примерно на 1,5 ГБ, который C отображает в память только для чтения. Путь инференса по умолчанию копирует активные строки матрицы в рабочую BPF-карту, отображенную в память, уже после перевода в Q24. Предварительная конвертация всех матриц в четырехбайтовый Q24 дала файл примерно на 3,0 ГБ, и на пробных прогонах этот вариант работал медленнее, на хосте, где свободно около 4,6 ГиБ. Удвоение объема весов узкое место не убрало.
Мы также собрали необязательный путь через arena на всю модель. Он копирует только текущий батч BF16 на 128 строк в BPF arena примерно на 2 МиБ. Таблица поиска на 65 536 записей в этой arena сопоставляет каждый битовый шаблон BF16 с тем же целым Q24, что использует драйвер по умолчанию. Дальше BPF читает эти веса и считает скалярное произведение. Конвертация весов для активного батча матрицы уходит из C, и при этом никто не пытается держать всю модель в памяти, доступной ядру. На ID токена 0 и на продолжении Hello, world! в два токена он вернул те же ID токенов и побайтово те же логиты по всему словарю, что и путь по умолчанию. Smoke-тест arena еще и точно совпал на 128 настоящих строках Q-проекции.
Это изменение ускорения не показало. Шесть чередующихся замеров на одном токене: 1,227/1,190/1,251 секунды у пути по умолчанию и 1,220/1,228/1,234 секунды у пути через arena. Таймер в отчете стартует после настройки модели и BPF, и по такой маленькой выборке на том же хосте нельзя судить ни о пропускной способности, ни о хвостовой задержке. Arena касается границы доступа к данным. Веса она не сжимает и оставшиеся вызовы операторов не убирает.
Решат ли это четырехбитные веса?
INT4 заманчив тем, что два веса умещаются в байт, а один вес BF16 занимает два. Проверенное представление INT4 с групповым масштабом упаковало матрицы этой модели в 316 616 704 байта, включая масштабы, и его BPF-оператор прошел верификатор и тесты по строкам. В одном сравнении на единственном токене прямой проход занял 0,956 секунды против 1,243 секунды у Q24, не считая 1,928 секунды на предварительную упаковку. Сигнал по производительности полезный, но точность провалила тест поважнее: следующий токен пути по умолчанию для входного ID 0 был 9, а эта версия INT4 выбрала 284. Средняя абсолютная ошибка логитов по всему словарю составила 1,837. На других проверенных промптах следующие ID тоже сменились. Даже более мелкие группы квантования или проекция на словарь в более высокой точности этот пример не вернули.
Так что INT4 - путь для исследований, а не модель по умолчанию. Схема с калибровкой или со смешанной точностью, может, и сработает, но ей нужны сравнение токенов и логитов по всей модели на разных промптах, замеры памяти и предобработки и честно снятое распределение задержек. Файл весов поменьше сам по себе не показывает, что на выходе все еще та модель, которую имели в виду.
Что по-прежнему остается вне ядра?
Точный токенизатор читает большой tokenizer.json, режет текст регулярным выражением, применяет байтовый BPE и собирает байты обратно для вывода. Текст и ввод-вывод делает C. Перенос в BPF потребовал бы отдельной ограниченной реализации и не изменил бы дорогую матричную работу. C также решает, какой оператор идет следующим, и подает следующий батч матрицы. Вся модель не лежит в памяти, доступной BPF, поэтому один долгий вызов в ядре при текущей схеме не смог бы просто пройти все ее слои.
Текущая KV-карта - еще одно ограничение. По раскладке на позицию резервируется около 224 КиБ. Если выделить все 40 960 позиций из конфигурации, один только KV потребует примерно 8,75 ГиБ. Внимание умеет просматривать историю ограниченными кусками, но ни расход памяти на длинном контексте, ни качество генерации на произвольных контекстах пока не установлено.
Данных хватает на узкий, но конкретный вывод. На тестовом ядре BPF-программы, принятые верификатором, выполняют все 28 слоев и выбирают токены настоящей модели. Для Hello, world! первый сгенерированный ID - 1096 (This), и пять верхних логитов совпадают с официальным эталоном BF16. Сравнение с эталоном покрывает небольшой набор входов, а ошибка фиксированной точки по всему словарю ненулевая. Открытый инженерный вопрос не в том, можно ли выразить трансформер в eBPF. Вопрос в том, смогут ли лучшие форматы весов, меньше границ и ограниченная память сделать это выражение полезным для нагрузки, где запуск в ядре действительно важен. Исходники, команды и замеры доступны для следующего теста.