From e0999ad8707ee1b25b686517201e4bfdc2d4fb3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D0=B8=D0=BB=20=D0=9E=D0=BC=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D1=87=D0=BA=D0=BE?= Date: Sat, 26 Sep 2026 14:47:41 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2=20=D1=84=D0=BE=D0=BD?= =?UTF-8?q?=D0=B5,=20=D0=BF=D0=BE=D0=BA=D0=B0=20=D1=81=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D0=BF=D1=80=D0=BE=D1=86=D0=B5=D1=81=D1=81?= =?UTF-8?q?=D0=BE=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/EXPERIMENTS.md | 63 ++++++++++++++++++++++++++ flyguard/pipeline.py | 62 ++++++++++++++++++++----- tests/test_pipeline.py | 100 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6afe43f..94c4fb8 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ flyguard/ ядро: стадии обработки, память, сч export.py 3D-рамки, время до столкновения, маркеры RViz synth.py вставка предметов трассировкой лучей tools/ обучение, оценка, разбор, полигон с аугментациями -tests/ 50 тестов, запускаются без данных и без ROS +tests/ 52 теста, запускаются без данных и без ROS docs/ методика и результаты artifacts/ обученные модели ``` diff --git a/docs/EXPERIMENTS.md b/docs/EXPERIMENTS.md index 7dcf7ee..e3494b3 100644 --- a/docs/EXPERIMENTS.md +++ b/docs/EXPERIMENTS.md @@ -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` — не ждать, как на стенде). diff --git a/flyguard/pipeline.py b/flyguard/pipeline.py index d52c0d7..84fb2b6 100644 --- a/flyguard/pipeline.py +++ b/flyguard/pipeline.py @@ -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 diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 30799ec..9209159 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -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()