8.4 KiB
title, sidebar_position
| title | sidebar_position |
|---|---|
| Синхронизация: stream sync, не CUDA events | 3 |
Синхронизация: stream sync, не CUDA events
Между producer'ом и consumer'ом в разных процессах нужен механизм, который гарантирует: к моменту когда consumer начинает читать slot, все GPU writes producer'а в этот slot уже зафиксированы в HBM. До v0.4 этим занимались CUDA IPC events. С v0.4 — cuStreamSynchronize + atomic ordering. Смена не косметическая, и здесь объяснено почему.
Что было в v0.3 — CUDA IPC events
Producer на каждый publish делал cudaEventRecord на свой stream. Handle event'а (cudaIpcEventHandle_t) экспортировался один раз при старте и шарился со всеми subscriber'ами. Subscriber на каждый frame делал cudaStreamWaitEvent на свой stream — GPU scheduler сам ждал completion record'а producer'а перед тем как пустить DtoD copy в очередь.
Преимущество: CPU не блокируется. Producer кидает работу в очередь и едет дальше; ожидание происходит в GPU command queue.
Недостаток: CUDA IPC events требуют shared PID namespace между процессами — точно так же как требовал cudaIpcOpenMemHandle. NVIDIA Driver API экспортирует event handle только через тот же legacy IPC механизм, для которого нет POSIX FD аналога. CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR существует для memory, для events — нет.
Этот недостаток нас и убил на Frigate. Frigate под s6-overlay не отдаёт shared PID — а попытка cudaIpcOpenEventHandle без shared PID падает молча и subscribe зависает на первом frame timeout'ом. См. memory feedback про pid share.
Что делает v0.4 вместо
Producer перед публикацией seq делает cuStreamSynchronize на тот stream куда писались GPU данные. Это блокирующая CPU-операция — функция возвращается только когда все pending writes этого stream'а зафиксированы. После этого atomic store в shared header.
Из producer.c::do_publish (v0.4):
static int do_publish(cuframes_publisher_t *pub, int32_t slot,
void *stream, int64_t pts_ns)
{
/* 1. ждём GPU writes этого stream'а */
cuStreamSynchronize((CUstream)stream);
/* 2. обнуляем ack-bitmap для нового seq */
atomic_store_explicit(&pub->hdr->slots[slot].ack_bitmap, 0,
memory_order_release);
atomic_store_explicit(&pub->hdr->slots[slot].pts_ns, pts_ns,
memory_order_release);
/* 3. publish slot.seq — после этого consumer его увидит */
atomic_store_explicit(&pub->hdr->slots[slot].seq, pub->next_seq,
memory_order_release);
/* 4. publish global_seq — wake-up для poll'ящих consumer'ов */
atomic_store_explicit(&pub->hdr->global_seq, pub->next_seq,
memory_order_release);
return 0;
}
Consumer на той стороне (consumer.c) делает acquire-load, читает slot, и повторно проверяет seq уже после того как зафиксировал указатель — это защита от того что producer успел перезаписать slot между чтением global_seq и реальным копированием:
uint64_t gs = atomic_load_explicit(&sub->hdr->global_seq, memory_order_acquire);
// ... найти slot_idx по gs ...
uint64_t slot_seq = atomic_load_explicit(&sub->hdr->slots[slot_idx].seq,
memory_order_acquire);
int64_t pts = atomic_load_explicit(&sub->hdr->slots[slot_idx].pts_ns,
memory_order_acquire);
/* v0.4: producer уже cuStreamSynchronize'нул перед atomic_store seq.
* post-check verify_seq защищает от перезаписи slot'а producer'ом. */
uint64_t verify_seq = atomic_load_explicit(&sub->hdr->slots[slot_idx].seq,
memory_order_acquire);
После post-check consumer делает DtoD memcpy в свой stream. На одном GPU hardware coherence гарантирована — HBM один, cache L2 общий, после cuStreamSynchronize у producer'а все его writes уже в L2/HBM, и любой subsequent kernel/copy с того же GPU их увидит.
Sequence diagram
sequenceDiagram
participant PApp as Publisher app
participant PStream as Publisher CUDA stream
participant HBM as GPU HBM (shared)
participant Header as SHM header (atomic)
participant CApp as Consumer app
participant CStream as Consumer CUDA stream
PApp->>PStream: kernel / NVDEC writes
Note over PStream,HBM: async — pending in stream
PApp->>PStream: cuStreamSynchronize
PStream-->>HBM: all writes flushed
PStream-->>PApp: return (CPU unblocked)
PApp->>Header: atomic_store(seq, release)
loop poll
CApp->>Header: atomic_load(global_seq, acquire)
end
CApp->>Header: read pts, slot_seq
CApp->>Header: atomic_load(slot_seq, acquire) [verify]
CApp->>CStream: cuMemcpyDtoDAsync(slot → dst)
CStream->>HBM: read — sees flushed data
CStream-->>CApp: copy enqueued
Trade-offs
| v0.3 (CUDA IPC events) | v0.4 (stream sync) | |
|---|---|---|
| Cross-namespace (без shared PID) | нет | да |
| CPU блокировка на publish | нет | да, ~1 ms |
| GPU command queue ordering | автоматически | вручную (acquire/release) |
| Лишний race без post-check | нет | да, защищён verify_seq |
| Зависимость от CUDA Driver feature | cudaIpcGetEventHandle |
cuStreamSynchronize (всегда есть) |
| Совместимость с s6-overlay / Frigate | сломано | работает |
cuStreamSynchronize стоит порядка миллисекунды (зависит от того сколько pending work на stream'е). На 25 fps publisher это ≈ 2.5% CPU времени publisher thread'а — заметно, но не критично для real-time CCTV. Если для твоего сценария это дорого — возможна оптимизация через cuEventQuery polling, но v0.4 этого пока не делает (sync проще, корректнее, и достаточно дёшев).
Отказ от events — это не «лучше или хуже», это смена области применения. v0.3 не работал в s6/Frigate. v0.4 работает, ценой ~1 ms CPU per publish.
Что это означает для разработчика
- Передавай в
cuframes_publisher_publishтот же stream, на котором писались данные. ИначеcuStreamSynchronizeбудет ждать чужие writes (в худшем случае — никаких) и data race вернётся. stream = NULL(default stream) допустим, но default stream сериализуется со всем GPU-контекстом — это обычно медленнее чем dedicated stream.- Consumer не должен полагаться на CUDA event sync — его больше нет. Stream sync на producer'е + atomic ordering на consumer'е заменяют всю старую IPC event machinery.
Следующее
- Frame ring vs Packet ring — packet ring sync проще, там нет CUDA вообще.
- Ownership modes — почему VMM ограничение убрало и EXTERNAL и события одновременно.
- Protocol reference — точный layout shared header и atomic-полей.