Год назад запуск модели на 35 миллиардов параметров подразумевал облако, очередь на GPU, и счёт от провайдера в конце месяца. Сегодня я покажу, как мы сделали это на одной потребительской видеокарте AMD за $500 — без ROCm, без CUDA, без MLX, одним бинарником на Zig.
Это пост про ZINC — inference engine, который мы строим с нуля под железо, которое люди реально покупают. Не как proof of concept, а как рабочий инструмент с OpenAI-совместимым API, потоковой генерацией и встроенным чатом.

Контекст: почему 35B на потребительском железе — это рубеж
Модели класса 35B — это не «ещё одна демка». Это порог, за которым локальный инференс становится практически полезным. Qwen3.5-35B-A3B при Q4_K квантизации выдаёт качество, сопоставимое с GPT-4-class моделями на большинстве повседневных задач: код, анализ текста, структурированные ответы, мультиязычность.
Проблема: такая модель в Q4_K_XL занимает ~21 ГБ видеопамяти. До недавнего времени это означало минимум A100/H100 в облаке или RTX 4090 за $1600+. AMD же, несмотря на конкурентоспособное железо, оставалась на обочине — ROCm не поддерживает потребительские RDNA3/RDNA4 карты как first-class target.
А потом AMD выпустила Radeon AI PRO R9700: 32 ГБ GDDR6, 576 ГБ/с полосы памяти, 64 Compute Unit на архитектуре RDNA4. Ценник — в районе $500.
На бумаге — хватает. На практике — ни один существующий inference engine не умел использовать это железо на полную. Вот тут начинается наша история.
Что такое ZINC
ZINC (Zig INferenCe) — inference engine для локального запуска LLM, написанный с нуля на Zig. Два GPU-бэкенда: Vulkan (AMD RDNA3/RDNA4 на Linux) и Metal (Apple Silicon на macOS). Ни одной внешней runtime-зависимости, кроме драйвера GPU.
Что внутри:
Собственный парсер GGUF (589 строк Zig)
BPE-токенизатор, читающий словарь из метаданных GGUF
Статический граф вычислений с pre-recorded command buffers
24 hand-tuned GLSL compute шейдера (Vulkan) + 31 MSL шейдер (Metal)
Paged KV cache (16-токенные страницы, как в vLLM)
Flash attention с grouped-query attention
OpenAI-совместимый HTTP API с SSE-стримингом
Встроенный чат-UI
Один бинарник. zig build run — и всё работает.

Текущие цифры
Прежде чем лезть в архитектуру, давайте посмотрим на результаты. Всё измерено на AMD Radeon AI PRO R9700 (32 ГБ, 576 ГБ/с), драйвер RADV (Mesa 25.0.7), RADV_PERFTEST=coop_matrix, ReleaseFast сборка:
Модель | Квант. | Размер в VRAM | ZINC | Сравнение |
|---|---|---|---|---|
Qwen3.5-35B-A3B | UD-Q4_K_XL | 20.7 ГБ | 38 tok/s | llama.cpp: ~107 tok/s |
Qwen3.5-2B | Q4_K_M | 1.2 ГБ | 27 tok/s | — |
Qwen3-8B | Q4_K_M | 4.8 ГБ | ~35 tok/s | — |
38 tok/s для 35B модели. На одной потребительской карте. Без ROCm.
Да, llama.cpp быстрее — примерно в 2.8x. Мы к этому вернёмся. Но llama.cpp — это 4+ года development, а ZINC существует пару месяцев. И разрыв сокращается с каждой неделей: мы начали с 4 tok/s и прошли путь до 38 через серию конкретных архитектурных решений.

Анатомия decode-токена: 26 миллисекунд под микроскопом
Один токен Qwen3.5-35B-A3B генерируется за ~26.3 мс. Вот что происходит внутри:
Архитектура модели
Qwen3.5-35B-A3B — не обычный трансформер. Это гибрид:
40 слоёв, из которых 4 — full attention, 36 — SSM (structured state space)
Mixture of Experts (MoE): в каждом слое 8 маршрутизируемых экспертов + 1 общий
Hidden dim: 2048, Vocab: 248 320
Это делает forward pass сложнее, чем у стандартного Llama: вместо одинаковых трансформерных блоков — три разных типа вычислений в каждом слое.
Разбивка по времени
Routed MoE (все слои) 10.24 мс ██████████████████ 39% SSM слои (30 из 40) 5.67 мс ██████████ 21.5% Shared expert (все слои) 5.35 мс █████████ 20% Attention (4 слоя) 3.58 мс ██████ 13.6% Final tail (norm + logits) 0.90 мс █ 3.4% ─────────────────────────────────────────────────────────── Итого GPU ~26.3 мс 100%

Главный вывод: 39% времени уходит на MoE-маршрутизацию. Это 40 слоёв × (router projection + top-8 selection + dispatch по 8 экспертам + shared expert). Каждый эксперт — это матричное умножение. 40 слоёв × 9 матричных умножений на слой = 360 DMMV операций на один токен.
Ядро: DMMV и борьба за пропускную способность памяти
Decode одного токена — задача, ограниченная пропускной способностью памяти (memory bandwidth bound). GPU считает быстро, но ждёт, пока веса приедут из VRAM. Именно поэтому ключевая метрика — какой процент от пиковой полосы памяти мы утилизируем.
Что такое DMMV
DMMV (Dequantize Matrix-Multiply Vector) — основная операция single-token decode. Для каждого линейного слоя:
Прочитать квантованные веса из VRAM
Деквантовать на лету (в регистрах, без промежуточного буфера)
Умножить на входной вектор
Записать результат
Ключевое решение: один шейдер на формат квантизации. У нас отдельные шейдеры для Q4_K, Q5_K, Q6_K, Q8_0, F16, F32 — каждый знает точный layout своего формата и фьюзит деквантизацию с dot product.
Альтернатива — generic dequant + generic matmul через промежуточный буфер. Мы пробовали. Это медленнее, потому что промежуточный буфер удваивает трафик через VRAM.
// Q4_K: фьюженный dequant + dot product в одном шейдере // Промежуточного буфера нет — деквантизация прямо в аккумулятор uint32_t qs_val = data_a_packed[qs_base + j]; float d0 = float(qs_val & 0xF) - 8.0; float d1 = float((qs_val >> 4) & 0xF) - 8.0; sum += (d0 * sc0 + min0) * s_x[x_idx] + (d1 * sc1 + min1) * s_x[x_idx + 1];
Утилизация полосы памяти по операциям
Операция | Размерность | Утилизация BW | Время |
|---|---|---|---|
Vocab output (lm_head) | 248320 x 2048 | 93.2% | 1006 мкс |
Большая attention-проекция | 8192 x 2048 | 83.6% | 1481 мкс |
Средняя проекция | 4096 x 2048 | 66.1% | 682 мкс |
MoE эксперт (Q4_K) | 512 x 2048 | 59.6% | 1073 мкс |
Маленькая матрица | 32 x 2048 | 2.7% | 272 мкс |

93% утилизации на большом vocab output — это отличный результат для hand-tuned Vulkan шейдера. Проблема в том, что MoE заставляет нас делать много маленьких умножений (512 x 2048) вместо одного большого. На таких размерностях GPU не может насытить шину памяти — слишком мало работы, чтобы скрыть латентность.
Трюк с shared memory
Каждый поток DMMV читает один и тот же входной вектор. Если 64 потока в wave независимо читают его через L1, они вытесняют из кэша данные весов — а весов на порядки больше.
Решение: кооперативная загрузка входного вектора в shared memory (LDS на AMD — 64 КБ на CU):
// 64 потока кооперативно грузят входной вектор в LDS for (uint i = tid; i < K; i += 64) { s_x[i] = x_data[i]; } barrier(); // Теперь каждый поток читает веса из VRAM (через L1) // а входной вектор — из LDS (без конкуренции за L1)
На vocab projection (248 320 x 2 048) этот паттерн даёт разницу между ~80% и 93% утилизации — это +75 ГБ/с восстановленной полосы.
Почему Vulkan, а не ROCm
Это решение, которое определяет весь проект.
ROCm — официальный AI-стек AMD. Он поддерживает HIP, зрелый компилятор, библиотеки. Но ROCm не поддерживает потребительские RDNA3 и RDNA4 как first-class target. Карты, которые люди реально покупают — RX 7900 XTX, RX 9070 XT, AI PRO R9700 — в экосистеме ROCm являются гражданами второго сорта.
Путь ROCm: Установить ROCm 6.x -> Обнаружить, что GPU не поддерживается -> Попробовать HSA_OVERRIDE_GFX_VERSION -> Дебажить случайные segfault'ы -> Купить NVIDIA Путь ZINC: Установить Mesa драйвер -> zig build run -> Инференс работает
Vulkan работает на каждой AMD GPU с драйвером. RADV (открытый Mesa драйвер) активно поддерживается, и на RDNA4 раскрывает всё, что нужно для инференса: compute shaders, shared memory, subgroup операции, cooperative matrix (16x16x16).
Та же логика для Apple Silicon: Apple не предлагает CUDA и ROCm. Зато Metal на M-серии — отличный API: unified memory даёт zero-copy загрузку моделей, simdgroup операции покрывают потребности инференса.
Статический граф вычислений
Forward pass декодер-трансформера — детерминистическая структура. Каждый слой делает одно и то же: attention (или SSM), FFN (с MoE), residual add. Формы тензоров фиксированы. Порядок операций фиксирован. Меняются только данные.
ZINC эксплуатирует это: строим граф один раз при загрузке модели, записываем command buffer, переиспользуем каждый токен.
Загрузка модели (один раз): Парсинг конфига -> Построение узлов графа -> Топологическая сортировка -> Запись command buffer Генерация (каждый токен): Обновить push constants -> Отправить записанные команды -> GPU выполняет forward pass
Альтернатива — динамическое построение графа каждый forward pass (как в большинстве фреймворков). Это гибче, но платит CPU-цену за каждый токен: аллокации узлов, разрешение зависимостей, запись команд.
Со статическим графом CPU-работа на токен: обновить push constants, submit. На Vulkan, один vkCmdDispatch стоит ~0.016 мкс — пренебрежимо мало. Но решение о том, что диспатчить, стоит значительно дороже.
Наш граф для Qwen3.5-35B-A3B: 3 728 узлов, 2 356 диспатчей. CPU record time — ~0.58 мс на токен.
Фьюженные ядра: убираем лишний трафик
Между матричными умножениями есть десятки мелких операций: RMS normalization, SiLU, RoPE, sigmoid gating, softmax. Каждая по отдельности быстрая, но каждая читает и пишет полный hidden state вектор из VRAM.
Если запускать их отдельными ядрами:
RMS norm: read x -> write norm_x (2 x hidden_dim x 4 байта) Scale: read norm_x -> write scaled (2 x hidden_dim x 4 байта) SiLU: read gate -> write silu (2 x hidden_dim x 4 байта) Multiply: read silu, up -> write out (3 x hidden_dim x 4 байта)
Девять проходов через VRAM для арифметики, которая вычислительно почти бесплатна.
ZINC фьюзит их в составные ядра:
Фьюженное ядро | Операции | VRAM reads | VRAM writes |
|---|---|---|---|
| RMS norm + поэлементное масштабирование | 2 | 1 |
| SiLU(gate) * up | 2 | 1 |
| RoPE + reshape + запись KV cache | 1 | 2 |
| sigmoid(x) * y (attention gating) | 2 | 1 |
Экономия: ~3 мс на токен по сравнению с нефьюженной версией. При цикле в 26 мс это 11% прирост — от устранения лишнего трафика, а не от более быстрой арифметики.

Paged KV cache: готовимся к будущему
Большинство inference engines начинают с плоского KV cache — непрерывный буфер, выделенный на максимальную длину контекста. Просто, быстро, но расточительно: если у вас несколько сессий с разной длиной контекста, VRAM тратится впустую.
ZINC использует paged KV cache по мотивам PagedAttention из vLLM. Кэш разбит на 16-токенные страницы, аллоцируемые по требованию, доступ — через таблицу страниц.
Плоский KV cache: Сессия A: 200 токенов (выделено 8K слотов) Сессия B: 50 токенов (выделено 8K слотов) Потеряно: 15 700 слотов Paged KV cache: Сессия A: 200 токенов (13 страниц = 208 слотов) Сессия B: 50 токенов (4 страницы = 64 слота) Свободно: 15 728 слотов
Overhead в flash attention шейдере — две целочисленные операции на score:
uint page_idx = seq_pos / page_size; uint page_off = seq_pos % page_size; uint physical_addr = page_table[page_idx] * page_size + page_off;
На 2 048-токенной последовательности overhead от page table невидим в профайлере.
Зачем было платить за эту сложность сейчас? Потому что paged allocation — prerequisite для continuous batching. Когда мы добавим параллельную обработку нескольких запросов с разной длиной контекста (а это следующий шаг), не придётся переписывать cache layer.
Zig: секретное оружие
Выбор языка для GPU inference engine — это не вопрос вкуса. Это архитектурное решение.
Zig дал нам:
Comptime GPU backend selection: вся абстракция платформы — 6 строк, без vtable, без runtime dispatch
C FFI без трения: Metal говорит на Objective-C, но нам хватило одного .m файла-шима в 400 строк
Explicit allocators: каждая аллокация видна, никаких скрытых
newв конструкторахBuild system: компиляция шейдеров, линковка фреймворков, сборка бинарника — один
build.zig
// Вся абстракция GPU бэкенда. Весь файл. const builtin = @import("builtin"); pub const is_metal = builtin.os.tag == .macos; pub const is_vulkan = builtin.os.tag == .linux; pub const backend = if (is_metal) @import("../metal/device.zig") else @import("../vulkan/instance.zig");
Когда компилируете на macOS — Vulkan-код не существует. На Linux — Metal не существует. Компилятор вырезает мёртвую ветку целиком.
Почему не C++? Потому что CMake + FindVulkan + кастомные shader rules + Xcode project — это недели работы, которые мы потратили за один день с build.zig. Почему не Rust? Потому что borrow checker дрался бы с нами за lifetime GPU-буферов, которые не ложатся в модель владения Rust.
RDNA4: что мы узнали о железе
AMD публикует полную спецификацию ISA для RDNA4 — каждый опкод, каждая инструкция памяти, каждое правило планирования wavefront. Эта открытость делает проект вроде ZINC возможным.
Что мы выяснили при тюнинге:
Отключите ECC на GPU: amdgpu.ras_enable=0 в GRUB даёт +9% (101 -> 110 tok/s). GECC потребляет полосу памяти, которая нам нужна для весов.
Версия SPIR-V тулчейна критична: shaderc 2023.8 (Ubuntu 24.04) — отлично. shaderc v2026.2-dev — в 5 раз медленнее (19 vs 110 tok/s). Новый glslc добавляет SPIR-V декорации, которые ломают оптимизатор ACO в RADV.
Wave64 — оптимальный размер wave для DMMV: wave32 не даёт улучшений. 64 потока на wave — это hardware constant RDNA, и наши шейдеры написаны именно под это.
Concurrent decode масштабируется линейно: 1 слот = 110 tok/s, 4 слота = 108 tok/s каждый = 432 tok/s агрегатно. GPU не насыщен одним потоком decode — есть огромный запас для batching.

Gap analysis: где 2.8x до llama.cpp
Будем честны: llama.cpp быстрее. ~107 tok/s vs наши 38. Вот где разница:
Метрика | ZINC | llama.cpp | Разница |
|---|---|---|---|
Decode throughput | 38 tok/s | ~107 tok/s | 2.8x |
Эффективная BW | 127 ГБ/с | ~360 ГБ/с | 2.8x |
Утилизация пиковой BW | 22% | 62% | — |
Где теряем:
MoE dispatch: каждый эксперт — отдельный DMMV dispatch. llama.cpp лучше батчит экспертов.
Descriptor churn: 1 022 descriptor аллокации на токен. Pre-allocation уберёт overhead.
Barrier оптимизация: лишние compute-to-transfer barriers между операциями.
SSM delta-net: 3.08 мс на state update — всё ещё дорого, несмотря на row-tiled rewrite (было 7.71 мс).
Но у нас есть план. Каждый пункт — конкретная инженерная задача с измеримым target. Мы не гадаем, что медленно — мы профилируем каждый dispatch.

Apple Silicon: та же модель, другая физика
ZINC работает и на Apple Silicon через Metal бэкенд. Та же модель, тот же GGUF файл, те же API. Но физика памяти совсем другая.
На дискретных AMD GPU — VRAM отдельно, CPU RAM отдельно. Загрузка модели = mmap файл + DMA через PCIe.
На Apple Silicon — unified memory. CPU и GPU делят одну физическую память. Загрузка модели = mmap файл + newBufferWithBytesNoCopy. Буквально zero-copy:
// Metal: GPU читает прямо из mmap'd страниц const mmap_data = try std.posix.mmap(null, file_size, std.posix.PROT.READ, .{ .TYPE = .PRIVATE }, fd, 0); var gpu_buffer = try metal_buffer.wrapMmap(device_ctx, mmap_data.ptr + tensor.offset, tensor.size_bytes);
Текущие цифры на M1 Max 32 ГБ:
Qwen3.5-2B: ~17 tok/s
Qwen3-8B: ~8 tok/s
35B модели требуют 24+ ГБ unified memory — на M4 Pro / M4 Max с 48+ ГБ это будет работать уверенно. На M5 Max (614 ГБ/с полосы) мы ожидаем throughput, сопоставимый с дискретными AMD.
OpenAI-совместимый API: не CLI-тулза, а инфраструктура
Inference engine, который работает только из CLI — это демо. Inference engine с API — это инфраструктура.
ZINC раскрывает /v1/chat/completions, /v1/completions, /v1/models — те же endpoint’ы, что и OpenAI API. Любой клиент, говорящий по этому протоколу (а это почти все к этому моменту), может указать на ZINC и получить локальный инференс без изменений кода.
# Запуск ./zinc chat # Любой OpenAI-клиент работает curl http://localhost:9090/v1/chat/completions \ -d '{"model":"qwen","messages":[{"role":"user","content":"Привет!"}],"stream":true}'
Сервер поддерживает:
Streaming через Server-Sent Events
Chat template formatting (Qwen, Mistral, Gemma)
Session reuse cache (LRU, до 32 сессий, 30 мин idle timeout)
Stop sequence detection
Thinking mode (
<think>блоки)
Встроенный чат-UI на корневом URL — одна HTML-страница, embedded в бинарник.
Ноль зависимостей: почему это важно
ZINC не использует ни одной внешней библиотеки (кроме GPU драйвера). GGUF парсер — свой. Токенизатор — свой. HTTP сервер — свой. Чат UI — свой.
Это не минимализм ради минимализма. Это три конкретных преимущества:
Воспроизводимость. Когда пользователь сообщает баг, мы точно знаем, какой код работает. git bisect покрывает весь стек.
Время старта. ZINC стартует менее чем за секунду, включая mmap модели. Нет интерпретатора, нет JIT, нет цепочки разрешения динамических библиотек.
Деплой. Один статический бинарник. scp на любую машину с GPU драйвером — работает. Никаких conda environments, Docker images, конфликтов версий.
Широкое adoption: почему время пришло
Три вещи сошлись одновременно:
Железо стало доступным. RX 9070 XT за 500 с 32 ГБ тянет 35B. M4 MacBook Pro тянет 2B-8B моделей из коробки.
Модели стали достаточно хорошими. Qwen3.5, Gemma 4, DeepSeek — открытые модели, которые реально полезны в повседневной работе. Не «интересные демо», а рабочие инструменты.
Стек наконец-то догоняет. ZINC не единственный проект, который это видит. llama.cpp, tinygrad, MLX — все движутся в сторону потребительского железа. Но ни один не делает AMD потребительские GPU first-class target с hand-tuned шейдерами.

Что дальше
Архитектура ZINC спроектирована так, чтобы следующие шаги не требовали переписывания:
Continuous batching: paged KV cache и статический граф готовы. Основная работа — batch-aware dispatch layer.
TurboQuant KV compression: 3-битная квантизация KV cache, сокращение памяти в ~5x. Paged cache превращает это в per-page трансформацию.
Batched MoE dispatch: сейчас каждый эксперт — отдельный DMMV. Группировка экспертов в один dispatch закроет основной gap с llama.cpp.
Speculative decoding: draft-модель на том же GPU, верификация против основной модели.
Каждый из этих пунктов — конкретная инженерная задача, а не исследовательский проект.
Как попробовать
git clone https://github.com/zolotukhin/zinc.git cd zinc zig build -Doptimize=ReleaseFast ./zig-out/bin/zinc model list # посмотреть доступные модели ./zig-out/bin/zinc model pull qwen3-8b-q4k-m # скачать ./zig-out/bin/zinc chat # запустить
На AMD: нужен Mesa RADV драйвер и export RADV_PERFTEST=coop_matrix. На macOS: Xcode Command Line Tools и Metal-совместимый Mac (M1+).
Документация: zolotukhin.ai/zinc/docs Код: github.com/zolotukhin/zinc
35-миллиардная модель на потребительской видеокарте — это не фантазия и не бенчмарк-хак. Это инженерная задача, которая решается конкретными архитектурными решениями: hand-tuned шейдеры вместо generic абстракций, статический граф вместо динамического overhead, фьюженные ядра вместо промежуточных буферов, paged cache вместо расточительных flat-аллокаций.
Каждое решение в этом посте — ставка на то, что фокус побеждает универсальность. Пока что ставки оправдываются.
Если вы запускаете модели на AMD GPU, работаете с Vulkan compute, интересуетесь низкоуровневой оптимизацией инференса или просто хотите, чтобы локальный AI перестал быть привилегией дорогого железа — заходите в проект. Код открыт, задачи размечены, и каждый pull request реально двигает стрелку.
