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планировщик, выбирающий лучший узел из k случайно отобранных кандидатов с 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 обрабатывает каждый запрос на создание в четыре шага:
- Фильтрует кандидатов по capacity, image affinity, labels, health и политике local-vs-cloud.
- Сэмплирует k узлов — чтобы решения оставались O(k), а не O(n) при ~5k creations/s.
- Оценивает их по packingнасколько плотно удаётся уложить новые песочницы на узлы, чтобы не оставлять неиспользуемых ресурсов, fragmentationнасколько сильно при этом дробится свободное место на узлах, localityблизость узла к данным и зависимостям песочницы, чтобы меньше обращаться по сети, topologyучёт сетевой и физической топологии кластера при выборе узла и costстоимость размещения, включая штраф за облачные узлы.
- Выбирает лучший из 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 на одной машине устраняет дрейф железа: оба варианта измеряются вперемешку на одном и том же железе, а не в разных прогонах. Принцип — накладные расходы бенчмарк-харнесса не должны загрязнять измеряемое.
Результаты по бенчмаркам
| benchmark | v0.1.0 | optimized | изменение |
|---|---|---|---|
rl_env_steps_fast (64 envs, 4 workers) | 599k steps/s | 1,419k steps/s | 2.4x |
rl_gae (8192 x 64 window) | 22.6M trans/s | 117.5M trans/s | 5.2x |
protocol_encode | 419 MB/s | 888 MB/s | 2.1x |
protocol_decode | 310 MB/s | 892 MB/s | 2.9x |
packdiff_snapshot | 48.4k packs/s | 8,854k packs/s | 183x |
packdiff_apply | 8.6k applies/s | 34.9k applies/s | 4.1x |
apiserver_rps_keepalive (pipelined) | 21-31k (cold only) | 129k RPS | 6.1x vs cold |
apiserver_rps (connection-per-request) | 31.5k | 31.0k | par (client-bound) |
creation (5k @ 64 concurrent) | 5.9-9.9k/s | 13.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 — переносится без изменений.
Где смотреть в коде
- idrees2516/dsec-rs/docs/performance.md: (interleaved medians, 2-core VM)
- idrees2516/dsec-rs/docs/architecture.md: node
- idrees2516/dsec-rs/docs/performance.md: Placement: Floyd sampling + one RNG critical section
- idrees2516/dsec-rs/docs/architecture.md: — liveness, eviction, preemption
- idrees2516/dsec-rs/README.md: vs the paper
- idrees2516/dsec-rs/docs/architecture.md: (`dsec-storage`)
- idrees2516/dsec-rs/docs/architecture.md: Engine — k-choice power scheduling
- idrees2516/dsec-rs/docs/usage.md: The production integration points