Spec-Zone.ru › PyTorch 2

Деревья CUDAGraph

Обзор CUDAGraph

Для более подробного ознакомления с CUDAGraphs, прочитайте ускорение PyTorch с помощью CUDAGraphs.

CUDA Graphs, появившиеся в CUDA 10, позволяют определять и инкапсулировать серию CUDA ядер как единый блок, т.е. график операций, а не как последовательность отдельных операций. Это предоставляет механизм запуска нескольких операций на GPU с помощью одной операции на CPU, а значит, уменьшает издержки запуска.

CUDA Graphs могут обеспечить значительное ускорение, особенно для моделей с высокой нагрузкой на CPU или небольшими вычислениями. Существует ряд ограничений, связанных с требованием, чтобы одни и те же ядра запускались с одними и теми же аргументами и зависимостями, а также адресами памяти.

  • Невозможна обработка потоков управления
  • Ядра, которые вызывают синхронизацию хост-устройство (например, .item()), приводят к ошибкам
  • Все входные аргументы ядер фиксируются на значениях, зафиксированных при записи
  • Адреса CUDA памяти фиксированы, однако значения памяти по этим адресам могут меняться
  • Нет существенных операций CPU или эффектов на стороне CPU

Интеграция PyTorch с CUDAGraph

PyTorch предоставляет обёртку удобного использования вокруг CUDAGraphs, которая обрабатывает несколько сложных взаимодействий с кэширующим аллокатором PyTorch.

CachingAllocator использует отдельный пул памяти для всех новых выделений. Во время записи CUDAGraph память учитывается, выделяется и освобождается точно так же, как и во время выполнения eager. При воспроизведении вызываются только ядра, и нет изменений в аллокаторе. После начальной записи аллокатор не знает, какая память активно используется в пользовательских программах.

Использование отдельного пула памяти между eager-выделениями и выделениями cudagraph может увеличить объём памяти вашей программы, если выделено значительное количество памяти для обоих типов.

Создание вызываемых функций с графиками

Создание вызываемых функций с графиками — это абстракция PyTorch для совместного использования одного пула памяти над рядом вызываемых функций. Вызываемые функции с графиками используют тот факт, что при записи CUDA Graph память точно учитывается кэширующим аллокатором, чтобы безопасно разделять память между отдельными записями CUDA Graph. В каждом вызове выходы сохраняются как активная память, предотвращая перезапись активной памяти одной вызываемой функцией другой. Вызываемые функции с графиками могут вызываться только в одном порядке; адреса памяти из первого запуска записываются во второй и так далее.

Интеграция TorchDynamo с предыдущими CUDA Graphs

Запуск с cudagraph_trees=False не повторно использует память между отдельными захватами графика, что может привести к значительным проблемам с памятью. Даже для модели без разрывов графика это создаёт проблемы. Вперёд и назад — это отдельные захват графика, поэтому пулы памяти для вперёд и назад не совмещаются. В частности, память для активаций, сохранённых во вперёд, не может быть освобождена в назад.

Интеграция деревьев CUDAGraph

Как и вызываемые функции с графиками, деревья CUDA Graph используют один пул памяти для всех захватов графика. Однако вместо требования единственной последовательности вызовов, деревья CUDA Graph создают отдельные деревья захватов CUDA Graph. Давайте рассмотрим иллюстративный пример:

@torch.compile
def foo(x):
    # GRAPH 1
    y = x * x * x
    # graph break triggered here
    if y.sum() > 0:
        # GRAPH 2
        z = y ** y
    else:
        # GRAPH 3
        z = (y.abs() ** y.abs())
    torch._dynamo.graph_break()
    # GRAPH 4
    return z * torch.rand_like(z)

    # the first run warms up each graph, which does things like CuBlas or Triton benchmarking
    foo(torch.arange(0, 10), device="cuda")
    # The second run does a CUDA Graph recording, and replays it
    foo(torch.arange(0, 10), device="cuda")
    # Finally we hit the optimized, CUDA Graph replay path
    foo(torch.arange(0, 10), device="cuda")

В этом примере есть два отдельных пути, которые мы проходим через функцию: 1 -> 2 -> 4 или 1 -> 3 -> 4.

Мы совмещаем всю память в одном пуле памяти между отдельными записями, создавая ленту записей CUDA Graph, в данном случае 1 -> 2 -> 4. Мы добавляем инварианты, чтобы гарантировать, что память всегда находится в том же месте, что и при записи, и нет активных тензоров в пользовательских программах, которые могли бы быть перезаписаны.

  • Применимы те же ограничения от CUDA Graphs: одни и те же ядра должны вызываться с одними и теми же аргументами (статические размеры, адреса и т.д.)
  • Такая же структура памяти должна наблюдаться между записью и воспроизведением: если тензор, являющийся выходом одного графика, погибает после другого графика во время записи, то он также должен погибнуть во время воспроизведения.
  • Активная память в пуле CUDA требует зависимости между двумя записями
  • Эти записи могут быть вызваны только в одном порядке 1 -> 2 -> 4

Вся память совмещается в одном пуле памяти, поэтому дополнительной нагрузки на память по сравнению с eager нет. Что происходит, если мы пройдём новый путь и запустим Graph 3?

График 1 воспроизводится, а затем мы достигаем графика 3, который мы ещё не записали. При воспроизведении графика частный пул памяти не обновляется, поэтому y не отражается в аллокаторе. Без предосторожности мы перезапишем его. Чтобы поддержать повторное использование того же пула памяти после воспроизведения других графиков, мы сохраняем пул памяти в состоянии по окончании графика 1. Теперь, когда наши активные тензоры отражены в кэширующем аллокаторе, мы можем безопасно запустить новый график.

Сначала мы бы достигли оптимизированного пути CUDAGraph.replay(), который мы уже записали в графике 1. Затем мы бы достигли графика 3. Как и прежде, нам нужно будет один раз разогреть график перед записью. При разогреве адреса памяти не фиксируются, поэтому график 4 также вернётся к вызову inductor, не использующему cudagraph.

Во второй раз, когда мы достигнем графика 3, он разогрет и готов к записи. Мы запишем график 2, а затем снова запишем график 4, поскольку адреса входной памяти изменились. Это создаёт дерево записей CUDA Graph. Дерево CUDA Graph!

  1
 / \\
2   3
 \\   \\
  4   4

Ограничения

Так как CUDA Graph фиксирует адреса памяти, CUDA Graphs не имеют хорошего способа обработки активных тензоров из предыдущего вызова.

Предположим, мы проводим бенчмаркинг запуска инференса с помощью следующего кода:

import torch

def my_model(x):
    y = torch.matmul(x, x)
    return y

x = torch.randn(10, 10)
y1 = my_model(x)
y2 = my_model(x)

В реализации с отдельными CUDA Graph выход из первого вызова будет перезаписан вторым вызовом. Аналогично, в деревьях CUDA Graph, по умолчанию, активный выход от первого запуска потребовал бы зависимости между первым и вторым запуском, и мы никогда бы не достигли оптимизированного вызова cudagraph replay. Деревья CUDA Graph проигнорируют выходы от предыдущего запуска torch.compile и не будут создавать зависимость памяти. При обучении мы не будем игнорировать выходы от предыдущего запуска torch.compile, если у нас есть ожидающие обратные вычисления, которые ещё не были вызваны. TODO — добавить API для ручного увеличения поколения, выводить ошибку при доступе к предыдущему хранилищу

Сравнение

Недостатки

Отдельный CudaGraph

Деревья CUDAGraph

Память может увеличиться

При каждой компиляции графика (новые размеры и т.д.)

Если вы также запускаете память не-cudagraph

Записи

При любом новом вызове графика

Будет перезаписываться при каждом новом уникальном пути, который вы проходите через вашу программу

Недостатки

Вызов одного графика перезапишет предыдущий вызов

Невозможно сохранить память между отдельными проходами через вашу модель — один цикл обучения или один запуск инференса

© 2024, PyTorch Contributors
PyTorch has a BSD-style license, as found in the LICENSE file.
https://pytorch.org/docs/2.1/torch.compiler_cudagraph_trees.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API