плотные стадии на видеокарте, откат на процессор

This commit is contained in:
Данил Омелечко 2026-09-26 04:10:04 +03:00
parent 872565abcf
commit 89ca5b4163
7 changed files with 763 additions and 18 deletions

View file

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

View file

@ -3223,3 +3223,98 @@ MBON, новизна; у каждого трека ход улики) на си
| контур габарита в кривой | прямой короб, изогнутой части не видно | оба контура | | контур габарита в кривой | прямой короб, изогнутой части не видно | оба контура |
| решения узла | — | без изменений (синтетика: те же находки, фантомы, дальности и число кадров) | | решения узла | — | без изменений (синтетика: те же находки, фантомы, дальности и число кадров) |
| тесты | 48 | 50 | | тесты | 48 | 50 |
## 21. Плотные стадии на видеокарте с откатом на процессор (26.09)
Решение пользователя: считать на видеокарте, если она есть, и только при её
отсутствии или сбое — на процессоре. Раньше (п. 7.4) вывод держался на
процессоре: на видеокарту была перенесена одна ламина, выигрыш 3 мс из 43.
Перенос одной стадии и правда ничего не даёт — выигрыш даёт перенос всех
плотных стадий разом, с данными, которые между стадиями не ездят.
### 21.1. Что переносить
Профиль кадра (cProfile, синтетика, 150 кадров; под профилировщиком всё
медленнее, важны доли): кластеризация с учётом глубины 20 мс, раскладка точек
по углам 14, ламина 8, оценка движения 8.5, ось пути 6.5, плоскость 3.9 из 74.
Без профилировщика кадр — 49 мс на синтетике и 36 мс на записи с поезда, из них
на раскладку, ламину и кластеризацию приходится 22–34 мс. Они работают с целым
образом 128 × 600 лучей — это и перенесено (`flyguard/gpu.py`). Плоскость, ось,
движение, треки и решение остались на процессоре: там мелкие массивы и
ветвистая логика, копирование на видеокарту дороже расчёта.
### 21.2. Те же операции в тех же типах
Цель — не «похожий», а тот же результат, иначе откат на процессор менял бы
решения посреди поездки.
* **Сетчатка** — выборки, умножения и корень в float32, отдельными ядрами
(без слияния умножения со сложением). На видеокарту уходит только окно сырых
столбцов, нужное сектору (5 МБ вместо 24).
* **Ламина.** `scipy.ndimage.uniform_filter` считает окно скользящим средним в
double. Суммы чисел float32 такого диапазона (диспаритет 0.003…20, окно до
121) в double точны при любом порядке сложения, поэтому окно берётся из
накопленной суммы, делится на размер и округляется в float32 после каждой
оси — как в scipy. Сверка окон: 0 расхождений на 24.5 млн значений (запись) и
20.6 млн (синтетика). Вся ламина записана в граф CUDA: видеокарта считает её
за 0.4 мс, а запуск сотни мелких операций из Python стоил 3 мс; с графом —
0.6 мс вместе с выгрузкой.
* **Кластеризация** — рёбра сразу для всех 17 соседей, компоненты
подвешиванием к меньшему номеру со сжатием путей: корень компоненты — её
наименьший луч, и нумерация совпадает со `scipy.sparse.csgraph`. На 42 тыс.
лучей и 581 тыс. рёбер — 8 раундов (медиана).
Исключение — раскладка по углам точек (кадры со сбитым порядком, как в
синтетике организаторов): арктангенс видеокарты и процессора расходится в
последнем знаке, а numpy сортирует ячейки неустойчиво.
### 21.3. Сверка
По стадиям, 80 кадров: на `roundT_doubleT` все три — бит в бит; на синтетике
ламина и кластеризация — бит в бит, раскладка по углам разошлась в 13 кадрах из
80, 326 значений из 300 тыс.
Конвейер целиком, синтетика и шесть записей, покадрово против процессорного
прогона той же конфигурации (`v_t33`): на всех шести записях совпали решения,
объекты, кандидаты и треки в каждом кадре; на синтетике решения, объекты и
треки — во всех 1499 кадрах, кандидаты разошлись в трёх (раскладка по углам),
на треки это не повлияло. Синтетика 9 из 10, те же дальности и число кадров.
### 21.4. Откат
Любое исключение видеокарты — нет драйвера, не хватило памяти, ошибка ядра —
ловит конвейер (`FlyGuard._gpu_off`): до конца работы он считает на
процессоре, а текущий кадр досчитывается там же. Тест
`test_pipeline_survives_gpu_failure_mid_frame` роняет видеокарту на 20-м кадре
и сверяет решения с чисто процессорным прогоном. Узел пишет в журнал, где идёт
счёт, и предупреждает, если видеокарта отказала на ходу; в диагностике — поле
`device`.
### 21.5. В контейнере
Образ с PyTorch 2.9.1 + CUDA 12.8, Docker в WSL, видеокарта через `/dev/dxg`
(на стенде — `--gpus all`). Узел, `ros2 bag play` в том же контейнере:
| запись | счёт | кадр, медиана / p95 | обнаружение |
|---|---|---|---|
| `doubleT_obstacle` | видеокарта | 23.5 / 31.3 мс | 189 из 190, 55.8 м |
| `doubleT_obstacle` | процессор | 32.3 / 36.2 мс | 189 из 190, 55.8 м |
| синтетика | видеокарта | 26.8 / 43.1 мс | 390 кадров с тревогой |
| синтетика | процессор | 42.3 / 47.0 мс | 398 кадров с тревогой |
Строка `doubleT_obstacle` с видеокартой — после правки прогрева. До неё кадров
с видеокартой приходило меньше (195 и 1502 против 201 и 1510, отсюда и 390
кадров тревоги на синтетике против 398): первый кадр шёл 1.0–1.7 с — при первом
вызове подгружаются ядра CUDA раскладки, и очередь подписки переполнялась.
Прогрев (`GpuStages.warmup`) теперь гоняет и раскладку, на маленькой выдуманной
решётке обоими путями: принято 201 из 201, худший кадр 38 мс.
### 21.6. Образ
PyTorch с библиотеками CUDA — 6.6 ГБ (сами библиотеки NVIDIA 4.3 ГБ, cuDNN и
cuBLAS из них почти половина); образ — 9.3 ГБ вместо 2.7. Нашим стадиям cuDNN,
cuBLAS и прочие не нужны, но PyTorch без них не загружается. Ставится одним
слоем без кэша pip. Колёса — из индекса PyTorch или, где сети нет, с HTTP-адреса
`TORCH_WHEELS` (`docker/fetch_wheels.py`, `docker/wheels/README.md`). Через
контекст сборки колёса не передаются: первая попытка так делала, и контекст в
4.1 ГБ плюс слой с колёсами плюс установка чуть не заняли весь диск C:.

500
flyguard/gpu.py Normal file
View file

@ -0,0 +1,500 @@
"""Видеокарта для плотных стадий конвейера: сетчатка, ламина, кластеризация.
Три стадии работают с целым образом 128 × 600 лучей и занимают больше
половины кадра: раскладка точек по решётке, ламина (центр минус окружение на
трёх масштабах) и кластеризация лучей с учётом глубины. Они и перенесены.
Остальное — плоскость пути, ось, оценка движения, треки, решение — остаётся
на процессоре: там мелкие массивы и ветвистая логика, и копирование на
видеокарту стоило бы дороже самого расчёта.
Каждая функция повторяет процессорную версию операция в операцию, в тех же
типах. Раскладка по порядку точек, ламина и кластеризация дают результат,
совпадающий с процессорным бит в бит (тесты и сверка на записях,
EXPERIMENTS п. 21):
* сетчатка — только выборки, умножения и корень, всё в float32 без слияния
операций;
* ламина — равномерные окна считаются через накопленные суммы в float64 и
округляются в float32 после каждой оси, ровно как `scipy.ndimage.
uniform_filter`: сумма чисел float32 такого диапазона в float64 точна, и
результат не зависит от порядка сложения;
* кластеризация — целочисленный граф; компоненты нумеруются по наименьшему
лучу, как у `scipy.sparse.csgraph.connected_components`.
Исключение — раскладка по углам точек (кадры с нарушенным порядком, как в
синтетике организаторов): арктангенс видеокарты и процессора может разойтись
в последнем знаке, а numpy сортирует ячейки неустойчиво. Расходятся считанные
лучи на кадр.
Любой сбой видеокарты — нет драйвера, не хватило памяти, ошибка ядра — ловит
конвейер: до конца работы он считает на процессоре (`device.
notify_cuda_error`), а кадр досчитывается там же, решение не теряется.
"""
from __future__ import annotations
import warnings
import numpy as np
from .lamina import SCALES, LaminaOutput
from .retina import DEG, ORDER_TOL_DEG, RangeImage, ScanLayout
class GpuStages:
"""Плотные стадии на видеокарте; держит таблицы решётки и образ кадра."""
def __init__(self, device: str = "cuda"):
import torch
self.torch = torch
self.dev = torch.device(device)
torch.zeros(1, device=self.dev) # поднять контекст сразу, а не на первом кадре
self._tab_key = None
self._tab: dict = {}
self._win: dict = {}
self._off: dict = {}
self._box: dict = {}
self._graphs = True # графы CUDA для ламины; при отказе — по одной операции
self._lam_key = None
# образ текущего кадра: ламина и кластеризация берут его отсюда, не копируя заново
self.r = None
self.valid = None
self._r_host = None
def warmup(self, shape: tuple[int, int] = (128, 600)) -> None:
"""Прогнать все три стадии на выдуманном кадре.
Первый запуск каждого ядра CUDA подгружает его код: без прогрева первый
кадр записи шёл 1–1.7 с, очередь подписки переполнялась, и терялись
первые 6–8 кадров (замерено в контейнере). Раскладка прогревается на
маленькой решётке обоими путями — по порядку точек и по углам.
"""
from .cdr import PointCloud2
t = self.torch
rng = np.random.default_rng(0)
n, w, e = 8, 64, 2
lay = ScanLayout(np.linspace(10.0, -20.0, n), -0.1, 3.2, np.zeros(n, np.int64),
np.zeros(n), w, e)
xyz = lay.dirs.transpose(1, 0, 2)[:, None, :, :] * np.float32(20.0) # столбец·эхо·кольцо
xyz = np.broadcast_to(xyz, (w, e, n, 3)).reshape(-1, 3)
pts = np.zeros(xyz.shape[0], dtype=[("x", "<f4"), ("y", "<f4"), ("z", "<f4"),
("intensity", "<f4")])
pts["x"], pts["y"], pts["z"] = xyz[:, 0], xyz[:, 1], xyz[:, 2]
pts["intensity"] = 10.0
cloud = PointCloud2(0.0, "", 1, pts.size, pts.dtype.itemsize, True, pts)
self.project(lay, cloud, slice(8, w - 8))
mixed = PointCloud2(0.0, "", 1, pts.size, pts.dtype.itemsize, True,
pts[rng.permutation(pts.size)])
self.project(lay, mixed, slice(8, w - 8))
r = (4.0 + 150.0 * rng.random(shape)).astype(np.float32)
self.r = t.as_tensor(r, device=self.dev)
self.valid = t.ones(shape, dtype=t.bool, device=self.dev)
self._r_host = r
self.lamina()
self.cluster_by_depth(rng.random(shape) < 0.3, r)
self.r = self.valid = self._r_host = None
t.cuda.synchronize(self.dev)
# ------------------------------------------------------------------ таблицы
def _tables(self, L: ScanLayout) -> dict:
key = (id(L), L.n_points, L.n_az)
if key != self._tab_key:
t, d = self.torch, self.dev
def up(a, dtype):
return t.as_tensor(np.ascontiguousarray(a), dtype=dtype, device=d)
self._tab = {
"sin_el": up(L._sin_el, t.float32),
"el_asc": up(L._el_asc, t.float64),
"el_order": up(L._el_order, t.int64),
"el_deg": up(L.el_deg, t.float64),
"el_step": up(L.el_step_deg, t.float64),
"resid": up(L.az_resid_deg, t.float64),
}
self._win = {}
self._tab_key = key
return self._tab
def _window(self, L: ScanLayout, start: int, stop: int) -> tuple:
"""Сырые столбцы, нужные сектору, и карта выпрямления внутри них."""
key = (start, stop)
if key not in self._win:
w = L.n_az
lo = start + int(L.col_shift.min())
hi = stop + int(L.col_shift.max())
g = L.gather[:, start:stop]
ok = L.gather_ok[:, start:stop]
if L.wrap:
raw = np.arange(lo, hi) % w
g = (g - lo) % w
else:
lo, hi = max(lo, 0), min(hi, w)
raw = np.arange(lo, hi)
g = g - lo
ok = ok & (g >= 0) & (g < (hi - lo))
g = np.clip(g, 0, hi - lo - 1)
# столбцы окна идут подряд, кроме стыка кругового скана
cut = np.flatnonzero(np.diff(raw) != 1) + 1
runs = [(int(a[0]), int(a[-1]) + 1) for a in np.split(raw, cut) if a.size]
t = self.torch
self._win[key] = (runs, t.as_tensor(g, dtype=t.int64, device=self.dev),
t.as_tensor(ok, device=self.dev))
return self._win[key]
# ------------------------------------------------------------------ загрузка точек
def _upload(self, pts: np.ndarray, rows: list[tuple[int, int]]):
"""Строки структурированного массива → байты на видеокарте, (N, шаг точки)."""
t = self.torch
step = pts.dtype.itemsize
parts = []
for a, b in rows:
raw = np.ascontiguousarray(pts[a:b]).view(np.uint8)
with warnings.catch_warnings():
# буфер сообщения только для чтения, а тензор мы не пишем
warnings.simplefilter("ignore", UserWarning)
parts.append(t.from_numpy(raw).to(self.dev, non_blocking=False))
buf = parts[0] if len(parts) == 1 else t.cat(parts)
return buf.view(-1, step)
def _field(self, buf, pts: np.ndarray, name: str):
dt, off = pts.dtype.fields[name][:2]
if dt.kind != "f" or dt.itemsize != 4 or dt.byteorder == ">":
raise TypeError(f"поле {name}: ожидался float32, а не {dt}")
return buf[:, off:off + 4].contiguous().view(self.torch.float32).reshape(-1)
# ------------------------------------------------------------------ сетчатка
def project(self, L: ScanLayout, pc, cols: slice | None) -> RangeImage:
"""То же, что `ScanLayout.project`, на видеокарте."""
w = L.n_az
start = 0 if cols is None else (cols.start or 0)
stop = w if cols is None else (cols.stop if cols.stop is not None else w)
self._tables(L)
img = None
if L.indexed and pc.n_points == L.n_points:
img = self._project_indexed(L, pc, start, stop)
if img is None:
L.n_geometric += 1
img = self._project_geometric(L, pc, start, stop)
self._r_host = img.r_near
return img
def _project_indexed(self, L: ScanLayout, pc, start: int, stop: int):
t = self.torch
tab = self._tab
n, e = L.n_rings, L.n_echo
runs, g, ok = self._window(L, start, stop)
pts = pc.points
per_col = e * n
buf = self._upload(pts, [(a * per_col, b * per_col) for a, b in runs])
ncol = sum(b - a for a, b in runs)
def cube(name: str):
return self._field(buf, pts, name).reshape(ncol, e, n).permute(2, 0, 1)
x, y, z = cube("x"), cube("y"), cube("z")
good = (x != 0) | (y != 0) | (z != 0)
r = t.sqrt(x * x + y * y + z * z)
good &= t.isfinite(r)
r = t.where(good, r, t.zeros((), dtype=r.dtype, device=self.dev))
dev = t.abs(z - r * tab["sin_el"][:, None, None])
tol = r * np.float32(ORDER_TOL_DEG * DEG) + np.float32(1e-3)
if bool(t.any(dev > tol)):
return None
if e == 1:
r_near = r[..., 0]
r_far = r[..., 0]
it = cube("intensity")[..., 0]
valid = good[..., 0]
else:
inten = cube("intensity")
inf = t.full((), float("inf"), dtype=r.dtype, device=self.dev)
near_i = t.argmin(t.where(good, r, inf), dim=-1, keepdim=True)
far_i = t.argmax(r, dim=-1, keepdim=True)
r_near = t.gather(r, -1, near_i)[..., 0]
r_far = t.gather(r, -1, far_i)[..., 0]
it = t.gather(inten, -1, near_i)[..., 0]
valid = good.any(dim=-1)
r_near = t.gather(r_near, 1, g)
r_far = t.gather(r_far, 1, g)
it = t.gather(it, 1, g)
valid = t.gather(valid, 1, g) & ok
zero = t.zeros((), dtype=r_near.dtype, device=self.dev)
r_near = t.where(valid, r_near, zero)
r_far = t.where(valid, r_far, zero)
return self._finish(pc.stamp, r_near, r_far, it, valid)
def _project_geometric(self, L: ScanLayout, pc, start: int, stop: int):
t = self.torch
tab = self._tab
n, wid = L.n_rings, stop - start
pts = pc.points
buf = self._upload(pts, [(0, pts.shape[0])])
x, y, z = (self._field(buf, pts, k) for k in ("x", "y", "z"))
good = (((x != 0) | (y != 0) | (z != 0)) & t.isfinite(x) & t.isfinite(y)
& t.isfinite(z))
idx = t.nonzero(good, as_tuple=True)[0]
x, y, z = x[idx], y[idx], z[idx]
step = L.az_step_deg
az = t.rad2deg(t.atan2(x, -y))
jf = (az - np.float32(L.az0_deg)) / np.float32(step)
if L.wrap:
jf = t.remainder(jf, L.n_az)
margin = float(np.abs(L.az_resid_deg).max()) / abs(step) + 1.0
sel = t.nonzero((jf > start - margin) & (jf < stop - 1 + margin), as_tuple=True)[0]
idx, x, y, z, az = idx[sel], x[sel], y[sel], z[sel], az[sel]
r = t.sqrt(x * x + y * y + z * z)
el = t.rad2deg(t.asin(t.clamp(z / t.clamp_min(r, np.float32(1e-6)), -1.0, 1.0)))
el64 = el.double()
asc = tab["el_asc"]
k = t.clamp(t.searchsorted(asc, el64), 1, n - 1)
k = k - ((el64 - asc[k - 1]) < (asc[k] - el64)).long()
h = tab["el_order"][k]
ok = t.abs(el64 - tab["el_deg"][h]) <= t.clamp_min(tab["el_step"][h], 0.2)
j = t.round(((az.double() - tab["resid"][h]) - L.az0_deg) / step).long()
if L.wrap:
j = t.remainder(j, L.n_az)
ok &= (j >= start) & (j < stop)
sel = t.nonzero(ok, as_tuple=True)[0]
r_near = t.zeros(n * wid, dtype=t.float32, device=self.dev)
r_far = t.zeros_like(r_near)
it = t.zeros_like(r_near)
valid = t.zeros(n * wid, dtype=t.bool, device=self.dev)
if sel.numel():
cell = h[sel] * wid + (j[sel] - start)
rr = r[sel]
mm = t.clamp_max(rr * 1000.0, float((1 << 20) - 1)).long()
order = t.argsort(cell * (1 << 20) + mm, stable=True)
cs, rs = cell[order], rr[order]
new = t.ones(cs.numel(), dtype=t.bool, device=self.dev)
new[1:] = cs[1:] != cs[:-1]
first = t.nonzero(new, as_tuple=True)[0]
last = t.cat([first[1:] - 1, first.new_tensor([cs.numel() - 1])])
r_near[cs[first]] = rs[first]
r_far[cs[last]] = rs[last]
valid[cs[first]] = True
if "intensity" in pts.dtype.names:
src = self._field(buf, pts, "intensity")[idx[sel]][order]
it[cs[first]] = src[first]
return self._finish(pc.stamp, r_near.reshape(n, wid), r_far.reshape(n, wid),
it.reshape(n, wid), valid.reshape(n, wid))
def _finish(self, stamp: float, r_near, r_far, it, valid) -> RangeImage:
self.r, self.valid = r_near, valid
host = [a.cpu().numpy() for a in (r_near, r_far, it, valid)]
return RangeImage(stamp, *host)
# ------------------------------------------------------------------ ламина
def _box_axis(self, X, sizes: list[int], dim: int):
"""Равномерное окно по одной оси для пачки размеров сразу.
`X` — (K, B, H, W) float32, k-й срез фильтруется окном `sizes[k]`
вдоль `dim` (2 или 3) с продолжением краевым значением. Суммы окон
берутся из накопленной суммы в float64: все частичные суммы чисел
float32 такого диапазона в float64 точны, поэтому сумма окна точна при
любом порядке сложения. Деление на размер и округление в float32 —
как в `scipy.ndimage.uniform_filter1d`.
"""
t = self.torch
Xd = X.double()
n = Xd.shape[dim]
lo, hi, n_lo, n_hi, size = self._box_tables(tuple(sizes), n, dim)
cs = t.cumsum(Xd, dim=dim)
cs = t.cat([t.zeros_like(cs.narrow(dim, 0, 1)), cs], dim=dim)
def along(a):
return a.expand(*Xd.shape[:dim], n, *Xd.shape[dim + 1:])
s = (t.gather(cs, dim, along(hi)) - t.gather(cs, dim, along(lo))
+ along(n_lo) * Xd.narrow(dim, 0, 1) + along(n_hi) * Xd.narrow(dim, n - 1, 1))
return (s / size).float()
def _box_tables(self, sizes: tuple, n: int, dim: int):
"""Границы окон по оси: считаются один раз (и до записи графа CUDA)."""
key = (sizes, n, dim)
if key not in self._box:
t = self.torch
p = t.as_tensor([s // 2 for s in sizes], dtype=t.int64, device=self.dev)
i = t.arange(n, device=self.dev)
shape = [len(sizes), 1, 1, 1]
shape[dim] = n
lo = (i[None, :] - p[:, None]).clamp(min=0).reshape(shape)
hi = ((i[None, :] + p[:, None]).clamp(max=n - 1) + 1).reshape(shape)
n_lo = (p[:, None] - i[None, :]).clamp(min=0).double().reshape(shape)
n_hi = (i[None, :] + p[:, None] - (n - 1)).clamp(min=0).double().reshape(shape)
size = t.as_tensor(sizes, dtype=t.float64, device=self.dev).reshape(-1, 1, 1, 1)
self._box[key] = (lo, hi, n_lo, n_hi, size)
return self._box[key]
def _boxes(self, X, sizes: list[tuple[int, int]]):
"""`scipy.ndimage.uniform_filter(x, size, mode="nearest")` для пачки.
`X` — (B, H, W) float32; выход (len(sizes), B, H, W): по осям по
очереди, с округлением в float32 после каждой, как в scipy.
"""
K = len(sizes)
Y = self._box_axis(X[None].expand(K, *X.shape), [s[0] for s in sizes], 2)
return self._box_axis(Y, [s[1] for s in sizes], 3)
def lamina(self, r_max: float = 300.0) -> LaminaOutput:
"""То же, что `lamina._process_cpu`, по образу текущего кадра.
Сама видеокарта считает ламину за доли миллисекунды, а запуск сотни
мелких операций из Python стоит в несколько раз дороже. Поэтому ламина
записывается в граф CUDA один раз на размер образа и дальше
запускается одной командой. Не поддерживает граф драйвер — считаем
теми же операциями по одной.
"""
t = self.torch
key = (tuple(self.r.shape), float(r_max))
out = None
if self._graphs:
try:
if self._lam_key != key:
self._capture_lamina(key, r_max)
self._lam_in[0].copy_(self.r)
self._lam_in[1].copy_(self.valid)
self._lam_graph.replay()
out = self._lam_out
except Exception:
self._graphs = False
self._lam_key = None
if out is None:
out = self._lamina_core(self.r, self.valid, r_max)
f = out[0].cpu().numpy()
return LaminaOutput(disp=f[0], on=f[1], off=f[2], on_scale=out[1].cpu().numpy(),
surround=f[3], hole=f[4])
def _capture_lamina(self, key: tuple, r_max: float) -> None:
t = self.torch
self._lam_in = (t.empty_like(self.r), t.empty_like(self.valid))
self._lam_in[0].copy_(self.r)
self._lam_in[1].copy_(self.valid)
side = t.cuda.Stream(self.dev)
side.wait_stream(t.cuda.current_stream(self.dev))
with t.cuda.stream(side):
for _ in range(2): # прогрев: таблицы окон и кэш памяти
self._lamina_core(*self._lam_in, r_max)
t.cuda.current_stream(self.dev).wait_stream(side)
g = t.cuda.CUDAGraph()
with t.cuda.graph(g):
self._lam_out = self._lamina_core(*self._lam_in, r_max)
self._lam_graph, self._lam_key = g, key
def _lamina_core(self, r, valid, r_max: float):
"""Ламина целиком на видеокарте: (disp, on, off, surround, hole) и on_scale."""
t = self.torch
v = valid.float()
zero = t.zeros((), dtype=t.float32, device=self.dev)
disp = t.where(valid & (r > 0.05), 1.0 / r, zero) * v
# все окна кадра одной пачкой: на каждом масштабе центр и окружение,
# для диспаритета и для маски эха, плюс окно «дыры»
sizes = []
for r_in, r_out in SCALES:
sizes += [(2 * r_in + 1, 2 * r_in + 1), (2 * r_out + 1, 4 * r_out + 1)]
sizes.append((5, 15))
box = self._boxes(t.stack([disp, v]), sizes)
on = t.zeros_like(disp)
off = t.zeros_like(disp)
on_scale = t.zeros(disp.shape, dtype=t.int8, device=self.dev)
surround_mid = None
for k, (r_in, r_out) in enumerate(SCALES):
s_in, s_out = sizes[2 * k], sizes[2 * k + 1]
n_in = s_in[0] * s_in[1]
n_out = s_out[0] * s_out[1]
num = box[2 * k + 1, 0] * n_out - box[2 * k, 0] * n_in
den = box[2 * k + 1, 1] * n_out - box[2 * k, 1] * n_in
sur = t.where(den > 0.5, num / den, zero)
enough = den > 8.0
c = t.where(enough, disp - sur, zero)
pos = t.clamp_min(c, 0.0) * v
neg = t.clamp_min(-(disp - sur), 0.0) * enough
better = pos > on
on = t.where(better, pos, on)
on_scale = t.where(better, t.full((), k, dtype=t.int8, device=self.dev), on_scale)
off = t.maximum(off, neg)
if k == 1:
surround_mid = sur
hole = 1.0 - box[-1, 1]
on = t.clamp(on, 0.0, 1.0 / max(r_max, 1.0) * 1e4)
return t.stack([disp, on, off, surround_mid, hole]), on_scale
# ------------------------------------------------------------------ кластеризация
def _offsets(self, col_reach: int, row_reach: int):
key = (col_reach, row_reach)
if key not in self._off:
pairs = [(dr, dc) for dr in range(0, row_reach + 1)
for dc in range(-col_reach, col_reach + 1) if not (dr == 0 and dc <= 0)]
p = self.torch.tensor(pairs, dtype=self.torch.int64, device=self.dev)
self._off[key] = (p[:, 0:1], p[:, 1:2])
return self._off[key]
def cluster_by_depth(self, mask: np.ndarray, r: np.ndarray, *,
rel_tol: float = 0.06, abs_tol: float = 0.35,
col_reach: int = 3, row_reach: int = 2):
"""То же, что `lobula.cluster_by_depth`, с теми же номерами компонент.
Рёбра строятся сразу для всех соседей, компоненты — подвешиванием к
меньшему номеру со сжатием путей: у каждой компоненты корнем остаётся
её наименьший луч, и нумерация по корням совпадает со scipy.
"""
t = self.torch
h, w = mask.shape
if r is self._r_host and self.r is not None:
rt = self.r.reshape(-1)
else:
rt = t.as_tensor(np.ascontiguousarray(r), dtype=t.float32,
device=self.dev).reshape(-1)
m = t.as_tensor(np.ascontiguousarray(mask), device=self.dev).reshape(-1)
idx = t.nonzero(m, as_tuple=True)[0]
n = idx.numel()
if n == 0:
return np.zeros(mask.shape, np.int32), 0
lut = t.full((h * w,), -1, dtype=t.int64, device=self.dev)
lut[idx] = t.arange(n, device=self.dev)
rows = t.div(idx, w, rounding_mode="floor")
cols = idx - rows * w
ri = rt[idx]
dr, dc = self._offsets(col_reach, row_reach)
nr = rows[None, :] + dr
nc = cols[None, :] + dc
ok = (nr < h) & (nc >= 0) & (nc < w)
nb = lut[t.where(ok, nr * w + nc, t.zeros_like(nr))]
ok &= nb >= 0
rb = ri[nb.clamp(min=0)]
ra = ri[None, :].expand_as(rb)
ok &= t.abs(ra - rb) <= (abs_tol + rel_tol * t.minimum(ra, rb))
s = t.arange(n, device=self.dev)[None, :].expand_as(nb)[ok]
d = nb[ok]
parent = t.arange(n, device=self.dev)
while True:
for _ in range(4):
ps, pd = parent[s], parent[d]
parent.scatter_reduce_(0, t.maximum(ps, pd), t.minimum(ps, pd), reduce="amin")
parent = parent[parent]
parent = parent[parent]
if not bool((parent[s] != parent[d]).any()):
break
while True:
nxt = parent[parent]
if bool(t.equal(nxt, parent)):
break
parent = nxt
roots, comp = t.unique(parent, return_inverse=True)
labels = t.zeros(h * w, dtype=t.int32, device=self.dev)
labels[idx] = (comp + 1).to(t.int32)
return labels.reshape(h, w).cpu().numpy(), int(roots.numel())

View file

@ -292,7 +292,7 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
platform_h_hi: float = 1.25, platform_h_hi: float = 1.25,
h_top: float = 0.0, half_width_top: float = 0.0, h_top: float = 0.0, half_width_top: float = 0.0,
top_d_max: float = 90.0, top_detect: bool = False, top_d_max: float = 90.0, top_detect: bool = False,
top_min_rays: int = 3) -> list[Candidate]: top_min_rays: int = 3, cluster=None) -> list[Candidate]:
"""Выделить кандидатов: связные пятна лучей, попавших в габарит. """Выделить кандидатов: связные пятна лучей, попавших в габарит.
Кластеризация ведётся по **расширенной** области, а членство в габарите Кластеризация ведётся по **расширенной** области, а членство в габарите
@ -337,7 +337,9 @@ def find_candidates(tf: TrackFrame, lam: LaminaOutput, corridor: Corridor, *,
gap_img = contrast_to_depth_gap(lam.on, tf.r) gap_img = contrast_to_depth_gap(lam.on, tf.r)
labels, n = cluster_by_depth(context, tf.r, col_reach=dilate_cols + 1) # `cluster` — та же кластеризация на видеокарте (FlyGuard._cluster), с тем
# же результатом; здесь самая большая маска кадра, и выигрыш в ней
labels, n = (cluster or cluster_by_depth)(context, tf.r, col_reach=dilate_cols + 1)
if n == 0: if n == 0:
return [] return []
if split_gap > 0: if split_gap > 0:

View file

@ -22,7 +22,7 @@ from .descending import Decision, DescendingNeurons
from .geometry import (STRAIGHT, Corridor, RailPlane, TrackFrame, fit_corridor, from .geometry import (STRAIGHT, Corridor, RailPlane, TrackFrame, fit_corridor,
fit_rail_plane) fit_rail_plane)
from .fan_body import FanBody from .fan_body import FanBody
from .lobula import Candidate, find_candidates, gauge_mask from .lobula import Candidate, cluster_by_depth, find_candidates, gauge_mask
from .medulla import EgoMotion, EgoMotionEstimator, EmdBank, looming from .medulla import EgoMotion, EgoMotionEstimator, EmdBank, looming
from .mbon_readout import MbonReadout from .mbon_readout import MbonReadout
from .mushroom_body import Habituation, HabituationConfig, MushroomBody, NoMemory from .mushroom_body import Habituation, HabituationConfig, MushroomBody, NoMemory
@ -221,11 +221,12 @@ class Params:
# сбрасывать никогда. # сбрасывать никогда.
reset_gap_s: float = 2.0 reset_gap_s: float = 2.0
novelty_gate: float = 0.0 # ниже этой новизны кандидат отбрасывается сразу novelty_gate: float = 0.0 # ниже этой новизны кандидат отбрасывается сразу
# Где считать ламину: 'cpu', 'cuda' или 'auto' (видеокарта, если есть). # Где считать плотные стадии — сетчатку, ламину, кластеризацию (gpu.py):
# По умолчанию процессор, и это решение, а не осторожность: GPU ускоряет # 'auto' — видеокарта, если PyTorch её видит, иначе процессор; 'cuda';
# только ламину (5.8 → 2.9 мс из 43), а на машине проверки требует # 'cpu'. Результат одинаков: на видеокарте те же операции в тех же типах.
# другого контейнера и запуска с --gpus all. EXPERIMENTS п. 7.4. # Любой сбой видеокарты переводит конвейер на процессор до конца работы,
device: str = "cpu" # кадр досчитывается там же. EXPERIMENTS п. 21.
device: str = "auto"
enable_motion: bool = True enable_motion: bool = True
enable_memory: bool = True enable_memory: bool = True
enable_looming: bool = False # T4/T5 + LPLC2: нужны для оценки надвигания и для enable_looming: bool = False # T4/T5 + LPLC2: нужны для оценки надвигания и для
@ -271,6 +272,15 @@ class FlyGuard:
self.p = params or Params() self.p = params or Params()
from .device import get_device from .device import get_device
self.device = get_device(self.p.device) self.device = get_device(self.p.device)
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.memory = memory if (memory is not None and self.p.enable_memory) else NoMemory() 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.readout = readout if (readout is not None and self.p.enable_mbon) else None
self.layout_full = layout self.layout_full = layout
@ -333,6 +343,25 @@ class FlyGuard:
else DescendingNeurons(warn_evidence=0.0, clear_evidence=0.0, else DescendingNeurons(warn_evidence=0.0, clear_evidence=0.0,
emergency_evidence=0.0, min_hits=1)) emergency_evidence=0.0, min_hits=1))
# ------------------------------------------------------------------ видеокарта
def _gpu_off(self, exc: Exception) -> None:
"""Сбой видеокарты: до конца работы считать на процессоре."""
from .device import notify_cuda_error
notify_cuda_error(exc)
self.gpu = None
self.device = "cpu"
self.gpu_error = f"{type(exc).__name__}: {exc}"
def _cluster(self, mask: np.ndarray, r: np.ndarray, **kw):
"""Кластеризация с учётом глубины: видеокарта, при сбое — процессор."""
if self.gpu is not None:
try:
return self.gpu.cluster_by_depth(mask, r, **kw)
except Exception as e:
self._gpu_off(e)
return cluster_by_depth(mask, r, **kw)
# ------------------------------------------------------------------ калибровка # ------------------------------------------------------------------ калибровка
def _ensure_layout(self, pc: PointCloud2) -> bool: def _ensure_layout(self, pc: PointCloud2) -> bool:
@ -412,7 +441,14 @@ class FlyGuard:
self.frames_seen += 1 self.frames_seen += 1
with t("retina"): with t("retina"):
img: RangeImage = self.layout_full.project(pc, self.cols) img: RangeImage | None = None
if self.gpu is not None:
try:
img = self.gpu.project(self.layout_full, pc, self.cols)
except Exception as e:
self._gpu_off(e)
if img is None:
img = self.layout_full.project(pc, self.cols)
with t("stabilize"): with t("stabilize"):
self.plane = fit_rail_plane(img, self.layout, prev=self.plane) self.plane = fit_rail_plane(img, self.layout, prev=self.plane)
@ -423,12 +459,14 @@ class FlyGuard:
else STRAIGHT) else STRAIGHT)
with t("lamina"): with t("lamina"):
dev = self.device lam = None
if dev.startswith("cuda"): if self.gpu is not None:
from .device import is_cuda_available try:
if not is_cuda_available(): lam = self.gpu.lamina() # по образу кадра, уже лежащему на видеокарте
self.device = dev = "cpu" except Exception as e:
lam = lamina.process(tf.r, tf.valid, device=dev) self._gpu_off(e)
if lam is None:
lam = lamina.process(tf.r, tf.valid, device="cpu")
with t("ego"): with t("ego"):
ego = (self.ego_est.update(tf, pc.stamp) if self.p.enable_motion ego = (self.ego_est.update(tf, pc.stamp) if self.p.enable_motion
@ -475,7 +513,8 @@ class FlyGuard:
h_top=self.p.h_top, h_top=self.p.h_top,
half_width_top=self.p.half_width_top, half_width_top=self.p.half_width_top,
top_d_max=self.p.top_d_max, top_d_max=self.p.top_d_max,
top_detect=self.p.top_detect) top_detect=self.p.top_detect,
cluster=self._cluster)
with t("mushroom"): with t("mushroom"):
cands = self.memory.annotate(cands) cands = self.memory.annotate(cands)

View file

@ -4,6 +4,8 @@ scipy>=1.10
lightgbm>=4.0 lightgbm>=4.0
# графики полигона: tools/plot_benchmark.py # графики полигона: tools/plot_benchmark.py
matplotlib>=3.7 matplotlib>=3.7
# только для обучения на GPU (tools/train_mbon.py --device cuda); ядру не нужен # видеокарта: плотные стадии конвейера (flyguard/gpu.py) и обучение
# (tools/train_mbon.py --device cuda). Без него ядро считает на процессоре —
# с тем же результатом
# torch>=2.0 # torch>=2.0
pytest>=7.0 pytest>=7.0

View file

@ -872,3 +872,110 @@ def test_gauge_outline_bends_with_the_track():
assert c100 == pytest.approx(-100.0 ** 2 / (2 * R), rel=1e-3) assert c100 == pytest.approx(-100.0 ** 2 / (2 * R), rel=1e-3)
assert at100[:, 0].min() == pytest.approx(c100 - 1.2, abs=1e-4) assert at100[:, 0].min() == pytest.approx(c100 - 1.2, abs=1e-4)
assert at100[:, 0].max() == pytest.approx(c100 + 1.2, abs=1e-4) assert at100[:, 0].max() == pytest.approx(c100 + 1.2, abs=1e-4)
# ------------------------------------------------------------ видеокарта
def _cuda_or_skip():
torch = pytest.importorskip("torch")
if not torch.cuda.is_available():
pytest.skip("нет видеокарты с CUDA")
def _scene(seed=0, shape=(128, 600)):
"""Образ, похожий на тоннель: гладкие стены, ступеньки-предметы, пропуски эха."""
rng = np.random.default_rng(seed)
h, w = shape
r = (np.linspace(3.0, 180.0, w)[None, :] * (1.0 + 0.2 * np.sin(np.arange(h)[:, None] / 9.0))
).astype(np.float32)
for _ in range(40):
i, j = rng.integers(0, h - 8), rng.integers(0, w - 12)
r[i:i + rng.integers(2, 8), j:j + rng.integers(2, 12)] *= np.float32(rng.uniform(0.3, 0.9))
r += rng.normal(0.0, 0.02, r.shape).astype(np.float32)
valid = rng.random(shape) > 0.1
return np.where(valid, r, np.float32(0.0)), valid
def test_gpu_lamina_and_clustering_match_cpu_bit_for_bit():
"""Ламина и кластеризация на видеокарте — те же числа и те же номера компонент."""
_cuda_or_skip()
import torch
from flyguard import lamina
from flyguard.gpu import GpuStages
from flyguard.lobula import cluster_by_depth
g = GpuStages("cuda")
for seed in range(3):
r, valid = _scene(seed)
g.r = torch.as_tensor(r, device="cuda")
g.valid = torch.as_tensor(valid, device="cuda")
a, b = lamina._process_cpu(r, valid), g.lamina()
for f in ("disp", "on", "off", "on_scale", "surround", "hole"):
assert np.array_equal(getattr(a, f), getattr(b, f)), f
mask = valid & (np.random.default_rng(seed).random(r.shape) < 0.5)
la, na = cluster_by_depth(mask, r)
lb, nb = g.cluster_by_depth(mask, r)
assert na == nb and np.array_equal(la, lb)
def test_gpu_projection_matches_cpu():
"""Сетчатка на видеокарте: целый кадр — бит в бит, перемешанный — почти."""
_cuda_or_skip()
from flyguard.bag import Bag
from flyguard.cdr import PointCloud2
from flyguard.gpu import GpuStages
if not DATA.exists():
pytest.skip("нет записей")
bag = Bag(next(p for p in DATA.iterdir() if p.is_dir()))
clouds = [pc for _, pc in bag.frames(start=2, stop=16)]
lay = ScanLayout.calibrate(clouds[:12])
cols = lay.column_slice(30.0)
g = GpuStages("cuda")
pc = clouds[-1]
a, b = lay.project(pc, cols), g.project(lay, pc, cols)
for f in ("r_near", "r_far", "inten", "valid"):
assert np.array_equal(getattr(a, f), getattr(b, f)), f
# порядок точек сбит, как в синтетике организаторов: раскладка по углам
rng = np.random.default_rng(0)
mixed = PointCloud2(pc.stamp, pc.frame_id, 1, pc.n_points, pc.point_step,
pc.is_dense, pc.points[rng.permutation(pc.n_points)])
a, b = lay.project(mixed, cols), g.project(lay, mixed, cols)
assert a.valid.sum() > 1000
differ = sum(int((getattr(a, f) != getattr(b, f)).sum())
for f in ("r_near", "r_far", "inten", "valid"))
assert differ < 1e-3 * a.valid.size # последний знак арктангенса и порядок равных
def test_pipeline_survives_gpu_failure_mid_frame():
"""Отказ видеокарты посреди кадра: кадр досчитан на процессоре, решение то же."""
from flyguard.bag import Bag
from flyguard.device import reset_device_cache
from flyguard.pipeline import FlyGuard, Params
if not DATA.exists():
pytest.skip("нет записей")
class Broken:
def project(self, *a, **k):
raise RuntimeError("CUDA error: device lost (имитация)")
lamina = cluster_by_depth = project
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="cpu"))
try:
for i, pc in enumerate(frames):
if i == 20: # видеокарта «была» и отказала
fg.gpu, fg.device = Broken(), "cuda"
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 None and fg.device == "cpu"
assert "device lost" in fg.gpu_error
finally:
reset_device_cache()