видеокарта прогревается в фоне, пока считает процессор

This commit is contained in:
Данил Омелечко 2026-09-26 14:47:41 +03:00
parent 89ca5b4163
commit e0999ad870
4 changed files with 215 additions and 12 deletions

View file

@ -97,7 +97,7 @@ flyguard/ ядро: стадии обработки, память, сч
export.py 3D-рамки, время до столкновения, маркеры RViz
synth.py вставка предметов трассировкой лучей
tools/ обучение, оценка, разбор, полигон с аугментациями
tests/ 50 тестов, запускаются без данных и без ROS
tests/ 52 теста, запускаются без данных и без ROS
docs/ методика и результаты
artifacts/ обученные модели
```

View file

@ -3318,3 +3318,66 @@ cuBLAS и прочие не нужны, но PyTorch без них не загр
`TORCH_WHEELS` (`docker/fetch_wheels.py`, `docker/wheels/README.md`). Через
контекст сборки колёса не передаются: первая попытка так делала, и контекст в
4.1 ГБ плюс слой с колёсами плюс установка чуть не заняли весь диск C:.
### 21.7. Docker Desktop: тот же архив на чистом движке
Образ перенесён файлом, как на стенд: выгружен из Docker в WSL
(`flyguard_image.tar`, 4.7 ГБ — слои внутри уже сжаты) и загружен
`docker load -i` в Docker Desktop 29.8 на Windows 11 (движок в WSL 2) за 6 мин.
Флаг `--gpus all` работает, как на Linux: PyTorch в контейнере видит RTX 5070 Ti,
без флага CUDA не видна и узел считает на процессоре. Бэги монтируются из Ubuntu
(`docker run` из WSL, `-v /root/bags:/data`), дымовой тест проходит.
| запись | счёт | кадр, медиана / p95 | принято | итог |
|---|---|---|---|---|
| `doubleT_obstacle` | видеокарта | 23.9 / 35.2 мс | 201 из 201 | 189 из 190, 55.8 м |
| `doubleT_obstacle` | процессор | 34.8 / 39.4 мс | 201 из 201 | 189 из 190, 55.8 м |
| синтетика | видеокарта | 26.4 / 42.6 мс | 1510 из 1510 | 398 кадров с тревогой |
| синтетика | процессор | 42.7 / 48.0 мс | 1510 из 1510 | 398 кадров с тревогой |
Это итоговый архив, с прогревом видеокарты в фоне (п. 21.8); замер ждёт
готовности видеокарты. Первый архив, без фонового прогрева, дал те же решения
и то же время с точностью до шума: 24.2 / 33.7 и 25.9 / 42.5 мс на видеокарте,
32.7 / 36.0 и 42.0 / 47.0 на процессоре. Синтетика с видеокартой здесь впервые
после правки прогрева (п. 21.5): 1510 кадров из 1510 и те же 398 кадров
тревоги, что на процессоре. Худший кадр синтетики на видеокарте — 0.37 с (и до
фонового прогрева был 0.33 с), но кадры не теряются: отброшено 0.
Каждый контейнер с видеокартой под WSL пишет в свой слой кэш драйвера CUDA,
около 330 МБ, и диск Docker Desktop рос: 12 → 18.7 ГБ за день вместе со второй
загрузкой архива. С `CUDA_CACHE_DISABLE=1` четыре прогона добавили 32 МБ, а
видеокарта готова через те же 16–17 с.
### 21.8. Долгий старт видеокарты и прогрев в фоне
С видеокартой узел поднимался 15–16 с, на процессоре — 0.8 с. Разбор по шагам
в контейнере: импорт PyTorch 0.9 с, контекст CUDA 0.5 с, прогрев стадий 14.6 с,
из них первая раскладка по порядку точек 8.3 с, по углам 4.5 с. На Windows без
контейнера тот же прогрев — 0.9 с. Дело в первом запуске ядер CUDA под WSL:
драйвер складывает загруженные модули в `~/.nv/ComputeCache` (321 МБ), и во
втором процессе того же контейнера прогрев идёт 0.3 с. Компиляция PTX ни при
чём: с `CUDA_DISABLE_PTX_JIT=1` все ядра работают и время то же. Новый контейнер
начинает с пустым кэшем, поэтому 15 с платятся при каждом `docker run`.
Узел всё это время не был подписан на лидар. Запуск через launch с `bag:=`
ждал подписки и кадров не терял, но запись, пущенная снаружи сразу после старта
контейнера, теряла бы первые 15 с. Предмет в начале контрольной записи мог так
и не попасть в обработку.
Поэтому видеокарта теперь поднимается в своём потоке (`FlyGuard(...,
gpu_background=True)` в узле). Конвейер тем временем считает на процессоре и
забирает готовые стадии только в начале кадра: ламина и кластеризация берут
образ, который сетчатка оставила на видеокарте, и переход посреди кадра их
сломал бы. Замер на `doubleT_obstacle`, Docker в WSL:
| запуск | готова через | кадр, медиана / p95 | принято | итог |
|---|---|---|---|---|
| запись после прогрева | 16.0 с | 23.4 / 30.2 мс | 201 из 201 | 189 из 190 |
| запись сразу, как на стенде | 16.5 с | 31.0 / 36.6 мс (почти всё на процессоре) | 201 из 201 | 189 из 190 |
| без видеокарты | — | 31.8 / 34.9 мс | 201 из 201 | 189 из 190 |
Заодно закрыт случай, который `try/except` не ловил: если драйвер зависнет, узел
останется на процессоре, а не встанет. Тесты: прогрев, отпущенный на 20-м кадре,
дальше ведёт те же кандидаты и решения, что процессор; упавший прогрев оставляет
процессор и причину в `gpu_error`. `docker/demo_test.sh` для замера скорости
ждёт видеокарту (`WAIT_GPU=0` — не ждать, как на стенде).

View file

@ -10,6 +10,7 @@
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
@ -225,7 +226,8 @@ class Params:
# 'auto' — видеокарта, если PyTorch её видит, иначе процессор; 'cuda';
# 'cpu'. Результат одинаков: на видеокарте те же операции в тех же типах.
# Любой сбой видеокарты переводит конвейер на процессор до конца работы,
# кадр досчитывается там же. EXPERIMENTS п. 21.
# кадр досчитывается там же. Узел поднимает видеокарту в фоне
# (`gpu_background`), считая первые кадры на процессоре. EXPERIMENTS п. 21.
device: str = "auto"
enable_motion: bool = True
enable_memory: bool = True
@ -268,19 +270,26 @@ class FlyGuard:
memory: MushroomBody | None = None,
layout: ScanLayout | None = None,
readout: "MbonReadout | None" = None,
track_readout=None):
track_readout=None,
gpu_background: bool = False):
self.p = params or Params()
from .device import get_device
self.device = get_device(self.p.device)
self.device = "cpu"
self.gpu = None # плотные стадии на видеокарте (gpu.GpuStages)
self.gpu_error: str | None = None
if self.device.startswith("cuda"):
try:
from .gpu import GpuStages
self.gpu = GpuStages(self.device)
self.gpu.warmup()
except Exception as e: # нет драйвера, не та видеокарта, нет памяти
self._gpu_off(e)
self._gpu_ready = None # (стадии, устройство) — готовы, ждут начала кадра
# gpu_background: видеокарта поднимается в своём потоке, а кадры тем
# временем считает процессор. Первый запуск ядер CUDA бывает долгим —
# в Docker под WSL 15 с на каждый новый контейнер, — и узел всё это время
# не был подписан и терял кадры. Зависни драйвер совсем — узел так и
# останется на процессоре, а не встанет.
self.gpu_pending = str(self.p.device).strip().lower() != "cpu"
if self.gpu_pending:
if gpu_background:
threading.Thread(target=self._bring_up_gpu, name="flyguard-gpu",
daemon=True).start()
else:
self._bring_up_gpu()
self._take_gpu()
self.memory = memory if (memory is not None and self.p.enable_memory) else NoMemory()
self.readout = readout if (readout is not None and self.p.enable_mbon) else None
self.layout_full = layout
@ -345,6 +354,36 @@ class FlyGuard:
# ------------------------------------------------------------------ видеокарта
def _bring_up_gpu(self) -> None:
"""Проверить CUDA, прогреть стадии и отдать их конвейеру (`_take_gpu`)."""
try:
from .device import get_device
dev = get_device(self.p.device)
if dev.startswith("cuda"):
from .gpu import GpuStages
stages = GpuStages(dev)
stages.warmup()
self._gpu_ready = (stages, dev)
except Exception as e: # нет драйвера, не та видеокарта, нет памяти
self._gpu_off(e)
finally:
self.gpu_pending = False
def _take_gpu(self) -> None:
"""Перейти на видеокарту, если она готова. Только между кадрами.
Посреди кадра нельзя: ламина и кластеризация берут образ, который
сетчатка оставила на видеокарте.
"""
if self._gpu_ready is not None:
self.gpu, self.device = self._gpu_ready
self._gpu_ready = None
@property
def gpu_active(self) -> bool:
"""Плотные стадии на видеокарте — уже или с ближайшего кадра."""
return self.gpu is not None or self._gpu_ready is not None
def _gpu_off(self, exc: Exception) -> None:
"""Сбой видеокарты: до конца работы считать на процессоре."""
from .device import notify_cuda_error
@ -439,6 +478,7 @@ class FlyGuard:
self.time_jumps += 1
self._last_stamp = pc.stamp
self.frames_seen += 1
self._take_gpu()
with t("retina"):
img: RangeImage | None = None

View file

@ -979,3 +979,103 @@ def test_pipeline_survives_gpu_failure_mid_frame():
assert "device lost" in fg.gpu_error
finally:
reset_device_cache()
class _CpuStages:
"""Подмена видеокарты: те же стадии на процессоре, прогрев ждёт сигнала."""
gate = None
def __init__(self, device):
self.calls = 0
self._img = None
def warmup(self):
if self.gate is not None:
self.gate.wait()
def project(self, L, pc, cols):
self.calls += 1
self._img = L.project(pc, cols)
return self._img
def lamina(self):
from flyguard import lamina
return lamina.process(self._img.r_near, self._img.valid, device="cpu")
def cluster_by_depth(self, mask, r, **kw):
return cluster_by_depth(mask, r, **kw)
def _wait_gpu(fg, timeout=5.0):
import time
t_end = time.monotonic() + timeout
while fg.gpu_pending and time.monotonic() < t_end:
time.sleep(0.01)
assert not fg.gpu_pending, "поток видеокарты не закончил"
def test_gpu_comes_up_in_background_while_cpu_counts(monkeypatch):
"""Прогрев видеокарты идёт в фоне: кадры тем временем считает процессор,
а на видеокарту конвейер переходит только с начала кадра — с тем же итогом.
Зависший прогрев (здесь — до 20-го кадра) узел не держит."""
import threading
import flyguard.device as device
import flyguard.gpu as gpu
from flyguard.bag import Bag
from flyguard.pipeline import FlyGuard, Params
if not DATA.exists():
pytest.skip("нет записей")
made = []
class Stages(_CpuStages):
gate = threading.Event()
def __init__(self, dev):
super().__init__(dev)
made.append(self)
monkeypatch.setattr(device, "get_device", lambda preferred="auto": "cuda")
monkeypatch.setattr(gpu, "GpuStages", Stages)
frames = [pc for _, pc in Bag(next(p for p in DATA.iterdir() if p.is_dir())).frames(stop=30)]
ref = FlyGuard(Params(device="cpu"))
fg = FlyGuard(Params(device="auto"), gpu_background=True)
try:
for i, pc in enumerate(frames):
if i == 20:
assert fg.gpu_pending and fg.gpu is None and fg.device == "cpu"
Stages.gate.set()
_wait_gpu(fg)
assert fg.gpu_active and fg.gpu is None # готова, но ждёт начала кадра
a, b = ref.process(pc), fg.process(pc)
assert (a is None) == (b is None)
if a is not None:
assert a.decision.detected == b.decision.detected
assert [c.d for c in a.candidates] == [c.d for c in b.candidates]
assert fg.gpu is made[0] and fg.device == "cuda"
assert made[0].calls == len(frames) - 20
finally:
Stages.gate.set()
def test_gpu_failing_to_warm_up_leaves_cpu(monkeypatch):
"""Прогрев в фоне упал: конвейер остаётся на процессоре, причина — в gpu_error."""
import flyguard.device as device
import flyguard.gpu as gpu
from flyguard.device import reset_device_cache
from flyguard.pipeline import FlyGuard, Params
class Bad(_CpuStages):
def warmup(self):
raise RuntimeError("CUDA error: no kernel image is available (имитация)")
monkeypatch.setattr(device, "get_device", lambda preferred="auto": "cuda")
monkeypatch.setattr(gpu, "GpuStages", Bad)
try:
fg = FlyGuard(Params(device="auto"), gpu_background=True)
_wait_gpu(fg)
assert fg.gpu is None and not fg.gpu_active and fg.device == "cpu"
assert "no kernel image" in fg.gpu_error
finally:
reset_device_cache()