Назад к блогу

DeepSeek Elastic Compute: как устроена архитектура эластичных вычислений

DeepSeek Elastic Compute: как устроена архитектура эластичных вычислений

Инженеры из DeepSeek построили систему, которая одновременно держит более ста тысяч изолированных окружений и создаёт по пять тысяч новых каждую секунду — именно на такой инфраструктуре обучают агентов, умеющих работать с настоящими инструментами. Авторы разбирают, как устроены управляющий и data-контур, планировщик размещения и жизненный цикл песочниц, и подкрепляют рассказ цифрами бенчмарков.

DSec — это система, которая держит 100k+ одновременных песочниц и выполняет около 5 000 созданий песочниц в секунду, чтобы обучать агентов, взаимодействующих с реальным инструментарием. Разбираем механику: как разделены плоскости управления и данных, как планировщик выбирает узел, что происходит с песочницей при паузе и потере узла, как устроены образы и протокол, и какие цифры показали бенчмарки.

Две плоскости вместо одного рантайма

DSec разделяет management plane и data plane. В dsec-rs это разделение повторено на уровне крейтов. К management plane относится dsec-control: apiserver, IAM, placement, watcher — REST поверх HTTP, без состояния, горизонтально масштабируемый. К data plane относятся dsec-protocol (Aether) и dsec-runtime (серверы Edge). Ключевое правило: каждая операция песочницы — это мультиплексированный Aether-запрос, а не вызов management plane.

Что именно лежит в каждом крейте:

  • dsec-protocol — Aether wire protocol: кадры, CRC32, кодек запрос/ответ, мультиплексирующие каналы.
  • dsec-runtime — конечный автомат жизненного цикла Edge, Aether server/client (channel + UDS transports), четыре бэкенда (FnCall pool, Container, MicroVM, FullVM), resource governance с pause-time reclaim.
  • dsec-control — IAM с вложенными квотами проектов, версионируемый registry + event log, k-choice placement engine с cloud bursting , heartbeat watcher с eviction + preemption , REST apiserver (axum), метрики Prometheus, rate limiting.

В маппинге на статью stateless apiserver (REST) соответствует dsec-control::apiserver (axum, 12 маршрутов), а Aether data plane — dsec-protocol (frames, CRC32, mux) плюс dsec-runtime::aether (server/client, UDS).

Четыре бэкенда исполнения и их latency-профили

DSec предоставляет четыре подключаемых бэкенда исполнения. По изоляции они различаются так: Fireagent начинает с Firecracker microVM как самой сильной границы изоляции. В dsec-rs изоляция не создаётся реально — контейнеры, microVM и namespaces не создаются, бэкенды представляют собой симуляции с latency-профилями.

Профиль NodeLatencyProfile::paper() воспроизводит якоря статьи: FnCall ~5 ms handout, MicroVM ~900 ms cold, pause ~4 s. Профиль ::zero() даёт чистую логику без задержек. В модели dsec-rs:

  • FnCall — пул предварительно допущенных сессий, выдача ~5 ms;
  • MicroVM — симулированная загрузка с задержкой ~900 ms;
  • FullVM — симуляция, самый медленный;
  • Container — симулированный образ с учётом cgroup.

Реализация — в dsec-runtime::backends вместе с NodeLatencyProfile.

Планировщик: k-choice power scheduling

Placement Engine обрабатывает каждый запрос на создание в четыре шага:

  1. Фильтрует кандидатов по capacity, image affinity, labels, health и политике local-vs-cloud.
  2. Сэмплирует k узлов — чтобы решения оставались O(k), а не O(n) при ~5k creations/s.
  3. Оценивает их по packing, fragmentation, locality, topology и cost.
  4. Выбирает лучший из k, разрешая ничьи seeded jitter'ом — это предотвращает положительную обратную связь herd-эффектов (закреплено регрессионным тестом).

Облачные узлы попадают в кандидаты только когда спецификация разрешает bursting, и получают штраф по стоимости при скоринге.

Сэмплирование и jitter выполняются под одним захватом блокировки, а выборка k кандидатов идёт алгоритмом Флойда: он выбирает k различных позиций за O(k²) и возвращает их отсортированными, так что скоринг идёт прямо по списку кандидатов. Раньше общий RNG захватывался дважды на решение (сэмплирование, затем jitter), а выборка k материализовала Vec из 1024 индексов и делала частичную тасовку Фишера–Йетса на каждое решение. Оптимизация убрала O(n)-аллокацию на решение, сохранив пропускную способность на уровне v0.1.0; метрика качества k-choice не изменилась — 67.65% от исчерпывающего улучшения при идентичных скорах.

Жизненный цикл песочницы

Песочница на Edge-узле проходит последовательность состояний: создание, готовность к работе, приостановку, приостановленное состояние, возобновление и уничтожение. Переходы выполняет сам Edge-узел, который владеет конечным автоматом жизненного цикла; переходы CAS-транзакциями.

Приостановка освобождает память: pause возвращает около 60% памяти песочницы по модели balloon + memory.reclaim. Возобновление подтягивает холодные страницы заново по модели MADV_WILLNEED. Допуск при создании работает без блокировок: оптимистичный reserve-verify-rollback на атомиках. При полной конкуренции проходит ровно max_sandboxes допусков.

Вытеснение выбирает жертв, начиная с приостановленных (это дешевле — приостановленные песочницы уже выгружены в swap), затем по наименьшему приоритету.

В dsec-bench измеряются распределения задержек pause/resume — одна из 17 групп бенчмарков. Задержки зависят от операций с памятью (reclaim при паузе и повторная подгрузка страниц при resume), а также от admission control на узле. Полный набор бенчмарков запускается командой cargo run --release -p dsec-bench и пишет результаты в bench-results.json и bench-results.md.

Watcher: liveness, eviction, preemption

Watcher отслеживает живость узлов: heartbeats несут информацию об использовании узла, а watcher истекает узлы по TTL и вытесняет их песочницы. Конкретное числовое значение TTL не приводится. Watcher реализован в dsec-control как heartbeat watcher с eviction и preemption, отвечает за health, eviction и preemption, с heartbeat TTL и paused-first preemption.

Когда узел возвращается после истечения TTL, его песочницы не восстанавливаются — они вытеснены, то есть записи о них теряются. Registry устроен как watch-style: каждая мутация увеличивает версию и рассылает событие подписчикам, поэтому вытеснение отражается как мутации с инкрементом версии и широковещательными событиями. Watcher получает heartbeats узлов через Registry, а SDK-класс LocalCluster использует его для наблюдения за составом кластера.

Хранилище: EROFS-образы, CoW и pack_diff

EROFS-style образы — загрузка блоков идёт по требованию: блоки подгружаются через fault, когда гость их читает. На узле работает LRU-кеш Arc<Block>, и блоки, общие для песочниц на одном образе, загружаются один раз — это даёт дедупликацию страничного кеша.

CoW-изоляция обеспечивается overlay-устройствами: поверх базового образа создаётся записываемый слой, в котором блоки копируются при первой записи. Поверх overlay работает layered FS — таблица файлов и аллокатор свободных блоков, образуя маленькую гостевую ФС с read-only корнем и записываемым верхним слоем.

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

Как ускорили pack_diff

Раньше каждый снапшот глубоко клонировал всю таблицу файлов (512 клонов String), даже когда ничего не менялось, — именно этот клон таблицы и был горячим путём после первого снапшота. Каждый apply заново интернировал 512 базовых путей в свежий LayeredImage, а затем сразу очищал и заменял их из пака.

Оптимизация сделала DiffPack.files типом Arc<[FileWire]> с путями Arc<str>, а LayeredImage держит кэш wire-снапшота с ключом по версии: неизменённая таблица выдаётся по refcount, и горячий путь снапшота — это два клона Arc. apply_diff «усыновляет» общую таблицу пака как свою запись кэша (с ключом по только что опубликованной версии), поэтому цепочки реплика→снапшот остаются на пути refcount. LayeredImage::replica_from(overlay, pack) строит таблицу реплики из собственных ключей пака (вставки по refcount) вместо интернирования базового образа и его замены. take_dirty_blocks() потребляет множество грязных блоков и копирует только грязные блоки под одним проходом блокировки, без полного клонирования CoW-карты.

Результат: packdiff_snapshot вырос с 48.4k packs/s до 8,854k packs/s (183x), packdiff_apply — с 8.6k applies/s до 34.9k applies/s (4.1x).

Сеть и протокол Aether

Заголовок Aether — 25-байтная структура: magic, version, stream id, type, length, flags, за которой следует payload; целостность подтверждается CRC32. CRC вычисляется по двум сегментам, которые уже есть у читателя: первые 22 байта заголовка (header[0..21]) и payload. Проверку выполняет Frame::decode_parts(header, payload). Для вычисления используется crc32fast с полиномом IEEE 802.3 (0xEDB88320), а не CRC-32C (Castagnoli, 0x1EDC6F41), который реализует x86-инструкция crc32.

Мультиплексирование потоков обеспечивает Connection: он реализует мультиплексирование запрос/ответ поверх любого AsyncRead + AsyncWrite. Демультиплексирование на клиенте выполняется циклом, который вычитывает до 64 кадров за одно пробуждение и сопоставляет завершённые вызовы под одним проходом блокировки pending-карты. Какие именно флаги заголовка влияют на демультиплексирование, не указано.

call_batch и recv_many

call_batch собирает exec одного vectorized tick в один батч: запросы сериализуются и слоты ответов регистрируются за один проход под одним локом, кадры отправляются одной передачей (AetherWriter::send_batch — для UDS это буквально один write_all + flush на N кадров), а ответы ожидаются под одним дедлайном. Поведение на проводе (кадры, корреляция по запросу, демультиплексирование) идентично N отдельным call.

На приёме и серверный цикл соединения, и клиентский цикл демультиплексирования дренируют до 64 кадров за одно пробуждение (Receiver::recv_many для каналов; постоянный буфер чтения для UDS, так что один read даёт много кадров — симметрично send_batch). Поэтому пайплайнированный батч из N кадров стоит одного цикла park/unpark на приёме, а завершённые вызовы сопоставляются за один проход под локом pending-карты.

Прирост на sandbox-env пути объясняется тем, что ~90% стоимости было транспортными накладными, а не работой exec: серверный микробенч без транспорта дал 2.12 us на exec против ~20 us амортизированно на полном пути. Пайплайнированный batch pool дал ~35.5k steps/s, а per-env reference path вырос с ~19.8k до ~28.8k steps/s (1.45x).

CPU и память при высокой плотности

CpuClass::Idle в SandboxGovernor соответствует концепции SCHED_IDLE для агентских песочниц. Хост-агент Firecracker задаёт cgroup v1 cpu.shares значением 1024 для песочницы и выставляет memory.limit_in_bytes значением 2147483648 (2 ГиБ). Fireagent обеспечивает соблюдение лимитов cgroups для каждой песочницы, разделяет read-only базовые образы между микро-VM и использует copy-on-write рабочие тома через qcow2.

При нехватке памяти срабатывает модель reclaim: пауза около 4 секунд (checkpoint + reclaim) через EdgeNode::pause и ResourcePool::pause_reclaim с 60% reclaim model, а при возобновлении выполняется prefetch через MADV_WILLNEED в ResourcePool::resume_refetch.

В cgroup v2 лимит памяти задаётся записью в memory.max, обязательно в паре с memory.swap.max=0 (в v1 — memory.limit_in_bytes), а для атомарного убийства поддерева используется cgroup.kill=1. Учёт памяти ведётся через _weigh: RSS из statm, а на точном проходе — PSS из smaps_rollup; tmpfs-скретч учитывается по st_blocks, а SysV shm относится на создателя (cuid, поле 9 в /proc/sysvipc/shm).

IAM, квоты и Registry

Проекты образуют дерево, и квоты сворачиваются иерархически: использование ребёнка учитывается против бюджета каждого предка. При создании песочницы admission.

В старой реализации check_quota клонировал предковые Project и вызывал project_usage, который сканировал каждую запись песочницы и аллоцировал строку format!("{}/", root) на каждую запись на каждого предка — O(n) аллокаций на создание. Исправление ввело инкрементальный индекс: usage_add(rec, ±1) поддерживает HashMap<project, Usage> по цепочке предков записи при вставке/удалении/переключении паузы/потере узла, а project_usage стал O(1)-чтением. check_quota теперь обходит цепочку предков под read-lock проектов без промежуточных клонов.

Результат: создание ускорилось на +35–123%, burst — на +18–22% при 100k жизненных циклах с 0 ошибок.

RL co-design: развязка rollout и GPU-обучения

DSec развязывает stateful rollout execution и preemptible GPU training за счёт Operator SDK: циклы RL-обучения выталкивают песочницы на рабочие хосты, а GPU-узлы занимаются только вычислением градиентов, при этом состояние rollout переживает паузы обучения.

В dsec-rl это отражено через ReplayBuffer — step-major ring, где шаг t всех сред лежит подряд, что соответствует episode-major раскладке PufferLib для обучения. Буфер хранит obs/action/reward/done/logprob/value и пошаговые LSTM (h, c) для актора и критика. Эпизоды нарезаются непрерывно, флаги done перезапускают GAE, скрытые состояния обнуляются на старте эпизодов (сброс LSTM как в PufferLib) с инвариант-чекером и починкой. EnvPool выполняет векторизованный шаг по scoped threads с семантикой reset_if_done (авто-сброс завершённых сред), а Driver — цикл rollout, который снимает снимок входного состояния модели, шагает среды и обнуляет h/c на done перед следующим forward. Preemptible rollouts (pause/resume) реализуются через pause/resume песочницы через SDK.

Откатанная оптимизация enqueue

Оптимизация enqueue в ReplayBuffer была переписана на field-major bulk-copy: один SIMD memcpy на поле на шаг вместо N копий на env. Замер в interleaved A/B прогонах показал примерно на 20% медленнее. Причина регресса: каждая per-env копия составляет 256-512 B, и компилятор векторизует её в плотные AVX-циклы, тогда как large-copy путь libc проигрывает на этом классе ядер VM. Bulk-версию откатили, а быстрый per-env цикл остался с комментарием, фиксирующим результат, чтобы эксперимент не повторяли вслепую. В таблице результатов rl_buffer_enqueue показан как par: 6.0M trans/s в v0.1.0 против 6.3M trans/s в optimized.

Граничные случаи и отказы

При сетевом разрыве (network partition) между management plane и Edge-узлом watcher перестаёт получать heartbeats и по истечении TTL истекает узел, выселяя его песочницы. Точное значение TTL и конкретное поведение клиента при создании песочницы в момент разрыва не указаны. Конфликт при возврате узла разрешается через preemption: жертвы выбираются paused-first, затем по наименьшему приоритету. Edge-узел владеет жизненным циклом песочницы через CAS-переходы и admission control против ResourcePool узла.

Идемпотентность создания песочницы обеспечивается заголовком Idempotency-Key: клиент может повторить запрос create с тем же ключом в течение 5 минут, и API вернёт уже существующую песочницу вместо создания дубликата. Вторая попытка не создаёт новую микро-VM и не расходует дополнительные ресурсы. Гонки при размещении разрешаются движком k-choice: фильтрация, сэмплирование k, скоринг, выбор лучшего из k, ничьи — seeded jitter. Повторные heartbeat от одного узла после рестарта обрабатываются watcher'ом: heartbeats несут информацию об использовании, watcher истекает узлы по TTL и выселяет их песочницы.

Как сравнивали и что получилось

Стенд и методология

Стенд — 2-ядерная виртуальная машина класса CI, сборка в release-профиле, чередующееся A/B-сравнение против базовой версии v0.1.0. Сборка cargo build --release занимает около 2 минут на 2 ядрах. Измерения проводятся как чередующиеся медианы по 3 прогонам на 2-ядерной ВМ. Минимальный стенд выбран для сопоставления с числами из статьи: в таблице scale anchors показано соответствие paper-чисел и бенчмарков на 2-ядерной ВМ, например ~5,000 creations/s сопоставляется с 5,806/s, а 100k+ concurrent sandboxes — со 100k burst. Версия Rust зафиксирована как MSRV 1.85. Версии зависимостей не указаны.

Чередование A/B на одной машине устраняет дрейф железа: оба варианта измеряются вперемешку на одном и том же железе, а не в разных прогонах. Принцип — накладные расходы бенчмарк-харнесса не должны загрязнять измеряемое.

Результаты по бенчмаркам

benchmarkv0.1.0optimizedизменение
rl_env_steps_fast (64 envs, 4 workers)599k steps/s1,419k steps/s2.4x
rl_gae (8192 x 64 window)22.6M trans/s117.5M trans/s5.2x
protocol_encode419 MB/s888 MB/s2.1x
protocol_decode310 MB/s892 MB/s2.9x
packdiff_snapshot48.4k packs/s8,854k packs/s183x
packdiff_apply8.6k applies/s34.9k applies/s4.1x
apiserver_rps_keepalive (pipelined)21-31k (cold only)129k RPS6.1x vs cold
apiserver_rps (connection-per-request)31.5k31.0kpar (client-bound)
creation (5k @ 64 concurrent)5.9-9.9k/s13.2-13.4k/s+35-123%

Соотношение с paper-числами: creation rate по paper latency model при 256 concurrent — 6,306/s против ~5,000/s cluster-wide; burst lifecycle — 56.7k/s, 0 errors @ 100k против 100k+ concurrent sandboxes; apiserver throughput (keep-alive, pipelined) — 129,000 RPS против требования sustain ~5k creates/s; RL vectorized envs — 1.42M steps/s против PufferLib ~1M+/s; GAE — 117M transitions/s против C-path в pufferlib train().

17 наборов dsec-bench

В dsec-bench входят 17 наборов: creation rate (software + paper-latency), 100k burst lifecycle, pause/resume latency distributions, apiserver RPS, RL throughput (envs / buffer / GAE / driver / sandbox-envs), pack_diff size + apply + snapshot, placement throughput + k-choice quality, codec encode/decode.

Полный прогон одной командой:

cargo run --release -p dsec-bench          # full suite, writes bench-results.{json,md}
cargo run --release -p dsec-bench -- --quick
cargo run --release -p dsec-bench -- creation --count 5000 --concurrency 256 --paper-latency
cargo run --release -p dsec-bench -- burst --count 100000

Что цифры не показывают

В dsec-rs намеренно не создаются реальные контейнеры, microVM и namespaces: изоляция представлена как latency-profiled simulations, а реальное развёртывание подразумевает Firecracker API, containerd и настоящие EROFS-монтирования. Сеть тоже поддельная — egress routing это fake router с deny lists, тогда как в статье используются eBPF-программы на каждый sandbox. Распределённое хранилище заменено однопроцессным блочным хранилищем вместо 3FS, а pack_diff моделирует репликацию, а не сетевую передачу. Механизмы ядра (SCHED_IDLE, balloon/DAMON, core scheduling, MADV_WILLNEED, vfork) смоделированы как учёт и профили задержек. Реальные сборки контейнеров, реальные LLM-rollout и реальное GPU-обучение вынесены за рамки: крейт заменяет их детерминированными эквивалентами, сохраняющими наблюдаемые контракты. Поэтому цифры отражают работу алгоритмов, а не инфраструктуры.

Осталось неоптимизированным то, что при exec 2.12 us и ~48k execs/s aggregate на 2 ядрах оставшийся ~10x — это tokio task granularity: per-exec task spawn + schedule + wakeups. Следующим структурным шагом был бы inline-first-poll fast path (опрашивать каждое request future один раз в reader-задаче; спавнить только те, что реально приостанавливаются — безопасный паттерн требует waker с перепланированием) или io_uring для UDS-транспорта; оба варианта сохраняют протокол, но были признаны слишком рискованными, чтобы вносить их вслепую.

Что из этого следует на практике

  • Разделение management plane и data plane — не косметика: каждая операция песочницы идёт через мультиплексированный Aether-запрос, а не через вызов плоскости управления, поэтому apiserver можно масштабировать горизонтально, не трогая путь ввода-вывода.
  • Планировщик сознательно жертвует оптимальностью ради сложности: сэмплирование k вместо полного перебора даёт O(k) на решение при ~5k creations/s, а seeded jitter гасит herd-эффекты; качество k-choice при этом не меняется (67.65% от исчерпывающего улучшения).
  • Плотность держится на трёх вещах: лимиты cgroups на песочницу, разделение read-only базовых образов и CoW-тома. Пауза освобождает ~60% памяти, поэтому вытеснение начинается именно с приостановленных песочниц — они уже выгружены.
  • Инкрементальный индекс квот и refcount-таблица в pack_diff показывают общий приём: убрать O(n)-работу с горячего пути, заменив её O(1)-чтением и разделяемыми структурами. Это дало +35–123% на создании и 183x на снапшоте.
  • Цифры бенчмарков измеряют алгоритмы, а не инфраструктуру: изоляция, сеть, хранилище и механизмы ядра симулированы. Переход к продакшн-стеку означает замену ровно пяти точек — transport (in-process channels → real UDS / vsock), sandbox backend (userspace FnCall/симулированные контейнеры → Firecracker microVMs), storage (in-memory EROFS-style images → real EROFS/3FS/OverlayBD), judge (AnchorJudge/regex/rubric/executable → ваш LLM-judge API) и solver/proposer policy (scripted deterministic policies → ваш model endpoint). Всё остальное — IAM, квоты, placement, кодек Aether, env harness, верификаторы, flywheel — переносится без изменений.

Где смотреть в коде

Источники

Похожее