Discovery
Попробуйте другие наши приложения
vLLM vs SGLang: radix tree против block-level prefix caching
25 апреля 2026 г.

Если зайдёт — на канале @fuckup_files разбираю похожие темы про LLM-инфру в более коротком формате.

Когда серверу прилетает 200 одинаковых system-prompt'ов и 200 разных вопросов поверх них, инфраструктура должна хотя бы раз понять, что 90% входа — это копия предыдущего реквеста, и не тратить compute на пересчёт уже посчитанного. Это и есть prefix caching: ключевой механизм, который превращает голый attention в production-ready serving.

В vLLM и SGLang prefix caching реализован принципиально по-разному: vLLM использует block-level хэширование, SGLang — token-level radix tree. На бумаге это разница в структуре данных. На practice — это 29% throughput на multi-turn workload и до 6.4× ускорения на prefix-heavy сценариях вроде RAG. Разберём, почему так получается, как они работают внутри, и как выбрать движок под свою нагрузку.

Что такое prefix caching и зачем он нужен

KV cache в transformer'ах хранит ключи и значения для каждого токена контекста, чтобы при декодировании следующего не пересчитывать всю историю. Обычно его инвалидируют между запросами. Prefix caching ломает это правило: если новый запрос начинается с того же префикса, что был у предыдущего, KV-блоки префикса можно переиспользовать как есть — это сокращает prefill (вычисление KV для всего входа) пропорционально длине общего префикса.

В реальной нагрузке общий префикс почти всегда есть:

  • Multi-turn чат: каждый новый turn повторяет всю историю + system prompt
  • RAG: одни и те же chunks могут попадать в окно к разным пользователям
  • Tool/agent loops: длинный system prompt + tool definitions переиспользуется на каждом шаге
  • Few-shot batch inference: одинаковая преамбула на тысячах запросов

Без prefix caching prefill этих токенов выполняется заново при каждом запросе. С ним — переиспользуется из кэша. Разница в TTFT (time to first token) и в общем throughput'е — на порядок.

vLLM: block-level хэширование

vLLM нарезает KV cache на фиксированные блоки (обычно по 16 токенов) и считает для каждого хэш на основе:

  • хэша родительского блока в префиксе,
  • токенов внутри текущего блока,
  • доп. идентификаторов: LoRA ID, multimodal-хэшей, cache_salt.

Когда приходит новый запрос, vLLM хэширует его блоки слева направо и для каждого ищет совпадение в cache_blocks (мапа hash -> block_id). Совпало — берём готовый блок, не считаем заново. Не совпало — выделяем новый.

Алгоритм хэширования настраивается:

# По умолчанию — sha256, безопасный, но медленнее
vllm serve meta-llama/Llama-3.1-70B-Instruct \
  --enable-prefix-caching \
  --prefix-caching-hash-algo xxhash
АлгоритмСкоростьБезопасностьКогда выбирать
sha256 (default)средняякриптографическаяmulti-tenant без особых требований
xxhashбыстраяnon-cryptosingle-tenant, throughput критичен
sha256_cborсредняяcrypto + reproduciblemulti-tenant + аудит

Eviction — LRU с двусвязным free queue: при OOM выкидываются давно не использованные блоки с головы очереди. Освобождённые добавляются в хвост в обратном порядке, чтобы менее «полезные» блоки эвиктились первыми.

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

Для multi-tenant сетапов есть ещё одна тонкость: hash-коллизия теоретически позволяет одному пользователю получить кусок чужого KV-cache. Защита — cache_salt в payload'е реквеста, который подмешивается в хэш первого блока:

{
  "messages": [{"role": "user", "content": "..."}],
  "cache_salt": "user_42"
}

Реквесты с разными salt физически не могут хитнуть один блок, что закрывает timing side-channel attack.

SGLang: RadixAttention поверх radix tree

SGLang хранит KV cache в radix tree — древовидной структуре, где рёбра помечены последовательностями токенов произвольной длины (в отличие от обычного trie с одним токеном на ребро). Ключ — последовательность токенов, значение — соответствующий KV-тензор. Префикс матчится естественным обходом дерева от корня вниз.

Преимущество перед block-level подходом: tree автоматически находит частичные совпадения на уровне отдельных токенов. Если у предыдущего запроса был префикс [A, B, C, D], а у нового — [A, B, C, X], SGLang переиспользует [A, B, C] без необходимости попадать в block boundary. vLLM в той же ситуации либо переиспользует блок целиком (если границы совпали), либо пересчитает всё с нуля.

Eviction в RadixAttention — рекурсивный LRU по листьям. Когда свободной памяти нет, удаляется лист с самой старой last_used_at меткой; если родитель остался без потомков и не используется — удаляется и он, и так вверх по дереву. Это сохраняет «горячие» куски графа максимально долго.

Поверх RadixAttention SGLang включает cache-aware scheduling: когда в очереди стоит несколько запросов, scheduler приоритизирует те, у которых самый длинный matched prefix (DFS-order по дереву). Это аппроксимирует оптимальный порядок выполнения и максимизирует cache hit rate. У vLLM scheduler — обычная FIFO-очередь без учёта кэша.

Запускается RadixAttention в SGLang без отдельных флагов — он включён по умолчанию:

python -m sglang.launch_server \
  --model-path meta-llama/Llama-3.1-70B-Instruct \
  --tp 2 \
  --mem-fraction-static 0.85

Тонкая настройка идёт через --mem-fraction-static (доля GPU-памяти под KV cache) и --schedule-policy (по умолчанию lpm — longest prefix match, что и есть cache-aware DFS).

Архитектурно структура SGLang выглядит так:

                    [root]
                   /  |  \
        ["You are"]  ["Hi"]  ["Translate"]
              /        |         \
   ["a helpful AI"]   [...]   ["from English"]
        /   \                       |
     [...] [...]                ["to Russian"]

Каждое ребро — последовательность токенов; каждый узел — указатель на блоки KV cache в paged memory. При новом запросе scheduler проходит по дереву максимально глубоко, насколько совпадает префикс, и стартует генерацию с нужного места.

Числа: где разница реально видна

Бенчмарки на H100 (independent: Spheron, PremAI, Clarifai по сравнительному обзору morphllm):

WorkloadSGLangvLLMПреимущество SGLang
General throughput~16,200 tok/s~12,500 tok/s+29%
Prefix-heavy (RAG, multi-turn)до 6.4×
DeepSeek V3 (с MTP)3.1×
Уникальные промпты, нет overlapпаритетпаритетбез разницы

Конкретный замер от Runpod на 2×H100 SXM, DeepSeek-R1-Distill-Llama-70B, контекст 7k токенов:

SGLangvLLM
С прогретым кэшем35.0 tok/s (4.29s)32.8 tok/s (4.57s)
Холодный (без кэша)29.5 tok/s (5.09s)28.6 tok/s (5.25s)
Прирост от кэша~20%~10%

Важный наблюдаемый эффект: сам кэш у SGLang эффективнее почти вдвое по приросту. На холодном старте движки идут вровень — основное преимущество SGLang проявляется именно в способности агрессивно переиспользовать частичные совпадения.

Что ломает кэш на практике

Несколько вещей резко сбрасывают эффективность prefix caching, и это одинаково больно для обоих движков:

  • Динамический контент в начале промпта. Текущая дата/время, ID пользователя, любая переменная в первых токенах системного промпта моментально обнуляет hit rate. Все динамические значения переносить в конец промпта или в отдельную секцию после общего префикса.
  • Перестановка инструкций. Если в одном запросе [system, tools, examples], а в другом [system, examples, tools] — это с точки зрения кэша разные префиксы. Жёстко фиксируй порядок секций.
  • LoRA-адаптеры. vLLM включает LoRA ID в хэш блока, поэтому адаптер на запрос — отдельный namespace кэша. На пересечении LoRA-моделей кэш не переиспользуется.
  • Multimodal-входы. Хэши изображений тоже идут в block hash; даже одно и то же фото после повторной перекодировки даст другой хэш и промахнётся мимо кэша.
  • Tokenizer warm-up. На первых запросах после рестарта серва кэш холодный — реальные числа throughput'а имеет смысл снимать только после прогрева хотя бы 5–10 минут.

Когда что выбирать

def pick_inference_engine(workload):
    if workload.has_unique_prompts and not workload.shared_system_prompt:
        return "vllm"  # cache не помогает, выбираем по hardware/maturity
    if workload.is_multi_turn or workload.is_rag:
        return "sglang"  # radix tree побеждает на partial overlap
    if workload.target_hw in ("tpu", "trainium", "inferentia", "intel_gpu"):
        return "vllm"  # SGLang только NVIDIA + AMD ROCm
    if workload.requires_mtp_models:  # DeepSeek V3 и др. с Multi-Token Prediction
        return "sglang"  # vLLM пока без MTP
    return "vllm"  # шире экосистема, больше контрибьюторов

vLLM остаётся правильным выбором, когда:

  • нагрузка batch'евая с уникальными промптами (overlap минимален),
  • нужен TPU/Trainium/Gaudi/Intel,
  • критична совместимость со зрелой экосистемой и большим community.

SGLang выигрывает, когда:

  • много multi-turn/RAG-нагрузки с длинными общими префиксами,
  • модель использует Multi-Token Prediction (DeepSeek V3, Llama-MTP),
  • железо — H100/H200/B200/MI300X.

Заключение

Разница между prefix caching в vLLM и SGLang — не «у одного лучше, у другого хуже». Это разница инженерных компромиссов: vLLM предпочёл простоту адресации (фиксированные блоки + хэш) и поэтому работает на любом железе и интегрируется со всем подряд. SGLang выбрал более сложную структуру (radix tree + cache-aware scheduler) и за счёт этого выигрывает там, где у vLLM формат блоков становится узким горлышком — на partial overlap.

Что делать дальше: если у тебя сейчас крутится vLLM на multi-turn / RAG-нагрузке с длинными system prompts, имеет смысл прогнать тот же workload через SGLang и замерить разницу — обычно она измеряется десятками процентов throughput'а на тех же GPU. Только не забудь, что бенчить надо на реальных распределениях запросов, а не на синтетических уникальных промптах: на них преимущества RadixAttention не видны.


Это лишь один кусок большой темы про LLM-serving. На канале @fuckup_files разбираю смежное — конфиги vLLM/Ray под разные нагрузки, истории отладки multi-GPU, бенчмарки. Если хотя бы одна эта статья сэкономила тебе пару часов — подписаться займёт пять секунд.

Мой тг · про факапы@fuckup_files