Графы CUDA

Граф CUDA записывает одно выполнение фиксированной последовательности ядер и воспроизводит его одним запуском. LibreYOLO захватывает инференс на 39 проверенных семействах и обучение на 24 — всегда по семействам, всегда после побитовой проверки совпадения и никогда как тихий откат.

Семейства для инференса
39
Семейства для обучения
24
Флаг для инференса
predict(cuda_graph=True)
Флаг для обучения
train(cuda_graph=True)

Что захватывается

Граф записывает фиксированную последовательность ядер и адреса памяти, которые они читают и пишут. Значения, формы и поток управления он не записывает. Воспроизведение — это один запуск вместо сотен, поэтому выигрыш максимален на маленьких сетях при малых размерах батча, где шаг определяется накладными расходами на запуск ядер, а не арифметикой.

Две точки входа захватывают разный объём работы.

В графеEager
Инференсforward сети, model._forward(x)Предобработка, NMS, вся постобработка
Обучениеforward и backward сетиФункция потерь, шаг оптимизатора, обрезка градиентов, EMA, расписание скорости обучения

Ни NMS, ни функция потерь детекции в кандидаты не входят. Обе выбирают элементы по булевым маскам, запускают венгерское сопоставление или назначение меток и ветвятся по результату — а это ровно то, что граф записать не может. Именно вынесение их наружу делает захват безопасным, а не ограничением, которое приходится обходить.

Предсказание
from libreyolo import LibreYOLO, SAMPLE_IMAGE model = LibreYOLO("LibreYOLO9t.pt") # True захватывает граф при первом использовании каждой формы входа.# "auto" ждёт повторения формы, прежде чем платить за захват.result = model(SAMPLE_IMAGE, cuda_graph=True)
Обучение
from libreyolo import LibreYOLO model = LibreYOLO("LibreYOLO9t.pt")model.train(data="my-dataset.yaml", epochs=100, cuda_graph=True)
Обучение из CLI
libreyolo train model=LibreYOLO9t.pt data=my-dataset.yaml \  epochs=100 --cuda-graph

При предсказании cuda_graph принимает три значения. По умолчанию — False. True захватывает граф при первой встрече каждой формы входа. "auto" ждёт повторения формы, поэтому разовые запуски и работа с меняющимися формами никогда не платят за захват, который не будет переиспользован. capture_graph(imgsz=None, batch=1, dtype=None) снимает эту стоимость с первого запроса, graph_info() показывает захваченные графы и число воспроизведений, а release_graphs() их освобождает.

При обучении флаг — обычное булево значение, в CLI это --cuda-graph. О смежных настройках см. производительность предсказания и производительность обучения.

Поддержка при инференсе

Поддержка объявляется по семействам через переменную класса SUPPORTS_CUDA_GRAPH, и семейство помечается только после того, как оно захватывается и воспроизводится побитово идентично на двух пробных входах из разных распределений. Эта общая матрица совпадения покрывает 39 семейств в девяти задачах.

ЗадачаСемейства
detectyolo1, yolo2, yolo3, yolo4, yolo9, yolo9_p2, yolo9_e2e, yolox, yolo7, yolonas, picodet, rtmdet, dfine, deim, deimv2, rtdetr, rtdetrv2, rtdetrv4, rfdetr, ec
segmentdfine, rtmdet, rfdetr, ec
poseec, yolonas, rfdetr
pointfomo
classifyresnet, convnext, mobilenetv4, efficientnetv2, clip, dinov2, siglip2
semanticeomt, dinov2, segformer, pidnet, lingbotvision
depthdepth_anything, depth_anything3, zipdepth
restorenafnet, realesrgan, swinir
mattebirefnet

Некоторые семейства встречаются сразу в нескольких задачах, поэтому строк в матрице больше, чем в ней различных семейств. Ещё три семейства захватываются через собственные ветки кода с отдельными тестами, а не через общую матрицу, и в эти 39 не входят: PP-OCR, SAM и SenseNova.

Проверка побитовая, а не приблизительная. Более ранняя версия протокола оценивала совпадение по относительной величине и ошибочно понизила три здоровых семейства — YOLOX, EfficientNetV2 и YOLOv7, — у которых разница между eager и графом составляет около 1e-7, хотя на той пробе, которая важна, результат побитово идентичен.

Поддержка при обучении

В этом релизе захват при обучении вырос с двух семейств до 24 и охватывает пять задач.

ЗадачаСемейства
detectyolo9, yolo9_p2, yolo9_e2e, yolox, yolo7, yolonas, picodet, rtmdet, rfdetr, dfine, deim, deimv2, rtdetr, rtdetrv2, rtdetrv4, ec
classifyresnet, convnext, mobilenetv4, efficientnetv2
semanticsegformer, lingbotvision
pointfomo
restorenafnet

Всё остальное обучается в eager-режиме: другие задачи на тех же семействах, не перечисленные семейства, распределённые запуски и запуски с дистилляцией. Захват также пропускается, пока форма ещё новая, потому что путь обучения ждёт, пока форма входа повторится три раза, а значит, при multi_scale=True захвата может не случиться вообще.

Два разных ответа для неподдерживаемого семейства

Путь инференса выбрасывает исключение. predict(cuda_graph=True) на семействе, которое поддержку не заявило, выбрасывает NotImplementedError с именем семейства, вместо того чтобы выполниться в eager-режиме и оставить вас в уверенности, что вы получили ускорение, которого не было. Причина в том, что плохой захват не падает громко: воспроизведение forward, который делает что-то незахватываемое, молча возвращает неверные числа, поэтому поддержка должна быть явным утверждением по каждому семейству, а не попыткой с откатом.

Путь обучения пишет в лог. train(cuda_graph=True) передавать всегда безопасно, а семейство, задача или конфигурация, которые захватить нельзя, пишут одну строку и обучаются в eager-режиме без изменений. Захват, сломавшийся посреди запуска, тоже переводит остаток запуска в eager-режим, а не прерывает его. Асимметрия сделана намеренно: предсказание — это вызов, который можно поправить прямо на месте вызова, а запуск обучения не должен умирать на шестом часу из-за необязательной оптимизации.

Разделение по швам

Некоторые семейства нельзя захватить целиком, потому что одна из стадий действительно делает то, что граф записать не может. Вместо того чтобы отказаться от семейства, захват разделяется по проверенному шву: захватываемая часть воспроизводится, остальное выполняется в eager-режиме, а совокупный результат совпадает с полностью eager-выполнением.

СемействоЗахватываетсяEager и почему
Depth Anything 3СетьШаг sky — работа на стороне хоста после forward
BiRefNetЭнкодер, forward_encДекодер, у которого deform_conv2d при захвате воспроизводится с другим результатом
PP-OCRСтадия детекции, forward_detРаспознавание, потому что ширина кропа своя у каждой строки
SAMЭнкодер изображенияПуть промптов — он выполняется много раз на одно кодирование
SenseNovaВизуальная башняАвторегрессионная генерация с KV-кэшем, который растёт на каждом шаге
Детекторы энкодер-декодерБэкбон и энкодерДекодер и венгерский критерий

Разделение BiRefNet стоит перечитать дважды: некорректное поведение deform_conv2d при захвате воспроизводится и на голом вызове вне какой-либо модели. От замены на чистый PyTorch-эквивалент отказались, потому что это сдвинуло бы и eager-предсказания, а числа eager — это контракт.

Случай энкодер-декодер охватывает D-FINE, DEIM, DEIMv2, RT-DETR, RT-DETRv2, RT-DETRv4 и EC. Их декодер строит запросы contrastive denoising по эталонной разметке (ground truth), а число этих запросов берётся из наибольшего количества эталонных объектов в батче, поэтому число токенов у декодера меняется от батча к батчу. Именно этого граф допустить не может. Бэкбон вместе с энкодером — это примерно от одной пятой до одной четверти шага у этих семейств, поэтому они и стоят внизу таблицы ускорений.

PP-OCR захватывает по одному графу на каждую форму входа детекции, в пределах лимита кэша у раннера, и возвращает eager-результат, когда область захвата не активна.

Числовые результаты

Большинство семейств побитово идентичны, а там, где нет, причина названа, а не обойдена молчанием. На нулевом шаге обучения функция потерь побитово идентична у всех 24 семейств, и ни один буфер BatchNorm не отличается; категории разделяет именно сравнение градиентов.

КлассСемействаЧто это значит
Точное совпадениеБольшинство из 24Каждый градиент побитово идентичен
1 ULPfomo, lingbotvisionПоследний бит float32, около 1e-7 относительной разницы, из-за другого порядка суммирования
Шум eagerЛинейка DETRГрафовый прогон отличается от eager не больше, чем два прогона eager отличаются друг от друга
Округление floatrtmdet137 из 139 градиентов побитово идентичны, два отличаются примерно на 3e-4
Собственный поток RNGsegformerStochastic depth находится внутри захваченной области

Класс «шум eager» важно прочитать правильно. У этих семейств два прогона eager с фиксированным сидом уже расходятся между собой, поэтому побитовая идентичность — не планка, которую графовый прогон не взял; эту планку не берёт никто. Это верно и шире — при amp=False, где измеренный относительный недетерминизм 3.2e-7 в fp32-градиенте весов накапливается: два прогона YOLOv9-t в eager с фиксированным сидом расходятся на 36 процентов за 20 шагов, и отключение TF32 это не чинит.

Закреплённая память

Захват выполняется с capture_error_mode="thread_local". В режиме "global", который стоит в PyTorch по умолчанию, поток pin-memory у DataLoader, готовящий следующий батч, вызывает cudaHostAlloc — а это и портит идущий захват, и само отравляется им, так что запуск умирает на следующем чтении батча с ошибкой, поднятой изнутри потока pin-memory. Эту связку дважды видели на реальной кампании обучения, прежде чем поставили диагноз.

Режим thread-local ограничивает только захватывающий поток. Поток pin-memory никогда не обращается к CUDA-стриму, на котором идёт захват, так что ничему из того, что он делает, в графе и не место. Обучение идёт дальше и временно подменяет torch.cuda.CUDAGraph подклассом, который принудительно задаёт этот режим, потому что у make_graphed_callables нет аргумента для него; подмена делается под блокировкой, чтобы два одновременных захвата не оставили её установленной.

Что это даёт

Измерено на RTX 5070 Ti с AMP, по одному процессу на вариант, с воспроизведением одного реального батча, чтобы загрузчик данных не влиял, самый быстрый из 24 шагов после прогрева. Детекция на 640 px, классификация на 224 px.

СемействоБатчУскорение
FOMO s163.63x
MobileNetV4 s162.74x
EfficientNetV2 b0162.44x
YOLOv9-t81.99x
YOLOv9 e2e81.76x
YOLOv9 p281.49x
Всё остальноепо-разномуот 1.04x до 1.26x

Целый запуск выигрывает меньше, потому что граф не ускоряет ни загрузчик данных, ни валидацию. Дообучение YOLOv9-t на 20 эпох на 406 изображениях сократилось с 428.4 с до 367.7 с — сквозной выигрыш 1.16x, при одинаковом mAP50-95 0.6394 в обоих вариантах и одинаковых потерях по эпохам.

Потолок задаётся тем, какую долю шага занимает сама сеть. На том же железе при 640 px и батче 8 это 84 процента для YOLOv9-t, но только 26 процентов для RTMDet-t, который большую часть шага проводит в назначении меток. Накладные расходы на запуск ядер выше всего на Windows, поэтому на Linux выигрыш выходит примерно от трети до половины этой таблицы, а запуск, упирающийся в загрузчик данных, по времени вообще не меняется. Пиковая память сдвигается в диапазоне от 5 процентов ниже до 19 процентов выше.

Оговорки

Граф записывает адреса, а не значения, поэтому всё, что перемещает параметры, его сбрасывает. Смена устройства через predict(device=...), квантизация и деквантизация — всё это делает захваченные графы недействительными.

Размер батча значит больше, чем семейство: RT-DETR-r18 даёт 1.19x при батче 2 и 1.04x при батче 8, потому что большой батч упирается в вычисления, и накладных расходов на запуск, которые можно убрать, там меньше.

Набор проверок совпадения при инференсе запускался без установленного необязательного пакета kernels, поэтому безопасность захвата при активных скомпилированных ядрах Hub он не покрывает. Задайте LIBREYOLO_HUB_KERNELS=0, чтобы убрать их из картины, пока локализуете проблему с захватом. См. kernels.

Список семейств для инференса получен из матрицы CAPTURABLE в tests/e2e/test_cuda_graph_families.py на версии 1.5.0. Список семейств для обучения, классы совпадения и тайминги — из docs/training_cuda_graphs.md. API и NotImplementedError — из BaseModel._require_cuda_graph_support, cuda_graph_scope и capture_graph в libreyolo/models/base/model.py, вместе с переменной класса SUPPORTS_CUDA_GRAPH. Разделения по швам прочитаны из переопределений _get_graph_runner в семействах depth_anything3, birefnet, ppocr, sam и sensenova и из libreyolo/models/base/detr_cuda_graph.py. capture_error_mode — из libreyolo/models/base/cuda_graph.py и libreyolo/training/cuda_graph.py. Откат при обучении — из libreyolo/training/trainer.py, а флаг --cuda-graph — из libreyolo/cli/commands/train.py.