EVRTCK: тайловый дельта-кодек для удалённого рабочего стола — как мы убили H.264 для UI-контента

от автора

Мы написали свой кодек для lossless трансляции экрана на Rust

Я Артур Валиев, разработчик EVRT

Когда мы форкнули RustDesk и начали строить EvertyDesk — корпоративный удалённый рабочий стол с поддержкой Hyper-V, Proxmox, VirtualBox и умным агентом — первое, во что упёрлись, было видео.

H.264 — стандарт де-факто. TeamViewer, AnyDesk, Windows App — все используют H.264. Он работает. Но он lossy. И вот здесь начинается проблема, о которой в статьях про remote desktop обычно не говорят.

Когда оператор смотрит на терминал с git diff или читает трейс в VSCode — один размытый пиксель в букве это уже другой символ. H.264 при любом разумном битрейте вносит артефакты в области с резким контрастом. Точно туда, где у нас весь текст. Это неприемлемо.

H.264 в lossless режиме (QP=0, lossless profile) существует, но поддержка в декодерах — через пень-колоду, а для mostly-static контента предикторы рассчитанные под видео с движением дают нулевой выигрыш над тривиальным delta-кодером.

Нам не нужен motion estimation. Нам нужно: вот предыдущий кадр, вот текущий, вот список изменившихся тайлов, сожми только их.

Мы написали EVRTCK.

Wire format

Описывать протокол словами скучно. Вот пакет побайтово:

SizeFieldNotes04 Bmagic0x45 0x56 0x43 0x4B — ASCII «EVCK»41 Bversion0x01 — единственная реализованная версия51 Bflags0x01 = KEYFRAME · 0x02 = NOP (кадр идентичен предыдущему)64 Bframe_idu32 LE, монотонно возрастающий104 Bwidthu32 LE, пикселей144 Bheightu32 LE, пикселей182 Bmap_bytesu16 LE = ceil(tile_count / 8)20N Btile_mapбитовый массив: бит i установлен если тайл i грязный20+N…payloadsпоследовательно, только для грязных тайлов

Каждый payload начинается с байта mode. Три возможных значения:

SOLID0x014 байта RGBA — весь тайл залит одним цветомDELTA0x02u32 length + ZRLE-сжатый XOR-дельтаZSTD0x03u32 length + zstd level-1 XOR-дельта

Тайл — 32×32 пикселя = 4096 байт в BGRA. На 1080p: 60×34 = 2040 тайлов. Tile map: ceil(2040/8) = 255 байт. Полный пакет для 0% dirty = 275 байт. При 15% dirty (308 грязных тайлов, пользователь печатает в терминале) — ~1.7 КБ. Против 7 864 320 байт raw BGRA.

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

MODE_SOLID

Если все 1024 пикселя тайла одного цвета — фон, заливка, иконка — весь payload это 5 байт (mode + RGBA). Встречается значительно чаще, чем кажется: большинство UI-элементов монохромны по тайлам.

fn try_solid(tile: &[u8]) -> Option<[u8; 4]> {    let mut chunks = tile.chunks_exact(4);    let first = chunks.next()?;    let color = [first[0], first[1], first[2], first[3]];    for chunk in chunks {        if chunk != color { return None; }    }    Some(color)}

Для остальных тайлов — XOR-дельта между текущим и предыдущим кадром, затем компрессия. Выбор между ZRLE и zstd: если len(zrle) < raw_delta × 0.9 — ZRLE, иначе zstd level 1.

ZRLE эффективен когда большинство байт дельты — нули: небольшое изменение в текстовом курсоре, один символ в строке. Runs of zeros кодируются в 2 байта. zstd level 1 (~500 МБ/с throughput) выигрывает при высокой энтропии дельты — перемещение окна, частичный скролл.

Одна тонкость: prev хранится в BGRA (raw capture format от DXGI/GDI), а на wire отдаём RGBA. Конвертация — в момент сборки тайла в contiguous буфер, без отдельного прохода:

for y in y0..y1 {    for x in x0..x1 {        let off = (y * width + x) * 4;        // BGRA → RGBA: swap индексов 0 ↔ 2, G и A не трогаем        tile.push(rgba[off + 2]);       // R        tile.push(rgba[off + 1]);       // G        tile.push(rgba[off    ]);       // B        tile.push(rgba[off + 3]);       // A        tile_prev.push(prev[off + 2]);        tile_prev.push(prev[off + 1]);        tile_prev.push(prev[off    ]);        tile_prev.push(prev[off + 3]);    }}

XOR после конвертации корректен: оба буфера в одном формате, операция XOR channel-независима.

Параллелизм через rayon

Тайлы независимы — идеально для data-parallel workload. Каждая задача читает только свои строки из cur и prev (shared read, никакой синхронизации), пишет в свой элемент результирующего вектора:

let tile_results: Vec<Option<(Vec<u8>, u8)>> = (0..tile_count)    .into_par_iter()    .map(|idx| {        let tx = idx % tiles_x;        let ty = idx / tiles_x;        if tile_is_dirty(rgba, prev, width, height, tx, ty) {            Some(encode_tile_buf(rgba, prev, width, height, tx, ty))        } else {            None        }    })    .collect();

Ни одного Mutex, ни одного Arc. Rust ownership гарантирует корректность статически: rgba и prev — shared &[u8], доступные из любого потока; tile_results собирается из независимых Vec<u8>.

Keyframe throughput: 89 МиБ/с на 720p, 91 МиБ/с на 1080p, 91 МиБ/с на 4K. Три разных разрешения, одна цифра — кодек упёрся в memory bandwidth, а не в вычисления. Rayon максимально использует все ядра, дальнейшая оптимизация на CPU невозможна без смены алгоритма.

IDR, смена кодека и неочевидный баг

Параллельно с EVRTCK живёт H.264 через Media Foundation (Windows) и NVENC (если есть NVIDIA). Переключение — на основе dirty ratio и типа контента. Для видеопотока внутри браузера или анимаций EVRTCK проигрывает: он lossless, у него нет motion compensation, и при 90% dirty он шлёт все грязные тайлы целиком.

Один из первых неочевидных багов: при переключении H.264 → EVRTCK клиент получал первый P-frame EVRTCK и применял его к нулевому буферу (свежеинициализированный prev). Визуальный мусор на несколько секунд до следующего IDR.

Очевидное решение — IDR при переключении. Неочевидность: переключение двунаправленное. При возврате EVRTCK → H.264 H.264 декодер на клиенте тоже должен получить IDR, иначе он применяет P-frame дельты к неправильному опорному кадру.

// Недостаточно: только H264→EVRTCKif evrt_on && !was_evrt_on { force_recovery_key = true; }// Правильно: оба направленияif evrt_on != was_evrt_on {    force_recovery_key = true;}was_evrt_on = evrt_on;

IDR_MIN_EVRTCK = 10 секунд против IDR_MIN_H264 = 1200 мс. EVRTCK может позволить себе редкие keyframe-ы: P-frame даже после 10 секунд изменений на UI-контенте весит несколько килобайт. H.264 нуждается в частых IDR для recovery при потере пакетов.

NOP frame: честно об оптимизации

Самый распространённый сценарий в enterprise remote desktop: пользователь читает документ, ждёт деплоя, смотрит в Confluence. Кадры идентичны. До оптимизации:

Rayon запускал 2040 задач на 1080p. Каждая читала строки тайла с stride-ed доступом через 7680 байт (ширина строки 1920×4 байта). Ни одна не находила изменений. Итого: 3.17 мс на холостом ходу при 30 fps.

Решение выглядело элегантно: один rgba == prev перед rayon-циклом. Rust компилирует сравнение слайсов в вызов memcmp, который на x86-64 реализован через AVX2 и сравнивает 32 байта за такт. При bandwidth ~50 ГБ/с это должно было занять ~0.15 мс.

// Быстрый путь: кадр идентичен предыдущемуif !is_keyframe && rgba == prev {    let mut out = Vec::with_capacity(20);    out.extend_from_slice(MAGIC);    out.push(VERSION);    out.push(FLAG_NOP);              // 0x02    out.extend_from_slice(&frame_id.to_le_bytes());    out.extend_from_slice(&(width as u32).to_le_bytes());    out.extend_from_slice(&(height as u32).to_le_bytes());    out.extend_from_slice(&0u16.to_le_bytes()); // map_bytes = 0    return (out, FrameStats::default());}// Criterion: 2.93 мс. Ускорение — 8%.

Почему не 20×. rgba == prev — это memcmp двух буферов по 8.29 МБ каждый. Оба больше L3 кэша. Оба загружаются из RAM: 16.58 МБ при реальном DDR4 bandwidth ~5–6 ГБ/с = ~2.8–3 мс. Rayon раньше читал ровно те же 16.58 МБ, только через strided-доступ с overhead на 2040 задач — вот эти ~0.24 мс мы и сэкономили.

Настоящая победа другая: пакет уменьшился с 275 байт до 20 байт. Декодер при FLAG_NOP читает header, возвращает prev как есть — буквально ноль работы. При 30 fps на статичном экране:

До 61 Кбит/с 30 × 275 байт/кадр × 8 После 5 Кбит/с 30 × 20 байт/кадр × 8

Benchmark: Criterion, реальные числа

Все замеры: Criterion 0.5, 100 сэмплов, opt-level=3 lto=thin, Windows 11 x86-64, CPU backend (GPU Phase 2 в разработке).

Сценарий

Время

Throughput

Keyframe 720p

39.4 мс

89.3 МиБ/с

Keyframe 1080p

86.7 мс

91.2 МиБ/с

Keyframe 4K

344.6 мс

91.8 МиБ/с

P-frame 0% dirty — NOP path

2.93 мс

2.63 ГиБ/с

P-frame 5% dirty — движение курсора

3.17 мс

2.44 ГиБ/с

P-frame 15% dirty — печать в терминале

3.34 мс

2.31 ГиБ/с

P-frame 50% dirty — скролл в браузере

4.18 мс

1.85 ГиБ/с

P-frame 90% dirty — видео в браузере

5.37 мс

1.44 ГиБ/с

Roundtrip encode+decode 1080p 15%

3.99 мс

1.94 ГиБ/с

30 fps бюджет = 33.3 мс. Даже при 90% dirty мы в 6.2× запасе. Интересно что разница между 0% и 5% dirty — почти ноль: overhead rayon и tile scan на 1755 чистых тайлах доминирует над кодированием 102 грязных.

Wire size (синтетические фреймы с solid-color тайлами — реальный контент будет больше, порядки те же):

Dirty

Encoded

vs 7.91 МиБ raw

0%

20 Б

393 216:1

5%

779 Б

10 648:1

15%

1.7 КБ

4 642:1

50%

5.2 КБ

1 551:1

90%

9.2 КБ

877:1

Где мы слабы — честно

Dirty detection завязан на CPU scanWindows Desktop Duplication API (IDXGIOutputDuplication::AcquireNextFrame) возвращает dirty rectangles как метаданные к захваченному кадру — бесплатно, без сканирования. Используй мы их, tile_is_dirty не нужен вообще. Это Phase 2: WGPU-бэкенд уже написан и инициализирует GPU-буферы при старте, но пока работает на CPU fallback.

Нет протокола рассинхронизацииЕсли TCP-буфер переполнился и пакет потерялся — prev у энкодера и декодера расходятся навсегда до следующего IDR (до 10 секунд). Нужен: frame hash в каждом пакете и out-of-band механизм запроса IDR декодером при mismatch. Сейчас этого нет.

TILE_SIZE = 32 захардкоженДля 4K разумнее 64 или 128: tile_map overhead и per-tile spawn cost при 2040 тайлах перевешивают прирост от точного dirty detection. Для мобильных клиентов (Android, 720p) — возможно 16.

Rayon overhead при sparse dirtyПри < 50 грязных тайлах sequential encode быстрее чем spawn 2040 rayon-задач. Adaptive threshold: если dirty_count < 50 — sequential, иначе — rayon. Не реализовано.

90% dirty → лучше IDR чем P-frameПри высоком dirty_ratio overhead на tile_map, per-tile headers и 2040 rayon-задач делает keyframe эффективнее. Конкретный порог нигде не специфицирован и не измерен.

Абстрактные цифры encode-time ничего не говорят сетевому инженеру. Вот что уходит в провод при 30 fps и 60 fps — по сценариям:

Сценарий

Encode

Размер пакета

@ 30 fps

@ 60 fps

Idle — статичный экран

2.93 мс

20 Б

5 Кбит/с

10 Кбит/с

Мигание курсора, статус-бар

3.17 мс

779 Б

187 Кбит/с

373 Кбит/с

Печать в терминале (15% dirty)

3.34 мс

1.7 КБ

408 Кбит/с

816 Кбит/с

Скролл в браузере (50% dirty)

4.18 мс

5.2 КБ

1.2 Мбит/с

2.4 Мбит/с

Видео в браузере (90% dirty)

5.37 мс

9.2 КБ

2.2 Мбит/с

4.4 Мбит/с

Raw BGRA 1080p — без кодека

7.86 МБ

1 887 Мбит/с

3 774 Мбит/с

При типичном корпоративном сценарии — оператор читает логи, иногда переключает вкладки — реальный трафик не выходит за 200–400 Кбит/с. Это меньше, чем голосовой звонок в Telegram.

Единственный сценарий где EVRTCK проигрывает H.264 — видео в браузере при 60 fps: 4.4 Мбит/с против ~2 Мбит/с у lossy H.264. Там мы переключаемся на H.264 автоматически по dirty_ratio. Текст и UI — наша территория.

Latency budget: 33.3 мс на 30 fps

Этап

Время

Источник

DXGI capture (IDXGIOutputDuplication)

~1 мс

Windows API overhead

EVRTCK encode — idle (NOP path)

2.93 мс

Criterion

EVRTCK encode — typing 15% dirty

3.34 мс

Criterion, 1080p

EVRTCK encode — scroll 50% dirty

4.18 мс

Criterion, 1080p

EVRTCK decode на клиенте

0.65 мс

roundtrip 3.99 − encode 3.34

Сеть LAN 1 Гбит (408 Кбит/с)

< 1 мс

Сеть WAN Москва–СПб VPN

~15 мс

типичный RTT

Итого LAN, typing

~6 мс из 33.3 мс

5.2× запас

Итого WAN Москва–СПб, typing

~21 мс из 33.3 мс

1.6× запас

Encode занимает 10–18% frame budget даже в худшем типичном случае. Ботлнек у нас никогда не кодек — всегда сеть.

EVRTCK против протоколов конкурентов

Протокол

Lossless текст

Idle трафик

Typing трафик

RFB / VNC (ZRLE)

~0 Кбит/с

~800 Кбит/с

RDP / RemoteFX (H.264)

✗ lossy

50–200 Кбит/с

1–3 Мбит/с

H.264 lossless (QP=0)

200–500 Кбит/с

2–5 Мбит/с

EVRTCK

5 Кбит/с

408 Кбит/с

RFB/VNC — ближайший родственник по архитектуре. Разница: VNC не делает XOR-дельту на уровне пикселя, не использует zstd, и нет MODE_SOLID. При изменении одного символа в терминале VNC шлёт весь изменившийся прямоугольник. EVRTCK шлёт тайл с XOR-дельтой где ненулевых байт максимум 100.

Encode по разрешениям: полная таблица

P-frame бенчмарки запускались на 1080p. Для других разрешений — линейная экстраполяция по числу тайлов (числа bandwidth-bound, масштабирование линейное):

Разрешение

Тайлов

Keyframe

Idle NOP

Typing 15%

Scroll 50%

@ 30 fps typing

720p

1 035

39.4 мс

2.93 мс

~1.7 мс

~2.1 мс

~208 Кбит/с

1080p

2 040

86.7 мс

2.93 мс

3.34 мс

4.18 мс

408 Кбит/с

1440p

3 240

~138 мс

~2.93 мс

~4.2 мс

~5.3 мс

~648 Кбит/с

4K

8 160

344.6 мс

~2.93 мс

~8.4 мс

~10.5 мс

~1.6 Мбит/с

Keyframe на 4K — 344 мс — единственная цифра, которая выглядит страшно. Но keyframe случается раз в 10 секунд. 344 мс / 10 000 мс = 3.4% overhead к среднему encode time. На практике незаметно.

NOP latency не масштабируется с разрешением — это всегда memcmp (bandwidth-bound) плюс 20-байтный write. 720p даёт тот же 2.93 мс: оба буфера всё равно вылетают из L3.

Где это работает

EVRTCK — не proof of concept. Это production кодек в EvertyDesk: корпоративный удалённый рабочий стол для российского Enterprise на основе форка RustDesk.

Фича

Статус

EVRTCK lossless для text/UI

Production

Автопереключение EVRTCK ↔ H.264 по dirty_ratio

Production

Smart Agent — heartbeat, push-конфиг, уведомления

Production

Hyper-V / Proxmox / VirtualBox управление

Production

GPU backend (WGPU)

Beta

DXGI dirty rects Phase 2

Roadmap Q3 2026

Исходники EVRTCK выйдут с публичной бетой. Попробовать уже сейчас — desk.everty.ru.

H.264 — отличный кодек для видео. Для экрана, где 90% времени неподвижный текст и пиксель-в-пиксель точность критична — он избыточен в одном месте и недостаточен в другом. EVRTCK решает ровно ту задачу, которую нужно решить, и не решает то, что решать не нужно.

33 строки wire format. Три режима кодирования тайла. 20 байт на статичный кадр. 408 Кбит/с на печать в терминале. 16 unit-тестов, все зелёные.

Если у вас есть вопросы по реализации или собственные цифры на сравнимой задаче — пишите в комментарии.

ссылка на оригинал статьи https://habr.com/ru/articles/1059238/