FinCodeCow · AI-инфраструктура

Динамическое планирование, CUDA Graphs и оркестрация ядер с инициированием с устройства

Обзор главы 12 книги Chris Fregly «AI Systems Performance Engineering» (O'Reilly, 2026) — от атомарных очередей и захвата графов до NVSHMEM, Roofline-анализа и device-side диспетчеризации в масштабе кластера
Об источнике

Настоящий материал представляет собой обзор главы 12 книги Chris Fregly, «AI Systems Performance Engineering: Optimizing Model Training and Inference Workloads with GPUs, CUDA, and PyTorch», O'Reilly Media, 2026 (Copyright © 2026 Flux Capacitor, LLC).

Прямые цитаты из книги выделены отдельными блоками с пометкой «Цитата из книги». Остальной текст — систематизированное изложение содержания главы, сопоставление с актуальными данными, проверка прогнозов, а также технические детали, выходящие за пределы исходного текста и основанные на официальной документации NVIDIA и открытых источниках. Цитаты приводятся в ограниченном объёме в целях обзора и критического анализа.

IВведение: от оптимизации ядра к оркестрации конвейера

Глава 12 книги Fregly завершает блок, посвящённый низкоуровневой оптимизации CUDA. Если предыдущие главы были сосредоточены на внутриядерных техниках — арифметической интенсивности, Tensor Cores, специализации варпов и персистентных ядрах, — то глава 12 поднимается на уровень выше: как организовать параллельную работу между ядрами, между GPU и хостом, и между самими GPU. Ключевая идея главы: производительность GPU определяется не только плотностью загрузки SM, но и одновременной работой всех аппаратных подсистем — вычислительных SM, DMA-движков и интерконнектов.

Цитата из книги · Глава 12

«До сих пор мы занимались оптимизацией отдельных ядер — добивались максимальной пропускной способности вычислений и памяти на уровне одного запуска. Теперь настало время оркестрировать эти ядра так, чтобы графический процессор никогда не простаивал».

«В этой главе мы переходим от планирования на стороне хоста к планированию непосредственно на устройстве. Мы рассмотрим динамические очереди задач, управляемые быстрыми атомарными операциями в кэше L2, научимся сворачивать повторяющиеся запуски ядер и использовать CUDA Graphs для пакетирования фиксированных конвейеров и минимизации обращений к центральному процессору».

Изложение содержания

Глава делит материал на две взаимодополняющие части: механику динамического планирования и графов (первые разделы) и практические сценарии — условные узлы, динамический параллелизм и много-GPU оркестрацию (последующие разделы). В отличие от предыдущих глав, где рассматривались отдельные ядра, здесь акцент смещается на взаимодействие всех аппаратных подсистем GPU одновременно.

Отдельное внимание уделяется переходу от модели «хост управляет, устройство вычисляет» к модели, в которой GPU самостоятельно принимает решения о запуске следующей работы. Это принципиальное изменение парадигмы, которое становится возможным благодаря device-side запуску CUDA Graphs и динамическому параллелизму.

• • •

IIДинамическое планирование с помощью атомарных очередей задач

Анатомия простоя SM

Неравномерное распределение работы между потоками может привести к тому, что одни SM будут простаивать, пока другие всё ещё заняты. Это растрачивает вычислительные ресурсы и снижает общую пропускную способность.

Дисбаланс часто возникает, когда разные потоки или блоки обрабатывают переменный объём работы из-за зависящих от данных циклов или условных нагрузок. Некоторые блоки завершаются быстро, оставляя свои SM простаивающими, в то время как другие SM продолжают выполнять более длительные блоки.

На современных GPU с сотнями SM периоды простоя могут оставить множество SM без работы. Рассмотрим конкретный пример: GPU NVIDIA H100 содержит 132 SM, каждый из которых может одновременно выполнять до 64 варпов (2048 потоков). Если нагрузка такова, что половина блоков завершается за 1 мс, а вторая половина — за 10 мс, то в течение 9 мс половина SM (66 из 132) полностью простаивает. Это эквивалентно потере ~33 трлн потенциальных FLOPS в секунду — сравнимо с производительностью отдельного GPU предыдущего поколения.

К моменту завершения самой длительной работы часть GPU уже простаивала. Это снижает достигнутую заполняемость (achieved occupancy) — среднюю долю активных варпов на SM относительно аппаратного максимума. Помните, что вы можете использовать Nsight Systems для профилирования и наглядного отображения этих пробелов на временной шкале GPU.

Чтобы сопоставить пробелы на временной шкале с конкретными участками кода, вставьте маркеры диапазона NVTX вокруг значимых участков работы GPU:

#include <nvtx3/nvToolsExt.h>

nvtxRangePush("computeKernel_phase1");
computeKernel<<<grid, block>>>(d_in, d_out, N);
cudaStreamSynchronize(stream);
nvtxRangePop();

Атомарные счётчики: аппаратные основы

Атомарные счётчики — это основа для атомарных очередей, которые позволяют динамически распределять работу.

На современных GPU (архитектуры Ampere, Hopper, Blackwell) глобальные атомарные операции обслуживаются и сериализуются в кэше L2 на устройстве. Это снижает задержку по сравнению с обращениями к DRAM, когда целевая строка находится в кэше. Типичная задержка atomicAdd в L2 составляет 20–40 нс, в то время как атомарная операция с промахом в DRAM — 200–400 нс. Атомарные счётчики всё ещё несут задержку и сериализуются при конкуренции. Но неконкурирующие операции atomicAdd выполняются чрезвычайно быстро, оставаясь на кристалле.

Рисунок 1. Атомарные операции счётчика

Супербыстрые атомарные операции в кэше L2 GPU

T0
T1
T2
T3
T4
T5
T6
T7
8
COUNTER
atomicAdd threads L2-cache counter
Потоки инкрементируют единый счётчик в L2 — базовая примитива динамических очередей

В разделе Memory Workload Analysis в Nsight Compute вы найдёте счётчик atomic_transactions и atomic_transactions_per_request. Счётчик атомарных транзакций представляет общее количество атомарных транзакций в кэше L2, включая любые повторы, вызванные конкуренцией. Метрика атомарных транзакций на запрос, или коэффициент конкуренции, представляет среднее количество L2-транзакций, генерируемых каждой инструкцией atomicAdd.

Когда каждый atomicAdd выполняет ровно одну L2-транзакцию, ваш atomic_transactions_per_request держится около 1,0, что означает минимальные затраты. Если это отношение поднимается выше 1,0, это сигнализирует о том, что потоки простаивают и повторяют атомарные обновления вместо полезной работы. Каждый повтор указывает на конкуренцию.

Дополнительная оптимизация: __reduce_add_sync для суммирования внутри варпа

Варп-редукция __reduce_add_sync (sm_60+ для целочисленных типов, CUDA 9; redux.sync — sm_80+) выполняет редукцию суммы по варпу за одну инструкцию без необходимости в atomicAdd:

unsigned mask = 0xffffffff;
int partial = my_value;
int warp_sum = __reduce_add_sync(mask, partial);
int lane = threadIdx.x & 31;
if (lane == 0) {
    atomicAdd(&global_counter, warp_sum);
}

Этот подход снижает количество глобальных атомарных операций в 32 раза по сравнению с попотоковой схемой — без необходимости в __shfl_sync и ручном выборе лидера.

Пакетирование: амортизация атомарных операций

Оптимизация здесь заключается в амортизации атомарных операций путём захвата работы пакетами. То есть вместо того, чтобы каждый поток или варп выполнял atomicAdd, вы группируете несколько задач на одну атомарную операцию. Вот пример до и после с размером пакета 32:

// До пакетирования
int idx = atomicAdd(&queue_head, 1);
if (idx < N) process(data[idx]);

// После пакетирования
const int batchSize = 32;
int start = atomicAdd(&queue_head, batchSize);
for (int i = start; i < start + batchSize && i < N; ++i) {
    process(data[i]);
}

Теперь одно атомарное обновление выдаёт варпу целый срез работы — 32 элемента — прежде чем снова обратиться к счётчику. Вы по-прежнему платите одну L2-транзакцию за пакет, но выполняете в 32 раза больше полезной работы между ними.

На практике только один поток на варп выполняет этот atomicAdd, чтобы получить начальный индекс следующего пакета. Затем он транслирует его остальным потокам варпа (например, с помощью __shfl_sync). Весь варп обрабатывает эти 32 элемента параллельно. Это даёт одну атомарную операцию на варп вместо одной на поток, резко снижая конкуренцию.

В Nsight Compute вы увидите, как atomic_transactions резко падает, а количество транзакций на запрос возвращается к 1,0. Это доказывает, что вы обменяли дорогостоящую конкуренцию на устойчивые вычисления.

Динамические очереди на уровне варпов

Давайте теперь используем глобальный атомарный счётчик для координации динамической очереди задач. Цель — использовать атомарный счётчик и atomicAdd для балансировки произвольных нагрузок между всеми SM так, чтобы ни один поток или варп не простаивал.

В следующем примере кода (computeKernel) каждый поток вычисляет разное количество итераций на основе idx % 256. Потоки с малым значением idx % 256 выполняют очень мало работы, в то время как потоки с большим значением — много работы. В результате такого дисбаланса потоки завершаются в разное время, и некоторые SM простаивают в ожидании завершения самых длинных потоков. Вот код, использующий статическую неравномерную нагрузку:

// uneven_static.cu
#include <cuda_runtime.h>
#include <cmath>

__global__ void computeKernel(const float* input, float* output, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < N) {
        int work = idx % 256;
        float result = 0.0f;
        for (int i = 0; i < work; ++i) {
            result += sinf(input[idx]) * cosf(input[idx]);
        }
        output[idx] = result;
    }
}

int main() {
    const int N = 1 << 20;
    float* h_in = nullptr;
    float* h_out = nullptr;
    cudaMallocHost(&h_in, N * sizeof(float));
    cudaMallocHost(&h_out, N * sizeof(float));

    for (int i = 0; i < N; ++i) h_in[i] = float(i) / N;

    float *d_in, *d_out;
    cudaMalloc(&d_in, N * sizeof(float));
    cudaMalloc(&d_out, N * sizeof(float));
    cudaMemcpy(d_in, h_in, N * sizeof(float), cudaMemcpyHostToDevice);

    dim3 block(256), grid((N + 255) / 256);
    computeKernel<<<grid, block>>>(d_in, d_out, N);
    cudaDeviceSynchronize();

    cudaMemcpy(h_out, d_out, N * sizeof(float), cudaMemcpyDeviceToHost);
    cudaFree(d_in);
    cudaFree(d_out);
    cudaFreeHost(h_in);
    cudaFreeHost(h_out);
    return 0;
}

В оптимизированной версии с динамической диспетчеризацией задач единый глобальный счётчик (в памяти устройства) используется как очередь задач на уровне варпов. Мы превращаем счётчик в постоянную очередь задач, используя пакетные атомарные операции. Варпы, которые завершаются раньше, немедленно захватывают следующий пакет вместо простоя:

// uneven_dynamic.cu
#include <cuda_runtime.h>

__device__ unsigned int globalIndex = 0;

__global__ void computeKernelDynamicBatch(const float* input,
                                          float* output,
                                          int N) {
    int lane = threadIdx.x & (warpSize - 1);

    while (true) {
        unsigned mask = __activemask();
        int leader = __ffs(mask) - 1;

        unsigned int base = 0;
        if (lane == leader) {
            base = atomicAdd(&globalIndex, warpSize);
        }

        base = __shfl_sync(mask, base, leader);
        unsigned int idx = base + lane;

        if (idx >= (unsigned)N) break;

        float s = sinf(input[idx]);
        float c = cosf(input[idx]);
        int work = idx % 256;
        float result = 0.0f;

        #pragma unroll 1
        for (int i = 0; i < work; ++i) {
            result += s * c;
        }

        output[idx] = result;
    }
}

Рисунок 2. Динамическая очередь задач

Атомарная очередь — балансировка работы между SM и warp

1
2
3
4
5
6
7
8
atomicAdd
+8
SM 0
SM 1
SM 2
SM 3
Work = idx % 256 · динамическое распределение без простоя SM
Каждый варп атомарно захватывает следующий пакет задач из глобальной очереди

Иерархические и блочно-локальные очереди

Одиночная глобальная очередь хорошо работает для умеренных дисбалансов, но при экстремальной конкуренции (сотни активных варпов одновременно обращаются к одному счётчику) может потребоваться иерархический подход.

Идея: каждый блок потоков сначала обрабатывает локальный пул задач, выделенный ему при запуске. Когда локальный пул исчерпан, блок атомарно запрашивает следующий чанк из глобальной очереди. Это двухуровневая схема:

__global__ void hierarchicalDynamicKernel(const float* input,
                                           float* output,
                                           int N,
                                           int chunkPerBlock) {
    extern __shared__ int blockOffset[];

    int tid = threadIdx.x;
    int lane = tid & 31;

    if (tid == 0) {
        blockOffset[0] = atomicAdd(&globalIndex, chunkPerBlock);
    }
    __syncthreads();

    int localStart = blockOffset[0];
    int processed = 0;

    while (true) {
        int localIdx = localStart + processed + tid;
        if (localIdx >= (unsigned)N) {
            if (tid == 0) {
                blockOffset[0] = atomicAdd(&globalIndex, chunkPerBlock);
            }
            __syncthreads();
            localStart = blockOffset[0];
            processed = 0;
            localIdx = localStart + tid;
            if (localIdx >= (unsigned)N) break;
        }
        if (localIdx < (unsigned)N) {
            float s = sinf(input[localIdx]);
            float c = cosf(input[localIdx]);
            int work = localIdx % 256;
            float result = 0.0f;
            for (int i = 0; i < work; ++i) {
                result += s * c;
            }
            output[localIdx] = result;
        }
        processed += blockDim.x;
    }
}

Здесь chunkPerBlock — размер чанка, выделяемого каждому блоку за один atomicAdd. При chunkPerBlock = 256 (по одному элементу на поток в блоке из 256) и 4096 блоков на сетку, глобальный atomicAdd выполняется 4096 раз вместо 1 млн раз — снижение конкуренции в 244 раза.

Оценка производительности и метрики профилирования

В простом микробенчмарке с N = 220 и work = idx % 256 статически назначенное ядро заняло около 200 мс, тогда как версия с динамической очередью выполнилась примерно за 100 мс — ускорение в 2 раза. Для воспроизводимости: тест проводился на NVIDIA H100 SXM (80 ГБ HBM3), CUDA 12.4, частота ядра 1830 МГц, nvcc -O3 -arch=sm_90.

МетрикаСтатическая версияДинамическая версия
Время выполнения (N = 1M)~200 мс~100 мс
Достигнутая заполняемость~45%~78%
Активные циклы SM~52%~85%
atomic_transactions_per_requestN/A~1,02
Количество atomicAdd на GPU0~32768

В Nsight Compute проверьте: Warp Stall Reasons — если преобладает Stall Long Scoreboard (stall_long_sb, ожидание памяти), пакетирование может усугубить задержку; Register Pressure — дополнительный цикл while(true) увеличивает использование регистров; Achieved Occupancy — если динамическая версия показывает заполняемость ниже 60%, возможно, размер пакета слишком мал.

Итоги раздела II
Ключевое положение

Атомарные операции в L2 на современных GPU исключительно быстры. Пакетные инкременты снижают конкуренцию и балансируют нерегулярные нагрузки.

Практическое следствие

В Nsight Compute проверяются atomic_transactions_per_request (должен быть около 1,0), достигнутая заполняемость и warp stall reasons.

Ограничение

Для экстремальных дисбалансов применяются иерархические очереди. __reduce_add_sync (sm_60+, redux.sync sm_80+) снижает атомарные операции ещё в 32 раза.

• • •

IIICUDA Graphs

Мотивация: накладные расходы запуска

Когда ваш конвейер состоит из множества ядер, копирований, записей событий потока и обратных вызовов, их последовательный запуск на хосте на каждой итерации влечёт накладные расходы CPU. Типичная задержка запуска одного ядра с хоста составляет 5–10 мкс на современном GPU. Если ваш конвейер содержит 50 ядер (типично для inference-движков), это 250–500 мкс чистых накладных расходов на итерацию — при том, что вычисления могут занимать всего 1–2 мс. Накладные расходы составляют 12–25% общего времени.

CUDA Graphs позволяют захватить весь рабочий процесс один раз и воспроизводить его повторно практически без накладных расходов CPU — типичная задержка воспроизведения графа из 50 узлов составляет 2–5 мкс, что в разы (до 5–10× при коротких ядрах, в зависимости от нагрузки) меньше, чем при последовательных индивидуальных запусках.

Рисунок 3. Временная шкала запусков CUDA Graphs

Сравнение запусков ядер: без CUDA Graphs vs. с CUDA Graphs

Без CUDA Graphs 3 CPU launch calls + gaps
launch
Kernel A
gap
launch
Kernel B
gap
launch
Kernel C
С CUDA Graphs 1 graph replay · continuous
replay()
Kernel A
Kernel B
Kernel C
CUDA Graphs устраняют пробелы планирования CPU между ядрами

Зачем использовать CUDA Graphs: сокращение накладных расходов на запуск; лучшая планировка на GPU; зависимости известны заранее; корректное перекрытие асинхронных копирований и ядер.

Захват графа: потоковый и явный API

CUDA Graphs поддерживают два способа создания графа: потоковый захват (stream capture) и явное построение (explicit graph API).

Потоковый захват — самый простой способ:

cudaStream_t stream;
cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking);
cudaGraph_t graph;
cudaGraphExec_t instance;

cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);

kernelA<<<grid, block, 0, stream>>>(d_X);
kernelB<<<grid, block, 0, stream>>>(d_Y);
kernelC<<<grid, block, 0, stream>>>(d_Z);

cudaStreamEndCapture(stream, &graph);
cudaGraphInstantiate(&instance, graph, nullptr, nullptr, 0);

for (int iter = 0; iter < 100; ++iter) {
    cudaGraphLaunch(instance, stream);
}

cudaStreamSynchronize(stream);
cudaGraphExecDestroy(instance);
cudaGraphDestroy(graph);

Явное построение даёт более тонкий контроль — вы строите граф узел за узлом, явно указывая зависимости:

cudaGraph_t graph;
cudaGraphCreate(&graph, 0);

cudaGraphNode_t nodeA, nodeB, nodeC;
cudaKernelNodeParams paramsA = {}, paramsB = {}, paramsC = {};

paramsA.func = (void*)kernelA;
paramsA.gridDim = grid;
paramsA.blockDim = block;

cudaGraphAddKernelNode(&nodeA, graph, nullptr, 0, &paramsA);

cudaGraphNode_t depsB[] = {nodeA};
cudaGraphAddKernelNode(&nodeB, graph, depsB, 1, &paramsB);

cudaGraphNode_t depsC[] = {nodeB};
cudaGraphAddKernelNode(&nodeC, graph, depsC, 1, &paramsC);

Пулы памяти и стабильность указателей

Важным соображением является управление памятью с CUDA Graphs. Операции с памятью внутри CUDA Graph подчиняются тем же правилам, что и в потоках CUDA. Если вы выделяете память GPU внутри захвата, это выделение становится частью выполнения графа.

Обычно вы хотите избегать выделения памяти GPU внутри графа и предварительно выделять память вне графа. Многие фреймворки, такие как PyTorch, используют статические пулы памяти с CUDA Graphs:

pool = torch.cuda.graph_pool_handle()
g1 = torch.cuda.CUDAGraph()
g2 = torch.cuda.CUDAGraph()

with torch.cuda.graph(g1, pool=pool):
    # операции для графа 1
    ...

with torch.cuda.graph(g2, pool=pool):
    # операции для графа 2
    ...

Динамическое обновление графа

После того как вы записали CUDA Graph, вам не нужно выбрасывать его только из-за изменения параметров запуска. API обновления графа включает cudaGraphExecUpdate и низкоуровневый cudaGraphExecKernelNodeSetParams.

cudaKernelNodeParams newParams = {};
newParams.func = (void*)kernelB;
newParams.gridDim = dim3((batchSize + 255) / 256);
newParams.blockDim = dim3(256);
newParams.kernelParams = kernelArgs;

cudaGraphExecKernelNodeSetParams(instance, nodeB, &newParams);
cudaGraphLaunch(instance, stream);

PyTorch, инференс-движки и CUDA Graphs

AI-фреймворки, такие как PyTorch, используют CUDA Graphs под капотом для статических участков моделей глубокого обучения:

import torch

X = torch.randn(1<<20, device='cuda')

def opA(x): return x * 1.1
def opB(x): return x + 2.0
def opC(x): return x.sqrt()

static_x = torch.empty_like(X)
static_y = torch.empty_like(X)
static_z = torch.empty_like(X)
static_w = torch.empty_like(X)

_ = opC(opB(opA(X)))
torch.cuda.synchronize()

static_x.copy_(X)

g = torch.cuda.CUDAGraph()
stream = torch.cuda.Stream()
torch.cuda.synchronize()

with torch.cuda.graph(g, stream=stream):
    torch.mul(static_x, 1.1, out=static_y)
    torch.add(static_y, 2.0, out=static_z)
    torch.sqrt(static_z, out=static_w)

for i in range(100):
    g.replay()
МетрикаДо CUDA GraphsПосле CUDA Graphs
Вызовов запуска CPU на 100 итераций300 отдельных запусков ядер100 воспроизведений графа
Вызовов синхронизации хоста3000
Средний простой GPU между ядрами~3 мкс разрывов на итерацию0 мкс (непрерывное выполнение)
Сквозная задержка итерации~1,00 мс~0,75 мс (на 25% быстрее)

CUDA Graphs в тензорных движках: Triton и CuDNN

Triton генерирует CUDA-ядра, которые могут быть захвачены в графы точно так же, как ядра на C++:

import triton
import triton.language as tl

@triton.jit
def triton_kernel(x_ptr, y_ptr, N):
    pid = tl.program_id(0)
    offsets = pid * 256 + tl.arange(0, 256)
    mask = offsets < N
    x = tl.load(x_ptr + offsets, mask=mask)
    tl.store(y_ptr + offsets, x * 1.1, mask=mask)

g = torch.cuda.CUDAGraph()
with torch.cuda.graph(g):
    triton_kernel[(grid,)](x_buf, y_buf, N)
g.replay()
Итоги раздела III
Ключевое положение

CUDA Graphs сокращают накладные расходы запуска в разы — до 5–10× при коротких ядрах. Захват возможен как через потоковый API, так и через явное построение.

Практическое следствие

PyTorch использует статические пулы памяти для графов. Triton-ядра также захватываются. Динамическое обновление через cudaGraphExecUpdate позволяет избежать повторного захвата.

Ограничение

Память должна быть предварительно выделена вне графа. Динамические формы требуют обновления или пересоздания графа.

• • •

IVЗапуск CUDA Graph с инициированием с устройства

Мотивация: устранение хоста с критического пути

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

Чтобы включить запуск с устройства, сначала захватите граф как обычно на хосте. Затем создайте его экземпляр с флагом cudaGraphInstantiateFlagDeviceLaunch и загрузите с помощью cudaGraphUpload:

cudaGraphInstantiate(&graphExec, graph, nullptr, nullptr,
                     cudaGraphInstantiateFlagDeviceLaunch);
cudaGraphUpload(graphExec, stream);

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

Режимы запуска: fire-and-forget, tail, sibling

Fire-and-forget (cudaStreamGraphFireAndForget): дочерний граф начинает выполняться немедленно и одновременно с запускающим родительским ядром. Родитель не ждёт завершения дочернего. Лимит: до 120 общих fire-and-forget графов одновременно.

Tail (cudaStreamGraphTailLaunch): выполнение графа откладывается до завершения запускающего ядра. Граф ставится в очередь как продолжение. Лимит: до 255 ожидающих tail-запусков; для self-tail-launch — только один одновременно.

Tail-запуски особенно мощны при реализации планировщиков, резидентных на GPU. Постоянное ядро-«планировщик» запускает граф через tail, затем перезапускает себя:

__global__ void schedulerKernel(/* ... */) {
    cudaGraphExec_t self = cudaGetCurrentGraphExec();

    unsigned int taskIdx = atomicAdd(&queueHead, 1);
    if (taskIdx >= N_tasks) return;

    cudaGraphLaunch(decodingGraphExec, cudaStreamGraphTailLaunch);
    cudaGraphLaunch(self, cudaStreamGraphTailLaunch);
}

Sibling (cudaStreamGraphFireAndForgetAsSibling): запущенный граф выполняется как равноправный с родительским графом, а не как дочерний.

Резидентный планировщик LLM-инференса

Рассмотрим практический паттерн, объединяющий атомарные очереди и CUDA Graphs с device-side tail-запуском для планирования инференса LLM внутри постоянного ядра.

Лёгкое постоянное ядро-планировщик использует atomicAdd(&queueHead, 1) для захвата следующего элемента работы. Затем оно запускает через tail предварительно захваченный CUDA Graph декодирования и немедленно возвращается к следующему элементу в очереди.

Связывая tail-запуски в цепочку, каждый токен обрабатывается от начала до конца на устройстве. Поскольку CPU никогда не возвращается на критический путь, SM остаются загруженными, задержка на токен снижается, и можно адаптироваться к разным длинам последовательностей и размерам батчей на лету.

Отладка device-side графов

Отладка графов, запущенных с устройства, может быть сложной. Рекомендации:

  • Nsight Systems показывает дочерние графы на временной шкале GPU как отдельные потоки работы.
  • NVTX-маркеры в родительском ядре до и после вызовов cudaGraphLaunch помогают отметить, где происходят запуски.
  • Проверка ошибок device-side: используйте cudaGetLastError() внутри устройства (доступно с CUDA 12.0).
  • Логирование: printf внутри устройства работает, но может существенно замедлить выполнение.
Итоги раздела IV
Ключевое положение

Device-side запуск графов убирает CPU с критического пути. Задержка запуска вдвое ниже хостовой и не зависит от размера графа.

Режимы

Fire-and-forget — немедленный параллельный запуск. Tail — запуск после завершения родителя. Sibling — равноправный запуск.

Практическое следствие

Резидентный планировщик с tail-запуском позволяет обрабатывать токены LLM без возврата на CPU. Отладка ведётся через Nsight Systems и NVTX.

• • •

VУсловные узлы графа

Типы условных узлов: IF, IF/ELSE, WHILE, SWITCH

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

Рисунок 4. Условные узлы графа

IF · IF/ELSE · WHILE · SWITCH — ветвление прямо на GPU без CPU

IF

once

Выполняет тело один раз, если condition ≠ 0.

↓
body graph

IF/ELSE

branch

Выбирает одно из двух тел в зависимости от condition.

↓
true body
false body

WHILE

loop

Повторяет тело, пока condition ≠ 0.

↻
body graph · re-check

SWITCH

select

Выбирает i-е тело, если condition = i; иначе пропускает.

↓
body 0
body 1
body 2
Условные узлы переносят логику ветвления на GPU без обращений к CPU

Пример создания условного узла IF:

__global__ void setHandle(cudaGraphConditionalHandle handle,
                          int *data, size_t N) {
    constexpr int threshold = 123456;
    unsigned int flag =
        (reduce_sum(data, N) > threshold) ? 1u : 0u;
    cudaGraphSetConditional(handle, flag);
}

__global__ void bodyKernel() {
    printf("Условное тело выполнено на GPU!\n");
}

Вложенные условия и многоуровневые решения

Условные узлы могут быть вложенными. Например, тело узла WHILE может содержать узел IF. Это позволяет реализовать многоуровневую логику принятия решений без обращений к CPU:

┌──────────────────────────────────────┐
│  WHILE (пока есть невыполненные задачи)│
│  ┌──────────────────────────────────┐ │
│  │  IF (тип задачи == "compression")│ │
│  │  ┌────────────┐ ┌──────────────┐ │ │
│  │  │ LZ-сжатие  │ │ Huff-сжатие  │ │ │
│  │  └────────────┘ └──────────────┘ │ │
│  └──────────────────────────────────┘ │
│  ┌──────────────────────────────────┐ │
│  │  ELSE IF (тип == "decompression") │ │
│  │  ┌────────────────────────────┐  │ │
│  │  │  Декомпрессия              │  │ │
│  │  └────────────────────────────┘  │ │
│  └──────────────────────────────────┘ │
└──────────────────────────────────────┘

Практический пример: адаптивный выбор ядра

Рассмотрим конвейер сжатия на GPU, где ядро должно выбирать между разными алгоритмами в зависимости от содержимого данных:

__global__ void classifyData(cudaGraphConditionalHandle handle,
                               int* data, size_t N) {
    if (threadIdx.x == 0) {
        float entropy = compute_entropy(data, N);

        unsigned int algo;
        if (entropy < 2.0f)      algo = 0; // LZ-сжатие
        else if (entropy < 5.0f) algo = 1; // Huffman
        else                    algo = 2; // Без сжатия

        cudaGraphSetConditional(handle, algo);
    }
}
Итоги раздела V
Ключевое положение

Условные узлы (IF, IF/ELSE, WHILE, SWITCH) позволяют реализовать ветвление и циклы прямо на GPU без возврата на CPU.

Практическое следствие

Логика принятия решений переносится на устройство. Условия могут быть вложенными. Пример: адаптивный выбор алгоритма сжатия по энтропии данных.

Ограничение

Тела условных узлов не могут содержать динамический параллелизм или device-side запуск графов. Cooperative launches разрешены только без MPS.

• • •

VIДинамический параллелизм

Когда нужен DP, а когда достаточно графа

Динамический параллелизм (DP) даёт вашим ядрам GPU способность порождать новую работу для себя, вместо того чтобы ждать CPU. В то время как CUDA Graphs требуют, чтобы вы знали весь конвейер заранее, DP позволяет работающему «родительскому» ядру исследовать свои выходные данные и решать на лету, сколько «дочерних» ядер запустить.

КритерийCUDA Graphs (device-launch)Динамический параллелизм
Структура потока управленияЗаранее известнаВозникает из данных во время выполнения
Накладные расходы на запуск~5 мкс (амортизированные)~20–25 мкс на каждый дочерний запуск
Гибкость формыФиксированные формы, обновляемые через APIПроизвольные формы
Лимит запусков120 fire-and-forget, 255 tail2048 pending (настраиваемый)
Лучшее применениеПовторяющиеся конвейерыИерархические, нерегулярные задачи

Управление ресурсами: стек и pending launch count

Каждый дочерний запуск использует ресурсы планирования GPU и требует дополнительного пространства стека. Чтобы избежать ошибок «переполнения стека», увеличьте размер стека:

cudaDeviceSetLimit(cudaLimitStackSize, 1024);
cudaDeviceSetLimit(cudaLimitDevRuntimePendingLaunchCount, 8192);

Сравнение host-launched и device-launched

Версия с DP и device-side запуском:

// dp_device_launched.cu
// Компилировать с -rdc=true
__global__ void childKernel(float* data, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) data[idx] = data[idx] * data[idx];
}

__global__ void parentKernel(float* data, int n) {
    if (blockIdx.x == 0 && threadIdx.x == 0) {
        const int threadsPerBlock = 256;
        const int firstHalfCount = n / 2;
        const int secondHalfCount = n - firstHalfCount;
        const int blocksFirst = (firstHalfCount + threadsPerBlock - 1)
            / threadsPerBlock;
        const int blocksSecond = (secondHalfCount + threadsPerBlock - 1)
            / threadsPerBlock;

        childKernel<<<blocksFirst, threadsPerBlock>>>(data, firstHalfCount);
        childKernel<<<blocksSecond, threadsPerBlock>>>(
            data + firstHalfCount, secondHalfCount);
    }
}
МетрикаДо (запуск с хоста)После (запуск с устройства)
Всего запусков с хоста31
Средние накладные расходы на запуск~20 мкс~25 мкс
Циклы простоя GPU~40%~5%
Общее время выполнения1,00 мс0,75 мс

DP + NVSHMEM: нерегулярные задачи в кластере

Динамический параллелизм особенно мощен в сочетании с NVSHMEM для нерегулярных задач в кластере. Например, в алгоритме обхода графа каждый GPU обрабатывает свою часть графа. Когда обнаруживаются рёбра, ведущие к вершинам на других GPU, ядро может:

  1. Через NVSHMEM поместить данные в удалённый GPU.
  2. Через DP запустить дочернее ядро для обработки локальной части.
  3. Дочернее ядро на удалённом GPU активируется по сигналу NVSHMEM.
Итоги раздела VI
Ключевое положение

Динамический параллелизм позволяет ядрам порождать дочерние ядра. Простоя GPU снижается с ~40% до ~5%.

Практическое следствие

DP применяется для иерархических, нерегулярных задач. Комбинируется с NVSHMEM для межузловой координации. Требуется настройка стека и лимита pending-запусков.

Ограничение

Каждый дочерний запуск несёт накладные расходы ~20–25 мкс. Для повторяющихся конвейеров лучше подходят CUDA Graphs.

• • •

VIIОркестрация на нескольких GPU и узлах кластера

Peer-to-peer копирования и GPUDirect

Когда вы масштабируетесь с одного GPU до многих, основная цель остаётся прежней: поддерживать занятость каждого устройства, скрывая перемещение данных за полезной работой.

cudaMemcpyPeerAsync(dest_ptr, dest_gpu, src_ptr, src_gpu, size, comm_stream);
МежсоединениеПропускная способность (на GPU)Задержка
PCIe Gen5 x16~64 ГБ/с~5–10 мкс
NVLink 3 (A100)~600 ГБ/с~1–2 мкс
NVLink 4 (H100)~900 ГБ/с<1 мкс
NVLink 4 (B200)~1800 ГБ/с<1 мкс
InfiniBand NDR~50 ГБ/с~0,5–1 мкс

NCCL: коллективы и интеграция с CUDA Graphs

Когда вам нужна коллективная коммуникация, такая как all-reduce градиентов в PyTorch Distributed Data Parallel (DDP), вы запускаете неблокирующие процедуры NCCL в отдельном потоке:

ncclAllReduce(sendbuff, recvbuff, count, ncclFloat, ncclSum,
              comm, comm_stream);

NCCL-операции могут быть захвачены в CUDA Graphs:

cudaStreamBeginCapture(comm_stream, cudaStreamCaptureModeGlobal);
ncclAllReduce(sendbuff, recvbuff, count, ncclFloat, ncclSum,
              comm, comm_stream);
cudaStreamEndCapture(comm_stream, &graph);

Рисунок 5. Перекрытие All-Reduce с вычислениями

Bucketed gradient reduction перекрывается с backward pass

Process 1

Compute L1
Compute L2 ∥ AR-1
Compute L3
backward · all-reduce buckets

Process 2

Compute L1
Compute L2 ∥ AR-2
Compute L3
overlap hidden behind compute
AR bucket 1
AR bucket 2
AR bucket 3
AR bucket 4
Compute All-Reduce (overlapped)
Сегментированный all-reduce скрывает коммуникацию за вычислениями

NVSHMEM: односторонняя коммуникация PGAS

Для рабочих нагрузок, требующих сверхплотной, управляемой событиями координации, NVSHMEM рассматривает каждый GPU как обрабатывающий элемент (PE) в разделяемом глобальном адресном пространстве (PGAS). GPU может напрямую писать в память другого GPU из кода устройства, минуя CPU.

Рисунок 6. Односторонняя коммуникация NVSHMEM

GPU-initiated RMA: sender → receiver без участия CPU

PE 0 · Sender

data
flag

PE 1 · Receiver

data
flag
put
signal
nvshmem_float_p(remote_data, value, dest_pe);
nvshmem_quiet();
nvshmem_int_p(remote_flag, 1, dest_pe);
nvshmem_int_wait_until(remote_flag,
  NVSHMEM_CMP_EQ, 1);
float val = remote_data[1];
GPU/PE 0 пишет данные и флаг прямо в память GPU/PE 1

Паттерн «отправить и сигнализировать»:

#include <nvshmem.h>
#include <nvshmemx.h>

__device__ int *remote_flag;
__device__ float *remote_data;

__global__ void sender_kernel(float *local_data, int dest_pe) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    float value = local_data[idx];

    nvshmem_float_p(remote_data + 1, value, dest_pe);
    nvshmem_quiet();
    nvshmem_int_p(remote_flag + 0, 1, dest_pe);
}

__global__ void receiver_kernel(float *recv_buffer) {
    nvshmem_int_wait_until(remote_flag + 0, NVSHMEM_CMP_EQ, 1);
    float val = remote_data[1];
    recv_buffer[0] = val * 2.0f;
}

Work stealing на множестве GPU

NVSHMEM-атомарные операции позволяют каждому PE динамически захватывать работу из глобального счётчика — реализуя work stealing полностью на устройстве:

__global__ void work_steal_kernel(/*...*/, int *queue_head, Task *tasks) {
    while (true) {
        int idx = nvshmem_int_atomic_inc(queue_head);
        if (idx >= N_tasks) break;
        // Обрабатываем tasks[idx]...
    }
}

Кооперативные запуски и барьеры

Для сценариев, требующих минимального джиттера, запустите кооперативное ядро, охватывающее все GPU, с использованием nvshmemx_collective_launch():

__global__ void synchronized_step_kernel(/*...*/) {
    nvshmem_barrier_all();
    // Все GPU продолжают синхронно
}

Паттерн масштабирования на N GPU

Независимо от того, используете ли вы peer-копирования, кольца NCCL или односторонние операции NVSHMEM, паттерн масштабирования всегда один и тот же: система должна однократно диспетчеризовать ядра, конвейеризовать и перекрывать передачи данных с вычислениями и масштабироваться линейно — особенно с хостом CPU вне критического пути.

Рисунок 7. Параллельный обмен на N GPU

Каждый GPU вычисляет и одновременно обменивается данными — без idle и без застоя

GPU 0
compute + send
GPU 1
compute + recv
GPU 2
compute + send
GPU 3
compute + recv
GPU SMs Packets (RDMA/NVLink)
4 GPU в идеале работают как один GPU, который в 4 раза быстрее

Формула эффективности масштабирования:

Speedup(N) = 1 / (f_compute/N + f_comm)

где f_compute — доля вычислений, f_comm — доля коммуникации, N — количество GPU. При f_comm = 0,2 и N = 8 ускорение составит 1/(0,1 + 0,2) = 3,3×, а не 8×.

Итоги раздела VII
Ключевое положение

NVSHMEM предоставляет односторонние put/get и work stealing на устройстве. NCCL-операции захватываются в графы.

Практическое следствие

Перекрытие коммуникации с вычислениями через сегментированный all-reduce. NVSHMEM выигрывает у NCCL на малых сообщениях (8–256 КБ — в 10–20 раз), но проигрывает на больших — выбирайте по размеру сообщения.

Ограничение

Эффективность масштабирования ограничена долей коммуникации. Формула Speedup(N) показывает практический потолок.

• • •

VIIIПринятие решений на основе Roofline-модели

Основы Roofline

Roofline-модель — это визуальная модель производительности, которая связывает достигнутую производительность (FLOPS) с операционной интенсивностью (FLOPS/байт) и аппаратными пределами.

Performance = min(Peak FLOPS, OI × Peak Memory BW)

Операционная интенсивность (OI) — это количество операций с плавающей точкой, приходящихся на каждый байт, прочитанный из памяти:

OI = FLOPs / Bytes accessed

Например, для матричного умножения M × N × K: FLOPs = 2MNK, Bytes (FP16) = 2(MK + KN + MN). При M = N = K = 4096: OI ≈ 1365 FLOPS/байт.

Балансировочная точка для H100 (FP16): 989 TFLOPS / 3,35 TB/s ≈ 295 FLOPS/байт. Матричное умножение (OI ~1365) находится далеко справа — ограничение вычислениями. Поэлементное сложение (OI ~0,083) — далеко слева — ограничение памятью.

Сценарий A: ограничение памятью

Когда ваше ядро ограничено памятью (OI < ridge point), производительность ограничена пропускной способностью памяти. Приоритеты:

  1. Слияние ядер — объединение нескольких поэлементных операций в одно ядро увеличивает OI.
  2. Перекрытие копирований с вычислениями — используйте потоки CUDA для сокрытия задержки передачи данных.
  3. CUDA Graphs — пакетирование операций снижает накладные расходы CPU.
  4. Размещение данных — cudaMemcpyAsync с pinned memory.

Сценарий B: ограничение вычислениями

Когда ваше ядро ограничено вычислениями (OI > ridge point), производительность ограничена пиковой вычислительной мощностью:

  1. Снижение накладных расходов запуска — CUDA Graphs с device-side запуском.
  2. Динамический параллелизм — для адаптивных алгоритмов.
  3. Динамическое планирование — балансировка нагрузки через атомарные очереди.
  4. Tensor Core утилизация — wmma или mma для FP16/BF16/FP8.

Сценарий C: ограничение задержкой запуска

Когда время выполнения ядра сопоставимо с накладными расходами запуска (< 50 мкс):

  1. CUDA Graphs — захват всего конвейера в один граф (2–5 мкс вместо 250+ мкс).
  2. Device-side запуск графов — ещё ниже задержка (~2 мкс).
  3. Условные узлы — перенос ветвления на GPU.
  4. Постоянные ядра — ядро, которое не завершается, а ждёт новую работу.

Сценарий D: ограничение коммуникацией

В многопроцессорных системах производительность может быть ограничена пропускной способностью межсоединений:

  1. Перекрытие коммуникации и вычислений — отдельные потоки для NCCL и вычислений.
  2. NVSHMEM — односторонние операции для тонкой синхронизации.
  3. Конвейеризация — разбиение батча на микробатчи.
  4. NCCL в CUDA Graphs — устранение накладных расходов CPU.

Дерево решений

                    ┌─ Узкое место ─┐
                    │               │
            ┌── Память ──┐    ┌── Вычисления ──┐
            │            │    │                  │
     Слияние ядер   Перекрытие   CUDA Graphs    Динамическое
     + Graphs       копирований  + DP           планирование
                    │            │
              ┌── Запуск ──┐    ┌── Коммуникация ──┐
              │            │    │                    │
         Device-side    Условные  NVSHMEM +      NCCL в Graphs
         запуск графов   узлы     перекрытие     + конвейеризация
Итоги раздела VIII
Ключевое положение

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

Практическое следствие

Для каждого сценария существует свой набор приоритетных техник. Дерево решений помогает выбрать правильный инструмент.

Ограничение

На практике нагрузка может ограничиваться несколькими факторами одновременно. Требуется итеративное профилирование.

• • •

IXДополнительные темы

Cooperative Groups и кооперативные ядра

Cooperative Groups — это расширение CUDA, которое предоставляет более гибкие примитивы синхронизации, чем барьеры __syncthreads(). Кооперативные ядра запускают ядро, которое может синхронизироваться на уровне всей сетки через cg::this_grid().sync():

#include <cooperative_groups.h>
namespace cg = cooperative_groups;

__global__ void cooperativeKernel(float* data, int N, int iterations) {
    cg::grid_group grid = cg::this_grid();

    for (int iter = 0; iter < iterations; ++iter) {
        int idx = blockIdx.x * blockDim.x + threadIdx.x;
        if (idx < N) data[idx] = process(data[idx]);
        grid.sync();
    }
}

void* args[] = {&d_data, &N, &iterations};
cudaLaunchCooperativeKernel((void*)cooperativeKernel,
                             dim3(grid), dim3(block), args, 0, stream);

Ограничение: кооперативные ядра требуют, чтобы все блоки могли быть запущены одновременно. Это ограничивает максимальное количество блоков произведением max_blocks_per_SM × SM_count. На H100 это 32 × 132 = 4224 блоков максимум.

Triton и высокоуровневые альтернативы CUDA C++

Triton — это язык программирования GPU-ядер, разработанный OpenAI, который компилируется в эффективный PTX/SASS. Он предоставляет высокоуровневые абстракции для работы с блоками данных, автоматического управления памятью и векторизации.

Пример fused-ядра на Triton для scaled dot-product attention:

import triton
import triton.language as tl

@triton.jit
def flash_attention(Q, K, V, O, sm_scale,
                    N_ctx, BLOCK_M: tl.constexpr,
                    BLOCK_N: tl.constexpr, BLOCK_D: tl.constexpr):
    start_m = tl.program_id(0)
    off_hz = tl.program_id(1)
    off_qk = off_hz * N_ctx

    q = tl.load(Q + off_qk + start_m * BLOCK_M + ...)
    tl.store(O + off_qk + start_m * BLOCK_M + ..., result)

Stream-Ordered Memory Allocator

CUDA 11.2 представил stream-ordered memory allocator (cudaMallocAsync / cudaFreeAsync), который упрощает управление памятью в динамических рабочих нагрузках и интегрируется с CUDA Graphs:

cudaStream_t stream;
cudaMallocAsync(&ptr, size, stream);
kernel<<<grid, block, 0, stream>>>(ptr);
cudaFreeAsync(ptr, stream);

Профилирование device-side запусков

Nsight Systems: дочерние графы отображаются как отдельные строки на временной шкале GPU. Используйте NVTX-маркеры. Включите --gpu-metrics-device=all.

Nsight Compute: дочерние ядра, запущенные через DP, профилируются индивидуально. Используйте --kernel-id.

Практический чек-лист для device-side запусков:

  1. Убедитесь, что граф загружен через cudaGraphUpload.
  2. Проверьте, что cudaGraphInstantiate вызван с флагом cudaGraphInstantiateFlagDeviceLaunch.
  3. Убедитесь, что количество одновременных запусков не превышает лимиты.
  4. Проверьте использование стека устройства.
  5. Убедитесь, что хост не синхронизируется внутри цикла device-side запусков.

XКлючевые выводы

  • Динамическое планирование с атомарными очередями в кэше L2. Атомарные операции в кэше L2 на современных GPU исключительно быстры. Используйте пакетные инкременты для балансировки нерегулярных нагрузок. Это может повысить пропускную способность до 2× в случаях экстремального дисбаланса, но обычно между 10% и 30%.
  • CUDA Graphs для пакетирования конвейеров. Захват повторяющихся последовательностей ядер, копирований и событий в один граф. Это снижает накладные расходы запуска в разы (до 5–10× при коротких ядрах) и устраняет пробелы планирования CPU.
  • Оркестрация на стороне устройства. Запускайте работу с самого GPU через tail-запуск CUDA Graph или динамический параллелизм. Условные узлы (IF, WHILE, SWITCH) переносят логику ветвления на GPU.
  • Многопроцессорное перекрытие. Всегда перекрывайте коммуникацию с вычислениями. Используйте отдельные потоки для peer-to-peer передач, коллективов NCCL, CUDA-aware MPI или односторонних операций NVSHMEM.
  • NVSHMEM для тонкой координации. Для нерегулярных, зависящих от данных рабочих нагрузок NVSHMEM предоставляет односторонние операции put/get и тонкую синхронизацию через сигналы.
  • Roofline как метод выбора. Определите, ограничена ли ваша нагрузка памятью, вычислениями, задержкой запуска или коммуникацией. Выбирайте инструмент соответственно.

XIЗаключение

В этой главе мы вышли за пределы оптимизаций отдельных ядер и исследовали сквозные техники оркестрации. Мы рассмотрели, как запускать работу полностью на устройстве с помощью динамического параллелизма, захватывать сложные рабочие процессы в CUDA Graphs, переносить логику ветвления через условные узлы и координировать множество GPU с помощью NCCL и NVSHMEM.

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

Современные платформы NVIDIA всё больше размывают границу между CPU и GPU. Суперчипы Grace Blackwell и Vera Rubin соединяют CPU с несколькими GPU с помощью когерентного NVLink с огромной пропускной способностью. В таких системах традиционное разделение «хост управляет, устройство вычисляет» уступает место парадигме, в которой GPU сами управляют своим конвейером — от планирования и диспетчеризации до синхронизации и коммуникации.

Это не просто технологическая эволюция — это смена парадигмы программирования. Инженеры, которые освоят методы device-side оркестрации, получат возможность извлекать производительность, недоступную тем, кто остаётся в рамках хост-управляемой модели. Инструменты, которые мы рассмотрели — атомарные очереди, CUDA Graphs с device-side запуском, условные узлы, динамический параллелизм и NVSHMEM — это строительные блоки для конвейеров следующего поколения, в которых GPU работает как самостоятельный сопроцессор, а не как подчинённый периферийный блок.

• • •
Итог

Оркестрация GPU выходит за рамки отдельного ядра. Динамическое планирование, CUDA Graphs, device-side запуски, условные узлы, динамический параллелизм и NVSHMEM — это строительные блоки для конвейеров следующего поколения, в которых GPU работает как самостоятельный сопроцессор. Освоив их, вы сможете извлекать производительность, недоступную в рамках традиционной хост-управляемой модели.

FinCodeCow

Dynamic Scheduling, CUDA Graphs and Device-Initiated Kernel Orchestration

An extended guide: from atomic queues and graph capture to NVSHMEM, Roofline analysis, and device-side dispatch at cluster scale
About the source

This material is a review of Chapter 12 of Chris Fregly, «AI Systems Performance Engineering: Optimizing Model Training and Inference Workloads with GPUs, CUDA, and PyTorch», O'Reilly Media, 2026 (Copyright © 2026 Flux Capacitor, LLC).

Direct quotes from the book are marked with separate blocks labeled «Quote from the book». The rest of the text is a systematized exposition of the chapter's content, a comparison with current data, forecast verification, and technical details beyond the original text based on official NVIDIA documentation and open sources. Quotes are provided in limited volume for review and critical analysis purposes.

IIntroduction: from kernel optimization to pipeline orchestration

Chapter 12 of Fregly's book completes the block dedicated to low-level CUDA optimization. While Chapters 9 and 10 focused on intra-kernel techniques — arithmetic intensity, Tensor Cores, warp specialization, and persistent kernels — Chapter 12 rises to the next level: how to organize parallel work between kernels, between GPU and host, and between GPUs themselves. The key idea: GPU performance is determined not only by how tightly individual SMs are loaded, but also by how efficiently all hardware subsystems — compute SMs, DMA engines and interconnects — are used simultaneously.

Quote from the book · Chapter 12

«So far we have optimized individual kernels — maximizing compute and memory throughput at a single launch level. Now it is time to orchestrate these kernels so the GPU never sits idle».

«In this chapter we move from host-side scheduling to scheduling directly on the device. We will examine dynamic work queues driven by fast atomic operations in the L2 cache, learn to fold repeated kernel launches into CUDA Graphs, and batch fixed pipelines to minimize CPU overhead».

Exposition of content

The chapter divides the material into two complementary parts: the mechanics of dynamic scheduling and graphs (first sections) and practical scenarios — conditional nodes, dynamic parallelism and multi-GPU orchestration (later sections). Unlike previous chapters, which examined individual kernels, here the emphasis shifts to the interaction of all GPU hardware subsystems simultaneously.

Particular attention is paid to the transition from the "host manages, device computes" model to a model in which the GPU independently decides what work to launch next. This is a fundamental paradigm shift, made possible by device-side CUDA Graph launch and dynamic parallelism.

• • •

IIDynamic scheduling with atomic work queues

Anatomy of SM idle time

Uneven distribution of work across threads can leave some SMs idle while others are still busy. This wastes compute resources and lowers overall throughput.

Imbalance often occurs when different threads or blocks process variable workloads due to data-dependent loops or conditional loads. Some blocks finish quickly, leaving their SMs idle, while other SMs continue executing longer blocks.

On modern GPUs with hundreds of SMs, idle periods can leave many SMs without work. Consider a specific example: the NVIDIA H100 has 132 SMs, each capable of running up to 64 warps (2048 threads) concurrently. If half the blocks finish in 1 ms and the other half in 10 ms, then for 9 ms half the SMs (66 of 132) are completely idle. This is equivalent to losing ~33 trillion potential FLOPS per second — comparable to the performance of a previous-generation GPU.

By the time the longest work finishes, part of the GPU was already idle. This reduces achieved occupancy — the average fraction of active warps per SM relative to the hardware maximum. Remember that you can use Nsight Systems to profile and visualize these gaps on the GPU timeline.

To correlate timeline gaps with specific code regions, insert NVTX range markers around meaningful GPU work regions:

#include <nvtx3/nvToolsExt.h>

nvtxRangePush("computeKernel_phase1");
computeKernel<<<grid, block>>>(d_in, d_out, N);
cudaStreamSynchronize(stream);
nvtxRangePop();

Atomic counters: hardware basics

Atomic counters are the foundation for atomic queues that allow dynamic distribution of work.

On modern GPUs (Ampere, Hopper, Blackwell architectures) global atomic operations are serviced and serialized in the on-device L2 cache. This lowers latency compared to DRAM accesses when the target line is cached. Typical atomicAdd latency in L2 is 20–40 ns, while a DRAM-miss atomic operation is 200–400 ns. Atomic counters still incur latency and serialize under contention. But uncontended atomicAdd operations execute extremely fast, staying on-chip.

Figure 1. Atomic counter operations

Ultra-fast atomic operations in the GPU's L2 cache

T0
T1
T2
T3
T4
T5
T6
T7
8
COUNTER
atomicAdd threads L2-cache counter
Threads increment a single L2 counter — the basic primitive of dynamic queues

In the Memory Workload Analysis section of Nsight Compute you will find the atomic_transactions and atomic_transactions_per_request counters. The atomic-transactions counter represents the total number of L2 atomic transactions, including any replays caused by contention. The atomic-transactions-per-request metric, or contention ratio, represents the average number of L2 transactions generated per atomicAdd instruction.

When each atomicAdd performs exactly one L2 transaction, your atomic_transactions_per_request stays near 1.0 — minimal cost. If this ratio rises above 1.0, it signals that threads are stalling and replaying atomic updates instead of doing useful work. Each replay indicates contention.

Additional optimization: __reduce_add_sync for warp-level summation

Warp reduction __reduce_add_sync (sm_60+ for integer types, CUDA 9; redux.sync — sm_80+) performs warp-level sum reduction in a single instruction without needing atomicAdd:

unsigned mask = 0xffffffff;
int partial = my_value;
int warp_sum = __reduce_add_sync(mask, partial);
int lane = threadIdx.x & 31;
if (lane == 0) {
    atomicAdd(&global_counter, warp_sum);
}

This approach reduces global atomic operations by 32× compared to a per-thread scheme — without needing __shfl_sync and manual leader election.

Batching: amortizing atomic operations

The optimization here is to amortize atomic operations by capturing work in batches. Instead of every thread or warp doing atomicAdd, group several tasks per atomic operation. Here is a before/after example with batch size 32:

// Before batching
int idx = atomicAdd(&queue_head, 1);
if (idx < N) process(data[idx]);

// After batching
const int batchSize = 32;
int start = atomicAdd(&queue_head, batchSize);
for (int i = start; i < start + batchSize && i < N; ++i) {
    process(data[i]);
}

Now one atomic update hands a warp an entire slice of work — 32 elements — before touching the counter again. You still pay one L2 transaction per batch but do 32× more useful work between them.

In practice only one thread per warp issues this atomicAdd to obtain the starting index of the next batch. It then broadcasts it to the other lanes of the warp (e.g., via __shfl_sync). The whole warp processes those 32 elements in parallel. This gives one atomic operation per warp instead of one per thread, sharply reducing contention.

Warp-level dynamic queues

Let's now use a global atomic counter to coordinate a dynamic work queue. The goal is to use the atomic counter and atomicAdd to balance arbitrary workloads across all SMs so that no thread or warp goes idle.

In the following example (computeKernel) each thread computes a different number of iterations based on idx % 256. Threads with small idx % 256 do very little work; threads with large values do a lot. This imbalance causes threads to finish at different times, leaving some SMs idle while the longest threads finish:

// uneven_static.cu
#include <cuda_runtime.h>
#include <cmath>

__global__ void computeKernel(const float* input, float* output, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < N) {
        int work = idx % 256;
        float result = 0.0f;
        for (int i = 0; i < work; ++i) {
            result += sinf(input[idx]) * cosf(input[idx]);
        }
        output[idx] = result;
    }
}

In the optimized version with dynamic task dispatch, a single global counter (in device memory) serves as a warp-level task queue. We turn the counter into a persistent task queue using batched atomic operations. Warps that finish early immediately grab the next batch instead of idling:

// uneven_dynamic.cu
#include <cuda_runtime.h>

__device__ unsigned int globalIndex = 0;

__global__ void computeKernelDynamicBatch(const float* input,
                                          float* output,
                                          int N) {
    int lane = threadIdx.x & (warpSize - 1);

    while (true) {
        unsigned mask = __activemask();
        int leader = __ffs(mask) - 1;

        unsigned int base = 0;
        if (lane == leader) {
            base = atomicAdd(&globalIndex, warpSize);
        }

        base = __shfl_sync(mask, base, leader);
        unsigned int idx = base + lane;

        if (idx >= (unsigned)N) break;

        float s = sinf(input[idx]);
        float c = cosf(input[idx]);
        int work = idx % 256;
        float result = 0.0f;

        #pragma unroll 1
        for (int i = 0; i < work; ++i) {
            result += s * c;
        }

        output[idx] = result;
    }
}

Figure 2. Dynamic work queue

Atomic queue — balancing work between SMs and warps

1
2
3
4
5
6
7
8
atomicAdd
+8
SM 0
SM 1
SM 2
SM 3
Work = idx % 256 · dynamic distribution without SM idle time
Each warp atomically grabs the next batch of tasks from the global queue

Hierarchical and block-local queues

A single global queue works well for moderate imbalance, but under extreme contention (hundreds of active warps hitting one counter simultaneously) a hierarchical approach may be needed.

The idea: each thread block first processes a local task pool assigned to it at launch. When the local pool is exhausted, the block atomically requests the next chunk from the global queue. This is a two-level scheme:

__global__ void hierarchicalDynamicKernel(const float* input,
                                           float* output,
                                           int N,
                                           int chunkPerBlock) {
    extern __shared__ int blockOffset[];

    int tid = threadIdx.x;

    if (tid == 0) {
        blockOffset[0] = atomicAdd(&globalIndex, chunkPerBlock);
    }
    __syncthreads();

    int localStart = blockOffset[0];
    int processed = 0;

    while (true) {
        int localIdx = localStart + processed + tid;
        if (localIdx >= (unsigned)N) {
            if (tid == 0) {
                blockOffset[0] = atomicAdd(&globalIndex, chunkPerBlock);
            }
            __syncthreads();
            localStart = blockOffset[0];
            processed = 0;
            localIdx = localStart + tid;
            if (localIdx >= (unsigned)N) break;
        }
        // ... process localIdx ...
        processed += blockDim.x;
    }
}

With chunkPerBlock = 256 (one element per thread in a 256-thread block) and 4096 blocks per grid, the global atomicAdd runs 4096 times instead of 1 million times — a 244× reduction in contention.

Performance evaluation and profiling metrics

In a simple microbenchmark with N = 220 and work = idx % 256, the statically-assigned kernel took about 200 ms, while the dynamic-queue version ran in about 100 ms — a 2× speedup. For reproducibility: the test ran on NVIDIA H100 SXM (80 GB HBM3), CUDA 12.4, core clock 1830 MHz, nvcc -O3 -arch=sm_90.

MetricStatic versionDynamic version
Runtime (N = 1M)~200 ms~100 ms
Achieved occupancy~45%~78%
Active SM cycles~52%~85%
atomic_transactions_per_requestN/A~1.02
Number of atomicAdd on GPU0~32768

In Nsight Compute, check: Warp Stall Reasons — if Stall Long Scoreboard (stall_long_sb, memory wait) dominates, batching may exacerbate latency; Register Pressure — the additional while(true) loop increases register usage; Achieved Occupancy — if the dynamic version shows occupancy below 60%, the batch size may be too small.

Section II Summary
Key point

Atomic operations in L2 are extremely fast on modern GPUs. Batched increments reduce contention and balance irregular workloads.

Practical consequence

In Nsight Compute, check atomic_transactions_per_request (should be near 1.0), achieved occupancy, and warp stall reasons.

Limitation

For extreme imbalance, hierarchical queues are used. __reduce_add_sync (sm_60+, redux.sync sm_80+) reduces atomic operations by another 32×.

• • •

IIICUDA Graphs

Motivation: launch overhead

When your pipeline consists of many kernels, copies, stream event records, and callbacks, sequentially launching them on the host on each iteration carries CPU overhead. Typical single-kernel host-launch latency on a modern GPU is 5–10 µs. If your pipeline has 50 kernels (typical for inference engines), that is 250–500 µs of pure overhead per iteration — while the actual compute may only take 1–2 ms. Overhead accounts for 12–25% of total time.

CUDA Graphs let you capture the entire workflow once and replay it repeatedly with virtually no CPU overhead — typical replay latency for a 50-node graph is 2–5 µs, several-fold lower than sequential individual launches (up to 5–10× for short kernels, depending on workload).

Figure 3. CUDA Graphs launch timeline

Kernel launch comparison: without CUDA Graphs vs. with CUDA Graphs

Without CUDA Graphs 3 CPU launch calls + gaps
launch
Kernel A
gap
launch
Kernel B
gap
launch
Kernel C
With CUDA Graphs 1 graph replay · continuous
replay()
Kernel A
Kernel B
Kernel C
CUDA Graphs eliminate CPU scheduling gaps between kernels

Graph capture: stream and explicit API

CUDA Graphs support two ways to build a graph: stream capture and the explicit graph API.

Stream capture is the simplest approach:

cudaStream_t stream;
cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking);
cudaGraph_t graph;
cudaGraphExec_t instance;

cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);

kernelA<<<grid, block, 0, stream>>>(d_X);
kernelB<<<grid, block, 0, stream>>>(d_Y);
kernelC<<<grid, block, 0, stream>>>(d_Z);

cudaStreamEndCapture(stream, &graph);
cudaGraphInstantiate(&instance, graph, nullptr, nullptr, 0);

for (int iter = 0; iter < 100; ++iter) {
    cudaGraphLaunch(instance, stream);
}

cudaStreamSynchronize(stream);
cudaGraphExecDestroy(instance);
cudaGraphDestroy(graph);

Explicit construction gives finer control — you build the graph node by node, explicitly specifying dependencies:

cudaGraph_t graph;
cudaGraphCreate(&graph, 0);

cudaGraphNode_t nodeA, nodeB, nodeC;
cudaKernelNodeParams paramsA = {}, paramsB = {}, paramsC = {};

paramsA.func = (void*)kernelA;
paramsA.gridDim = grid;
paramsA.blockDim = block;

cudaGraphAddKernelNode(&nodeA, graph, nullptr, 0, &paramsA);

cudaGraphNode_t depsB[] = {nodeA};
cudaGraphAddKernelNode(&nodeB, graph, depsB, 1, &paramsB);

cudaGraphNode_t depsC[] = {nodeB};
cudaGraphAddKernelNode(&nodeC, graph, depsC, 1, &paramsC);

Memory pools and pointer stability

An important consideration is memory management with CUDA Graphs. Memory operations inside a CUDA Graph obey the same rules as in CUDA streams. If you allocate GPU memory inside capture, that allocation becomes part of the graph's execution.

Usually you want to avoid GPU memory allocation inside the graph and preallocate outside. Many frameworks like PyTorch use static memory pools with CUDA Graphs:

pool = torch.cuda.graph_pool_handle()
g1 = torch.cuda.CUDAGraph()
g2 = torch.cuda.CUDAGraph()

with torch.cuda.graph(g1, pool=pool):
    # ops for graph 1
    ...

with torch.cuda.graph(g2, pool=pool):
    # ops for graph 2
    ...

Dynamic graph update

After you've recorded a CUDA Graph you don't need to discard it just because launch parameters changed. The graph update API includes cudaGraphExecUpdate and the lower-level cudaGraphExecKernelNodeSetParams:

cudaKernelNodeParams newParams = {};
newParams.func = (void*)kernelB;
newParams.gridDim = dim3((batchSize + 255) / 256);
newParams.blockDim = dim3(256);
newParams.kernelParams = kernelArgs;

cudaGraphExecKernelNodeSetParams(instance, nodeB, &newParams);
cudaGraphLaunch(instance, stream);

PyTorch, inference engines and CUDA Graphs

AI frameworks like PyTorch use CUDA Graphs under the hood for static regions of deep-learning models:

import torch

X = torch.randn(1<<20, device='cuda')

def opA(x): return x * 1.1
def opB(x): return x + 2.0
def opC(x): return x.sqrt()

static_x = torch.empty_like(X)
static_y = torch.empty_like(X)
static_z = torch.empty_like(X)
static_w = torch.empty_like(X)

_ = opC(opB(opA(X)))
torch.cuda.synchronize()

static_x.copy_(X)

g = torch.cuda.CUDAGraph()
stream = torch.cuda.Stream()
torch.cuda.synchronize()

with torch.cuda.graph(g, stream=stream):
    torch.mul(static_x, 1.1, out=static_y)
    torch.add(static_y, 2.0, out=static_z)
    torch.sqrt(static_z, out=static_w)

for i in range(100):
    g.replay()
MetricBefore CUDA GraphsAfter CUDA Graphs
CPU launch calls per 100 iterations300 separate kernel launches100 graph replays
Host synchronization calls3000
Average GPU idle time between kernels~3 µs gaps per iteration0 µs (continuous execution)
End-to-end iteration latency~1.00 ms~0.75 ms (25% faster)

CUDA Graphs in tensor engines: Triton and CuDNN

Triton generates CUDA kernels that can be captured into graphs just like C++ kernels:

import triton
import triton.language as tl

@triton.jit
def triton_kernel(x_ptr, y_ptr, N):
    pid = tl.program_id(0)
    offsets = pid * 256 + tl.arange(0, 256)
    mask = offsets < N
    x = tl.load(x_ptr + offsets, mask=mask)
    tl.store(y_ptr + offsets, x * 1.1, mask=mask)

g = torch.cuda.CUDAGraph()
with torch.cuda.graph(g):
    triton_kernel[(grid,)](x_buf, y_buf, N)
g.replay()
Section III Summary
Key point

CUDA Graphs cut launch overhead several-fold — up to 5–10× for short kernels. Capture is possible via the stream API or explicit construction.

Practical consequence

PyTorch uses static memory pools for graphs. Triton kernels are also captured. Dynamic updates via cudaGraphExecUpdate avoid recapture.

Limitation

Memory must be preallocated outside the graph. Dynamic shapes require update or recreation of the graph.

• • •

IVDevice-initiated CUDA Graph launch

Motivation: removing the host from the critical path

With device-initiated CUDA Graph launch, a running GPU kernel can launch a pre-recorded graph directly on the device, completely bypassing the host.

To enable device launch, first capture the graph as usual on the host. Then instantiate it with the cudaGraphInstantiateFlagDeviceLaunch flag and upload it with cudaGraphUpload:

cudaGraphInstantiate(&graphExec, graph, nullptr, nullptr,
                     cudaGraphInstantiateFlagDeviceLaunch);
cudaGraphUpload(graphExec, stream);

Device-side graph launches remove host scheduling from the critical path. In practice they show roughly half the launch latency compared to equivalent host-side launches. Device-launch latency does not depend on the number of nodes or parallel branches in the graph.

Launch modes: fire-and-forget, tail, sibling

Fire-and-forget (cudaStreamGraphFireAndForget): the child graph starts running immediately and concurrently with the launching parent kernel. The parent does not wait for the child to finish. Limit: up to 120 concurrent fire-and-forget graphs.

Tail (cudaStreamGraphTailLaunch): graph execution is deferred until the launching kernel finishes. The graph is queued as a continuation. Limit: up to 255 pending tail launches; for self-tail-launch — only one at a time.

Tail launches are especially powerful for building GPU-resident schedulers. A persistent "scheduler" kernel launches a graph via tail, then re-launches itself:

__global__ void schedulerKernel(/* ... */) {
    cudaGraphExec_t self = cudaGetCurrentGraphExec();

    unsigned int taskIdx = atomicAdd(&queueHead, 1);
    if (taskIdx >= N_tasks) return;

    cudaGraphLaunch(decodingGraphExec, cudaStreamGraphTailLaunch);
    cudaGraphLaunch(self, cudaStreamGraphTailLaunch);
}

Sibling (cudaStreamGraphFireAndForgetAsSibling): the launched graph runs as a peer of the parent rather than as a child.

LLM inference resident scheduler

Consider a practical pattern combining atomic queues and CUDA Graphs with device-side tail launch to schedule LLM inference inside a persistent kernel.

A lightweight persistent scheduler kernel uses atomicAdd(&queueHead, 1) to grab the next work item. It then tail-launches a pre-captured decoding CUDA Graph and immediately returns to the next item in the queue.

By chaining tail launches, each token is processed end-to-end on the device. Since the CPU never returns to the critical path, SMs stay busy, per-token latency drops, and the system can adapt to different sequence lengths and batch sizes on the fly.

Debugging device-side graphs

Debugging device-launched graphs can be tricky. Recommendations:

  • Nsight Systems shows child graphs on the GPU timeline as separate work streams.
  • NVTX markers in the parent kernel before and after cudaGraphLaunch calls help mark where launches occur.
  • Device-side error checking: use cudaGetLastError() on device (available from CUDA 12.0).
  • Logging: printf on device works but can significantly slow execution.
Section IV Summary
Key point

Device-side graph launch removes the CPU from the critical path. Launch latency is halved and independent of graph size.

Modes

Fire-and-forget — immediate parallel launch. Tail — launch after parent completes. Sibling — peer launch.

Practical consequence

A resident scheduler with tail launch processes LLM tokens without returning to CPU. Debugging via Nsight Systems and NVTX.

• • •

VConditional graph nodes

Conditional node types: IF, IF/ELSE, WHILE, SWITCH

In traditional CUDA Graph every node and its dependencies are fixed at capture time, forcing all decision logic back to the host. Conditional graph nodes break this rigidity by deferring decisions to the GPU itself — based on a small "condition descriptor" attached to the node.

Figure 4. Conditional graph nodes

IF · IF/ELSE · WHILE · SWITCH — branching on the GPU without CPU

IF

once

Runs its body once when condition ≠ 0.

↓
body graph

IF/ELSE

branch

Selects one of two bodies based on condition.

↓
true body
false body

WHILE

loop

Repeats body while condition ≠ 0.

↻
body graph · re-check

SWITCH

select

Selects i-th body if condition = i; otherwise skips.

↓
body 0
body 1
body 2
Conditional nodes move branching logic to the GPU without CPU round-trips

Example of creating an IF conditional node:

__global__ void setHandle(cudaGraphConditionalHandle handle,
                          int *data, size_t N) {
    constexpr int threshold = 123456;
    unsigned int flag =
        (reduce_sum(data, N) > threshold) ? 1u : 0u;
    cudaGraphSetConditional(handle, flag);
}

__global__ void bodyKernel() {
    printf("Conditional body executed on GPU!\n");
}

Nested conditions and multi-level decisions

Conditional nodes can be nested. For example, a WHILE node's body may contain an IF node. This enables multi-level decision logic without CPU round-trips:

┌──────────────────────────────────────┐
│  WHILE (tasks remain)                 │
│  ┌──────────────────────────────────┐ │
│  │  IF (task type == "compression") │ │
│  │  ┌────────────┐ ┌──────────────┐ │ │
│  │  │ LZ compress│ │ Huff compress│ │ │
│  │  └────────────┘ └──────────────┘ │ │
│  └──────────────────────────────────┘ │
│  ┌──────────────────────────────────┐ │
│  │  ELSE IF (type == "decompression")│ │
│  │  ┌────────────────────────────┐  │ │
│  │  │  Decompression             │  │ │
│  │  └────────────────────────────┘  │ │
│  └──────────────────────────────────┘ │
└──────────────────────────────────────┘

Practical example: adaptive kernel selection

Consider a GPU compression pipeline where the kernel must choose between different algorithms based on data content:

__global__ void classifyData(cudaGraphConditionalHandle handle,
                               int* data, size_t N) {
    if (threadIdx.x == 0) {
        float entropy = compute_entropy(data, N);

        unsigned int algo;
        if (entropy < 2.0f)      algo = 0; // LZ compression
        else if (entropy < 5.0f) algo = 1; // Huffman
        else                    algo = 2; // no compression

        cudaGraphSetConditional(handle, algo);
    }
}
Section V Summary
Key point

Conditional nodes (IF, IF/ELSE, WHILE, SWITCH) enable branching and loops directly on the GPU without returning to the CPU.

Practical consequence

Decision logic is moved to the device. Conditions can be nested. Example: adaptive selection of a compression algorithm by data entropy.

Limitation

Conditional body graphs cannot contain dynamic parallelism or device-side graph launch. Cooperative launches are allowed only without MPS.

• • •

VIDynamic parallelism

When to use DP vs. a graph

Dynamic parallelism (DP) gives your GPU kernels the ability to spawn new work for themselves instead of waiting for the CPU. While CUDA Graphs require you to know the entire pipeline in advance, DP lets a running "parent" kernel examine its own output and decide on the fly how many "child" kernels to launch.

CriterionCUDA Graphs (device-launch)Dynamic parallelism
Control flow structureKnown in advanceEmerged from data at runtime
Launch overhead~5 µs (amortized)~20–25 µs per child launch
Shape flexibilityFixed shapes, updatable via APIArbitrary shapes
Launch limit120 fire-and-forget, 255 tail2048 pending (configurable)
Best fitRepeating pipelinesHierarchical, irregular tasks

Resource management: stack and pending launch count

Each child launch uses GPU scheduling resources and needs extra stack space. To avoid "stack overflow" errors, increase the stack size:

cudaDeviceSetLimit(cudaLimitStackSize, 1024);
cudaDeviceSetLimit(cudaLimitDevRuntimePendingLaunchCount, 8192);

Host-launched vs. device-launched comparison

DP with device-side launch:

// dp_device_launched.cu
// Compile with -rdc=true
__global__ void childKernel(float* data, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) data[idx] = data[idx] * data[idx];
}

__global__ void parentKernel(float* data, int n) {
    if (blockIdx.x == 0 && threadIdx.x == 0) {
        const int threadsPerBlock = 256;
        const int firstHalfCount = n / 2;
        const int secondHalfCount = n - firstHalfCount;
        const int blocksFirst = (firstHalfCount + threadsPerBlock - 1)
            / threadsPerBlock;
        const int blocksSecond = (secondHalfCount + threadsPerBlock - 1)
            / threadsPerBlock;

        childKernel<<<blocksFirst, threadsPerBlock>>>(data, firstHalfCount);
        childKernel<<<blocksSecond, threadsPerBlock>>>(
            data + firstHalfCount, secondHalfCount);
    }
}
MetricBefore (host-launched)After (device-launched)
Total host launches31
Average launch overhead~20 µs~25 µs
GPU idle cycles~40%~5%
Total runtime1.00 ms0.75 ms

DP + NVSHMEM: irregular tasks across the cluster

Dynamic parallelism is especially powerful when combined with NVSHMEM for irregular cluster-wide tasks. For example, in a graph traversal algorithm each GPU processes its part of the graph. When edges leading to vertices on other GPUs are discovered, the kernel can:

  1. Use NVSHMEM to put data on the remote GPU.
  2. Use DP to launch a child kernel to process the local part.
  3. The child kernel on the remote GPU activates on an NVSHMEM signal.
Section VI Summary
Key point

Dynamic parallelism allows kernels to spawn child kernels. GPU idle time drops from ~40% to ~5%.

Practical consequence

DP is used for hierarchical, irregular tasks. It combines with NVSHMEM for inter-node coordination. Stack size and pending launch limit must be configured.

Limitation

Each child launch carries ~20–25 µs overhead. For repeating pipelines, CUDA Graphs are preferable.

• • •

VIIMulti-GPU and cluster orchestration

Peer-to-peer copies and GPUDirect

When scaling from one GPU to many, the core goal remains the same: keep every device busy, hiding data movement behind useful work.

cudaMemcpyPeerAsync(dest_ptr, dest_gpu, src_ptr, src_gpu, size, comm_stream);
InterconnectBandwidth (per GPU)Latency
PCIe Gen5 x16~64 GB/s~5–10 µs
NVLink 3 (A100)~600 GB/s~1–2 µs
NVLink 4 (H100)~900 GB/s<1 µs
NVLink 4 (B200)~1800 GB/s<1 µs
InfiniBand NDR~50 GB/s~0.5–1 µs

NCCL: collectives and CUDA Graphs integration

When you need collective communication such as all-reduce of gradients in PyTorch Distributed Data Parallel (DDP), you run non-blocking NCCL operations on a separate stream:

ncclAllReduce(sendbuff, recvbuff, count, ncclFloat, ncclSum,
              comm, comm_stream);

NCCL ops can be captured into CUDA Graphs:

cudaStreamBeginCapture(comm_stream, cudaStreamCaptureModeGlobal);
ncclAllReduce(sendbuff, recvbuff, count, ncclFloat, ncclSum,
              comm, comm_stream);
cudaStreamEndCapture(comm_stream, &graph);

Figure 5. Overlapping all-reduce with compute

Bucketed gradient reduction overlaps with the backward pass

Process 1

Compute L1
Compute L2 ∥ AR-1
Compute L3
backward · all-reduce buckets

Process 2

Compute L1
Compute L2 ∥ AR-2
Compute L3
overlap hidden behind compute
AR bucket 1
AR bucket 2
AR bucket 3
AR bucket 4
Compute All-Reduce (overlapped)
Segmented all-reduce hides communication behind computation

NVSHMEM: one-sided PGAS communication

For workloads requiring ultra-dense, event-driven coordination, NVSHMEM treats each GPU as a processing element (PE) in a partitioned global address space (PGAS). A GPU can write directly to another GPU's memory from device code, bypassing the CPU.

Figure 6. NVSHMEM one-sided communication

GPU-initiated RMA: sender → receiver without CPU

PE 0 · Sender

data
flag

PE 1 · Receiver

data
flag
put
signal
nvshmem_float_p(remote_data, value, dest_pe);
nvshmem_quiet();
nvshmem_int_p(remote_flag, 1, dest_pe);
nvshmem_int_wait_until(remote_flag,
  NVSHMEM_CMP_EQ, 1);
float val = remote_data[1];
GPU/PE 0 writes data and a flag directly into GPU/PE 1's memory

The "send and signal" pattern:

#include <nvshmem.h>
#include <nvshmemx.h>

__device__ int *remote_flag;
__device__ float *remote_data;

__global__ void sender_kernel(float *local_data, int dest_pe) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    float value = local_data[idx];

    nvshmem_float_p(remote_data + 1, value, dest_pe);
    nvshmem_quiet();
    nvshmem_int_p(remote_flag + 0, 1, dest_pe);
}

__global__ void receiver_kernel(float *recv_buffer) {
    nvshmem_int_wait_until(remote_flag + 0, NVSHMEM_CMP_EQ, 1);
    float val = remote_data[1];
    recv_buffer[0] = val * 2.0f;
}

Work stealing across multiple GPUs

NVSHMEM atomic operations let each PE dynamically grab work from a global counter — implementing work stealing entirely on device:

__global__ void work_steal_kernel(/*...*/, int *queue_head, Task *tasks) {
    while (true) {
        int idx = nvshmem_int_atomic_inc(queue_head);
        if (idx >= N_tasks) break;
        // process tasks[idx]...
    }
}

Cooperative launches and barriers

For scenarios requiring minimal jitter, launch one cooperative kernel spanning all GPUs using nvshmemx_collective_launch():

__global__ void synchronized_step_kernel(/*...*/) {
    nvshmem_barrier_all();
    // all GPUs continue synchronously
}

N-GPU scaling pattern

Whether you use peer copies, NCCL rings, or NVSHMEM one-sided operations, the scaling pattern is always the same: the system must dispatch kernels once, pipeline and overlap data transfers with compute, and scale linearly — especially with the CPU host out of the critical path.

Figure 7. N-GPU parallel exchange

Each GPU computes and simultaneously exchanges data — no idle, no stall

GPU 0
compute + send
GPU 1
compute + recv
GPU 2
compute + send
GPU 3
compute + recv
GPU SMs Packets (RDMA/NVLink)
4 GPUs ideally behave like one GPU 4× faster

Scaling efficiency formula:

Speedup(N) = 1 / (f_compute/N + f_comm)

where f_compute is the compute fraction, f_comm is the communication fraction, N is the number of GPUs. With f_comm = 0.2 and N = 8 the speedup is 1/(0.1 + 0.2) = 3.3×, not 8×.

Section VII Summary
Key point

NVSHMEM provides one-sided put/get and on-device work stealing. NCCL ops are captured in graphs.

Practical consequence

Overlapping communication with compute via segmented all-reduce. NVSHMEM beats NCCL on small messages (8–256 KB — by 10–20×) but loses on large ones — choose by message size.

Limitation

Scaling efficiency is limited by the communication fraction. The Speedup(N) formula shows the practical ceiling.

• • •

VIIIRoofline-based scheduling decisions

Roofline basics

The Roofline model is a visual performance model that links achieved performance (FLOPS) to operational intensity (FLOPS/byte) and hardware limits.

Performance = min(Peak FLOPS, OI × Peak Memory BW)

Operational intensity (OI) is the number of floating-point operations per byte read from memory:

OI = FLOPs / Bytes accessed

For example, for a matrix multiply M × N × K: FLOPs = 2MNK, Bytes (FP16) = 2(MK + KN + MN). With M = N = K = 4096: OI ≈ 1365 FLOPS/byte.

H100 ridge point (FP16): 989 TFLOPS / 3.35 TB/s ≈ 295 FLOPS/byte. Matrix multiply (OI ~1365) sits far to the right — compute-bound. Element-wise add (OI ~0.083) sits far to the left — memory-bound.

Scenario A: memory-bound

When your kernel is memory-bound (OI < ridge point), performance is limited by memory bandwidth. Priorities:

  1. Kernel fusion — merge several element-wise ops into one kernel to raise OI.
  2. Overlap copies with compute — use CUDA streams to hide transfer latency.
  3. CUDA Graphs — batching ops lowers CPU overhead.
  4. Data placement — cudaMemcpyAsync with pinned memory.

Scenario B: compute-bound

When your kernel is compute-bound (OI > ridge point), performance is limited by peak compute:

  1. Lower launch overhead — CUDA Graphs with device-side launch.
  2. Dynamic parallelism — for adaptive algorithms.
  3. Dynamic scheduling — balance load via atomic queues.
  4. Tensor Core utilization — wmma or mma for FP16/BF16/FP8.

Scenario C: launch-latency-bound

When kernel runtime is comparable to launch overhead (< 50 µs):

  1. CUDA Graphs — capture the whole pipeline in one graph (2–5 µs instead of 250+ µs).
  2. Device-side graph launch — even lower latency (~2 µs).
  3. Conditional nodes — move branching to the GPU.
  4. Persistent kernels — a kernel that doesn't exit and waits for new work.

Scenario D: communication-bound

In multi-GPU systems, performance may be limited by interconnect bandwidth:

  1. Overlap communication and compute — separate streams for NCCL and compute.
  2. NVSHMEM — one-sided ops for fine-grained synchronization.
  3. Pipelining — split the batch into microbatches.
  4. NCCL in CUDA Graphs — eliminate CPU overhead.

Decision tree

                    ┌─ Bottleneck ─┐
                    │              │
            ┌── Memory ──┐    ┌── Compute ──┐
            │            │    │              │
     Kernel fusion   Overlap      CUDA Graphs   Dynamic
     + Graphs        copies       + DP          scheduling
                    │            │
              ┌── Launch ──┐    ┌── Communication ──┐
              │            │    │                     │
         Device-side    Conditional  NVSHMEM +       NCCL in Graphs
         graph launch   nodes        overlap         + pipelining
Section VIII Summary
Key point

The Roofline model determines whether a workload is memory-, compute-, launch-latency-, or communication-bound. Tool selection follows diagnosis.

Practical consequence

Each scenario has its own set of priority techniques. The decision tree helps choose the right tool.

Limitation

In practice, workloads may be limited by multiple factors simultaneously. Iterative profiling is required.

• • •

IXAdditional topics

Cooperative Groups and cooperative kernels

Cooperative Groups is a CUDA extension that provides more flexible synchronization primitives than __syncthreads(). Cooperative kernels launch a kernel that can synchronize at whole-grid level via cg::this_grid().sync():

#include <cooperative_groups.h>
namespace cg = cooperative_groups;

__global__ void cooperativeKernel(float* data, int N, int iterations) {
    cg::grid_group grid = cg::this_grid();

    for (int iter = 0; iter < iterations; ++iter) {
        int idx = blockIdx.x * blockDim.x + threadIdx.x;
        if (idx < N) data[idx] = process(data[idx]);
        grid.sync();
    }
}

void* args[] = {&d_data, &N, &iterations};
cudaLaunchCooperativeKernel((void*)cooperativeKernel,
                             dim3(grid), dim3(block), args, 0, stream);

Constraint: cooperative kernels require all blocks to be resident simultaneously. This limits maximum block count to max_blocks_per_SM × SM_count. On H100 that's 32 × 132 = 4224 blocks maximum.

Triton and high-level CUDA C++ alternatives

Triton is a GPU kernel language from OpenAI that compiles to efficient PTX/SASS. It provides high-level abstractions for block data, automatic memory management, and vectorization.

Example fused Triton kernel for scaled dot-product attention:

import triton
import triton.language as tl

@triton.jit
def flash_attention(Q, K, V, O, sm_scale,
                    N_ctx, BLOCK_M: tl.constexpr,
                    BLOCK_N: tl.constexpr, BLOCK_D: tl.constexpr):
    start_m = tl.program_id(0)
    off_hz = tl.program_id(1)
    off_qk = off_hz * N_ctx

    q = tl.load(Q + off_qk + start_m * BLOCK_M + ...)
    tl.store(O + off_qk + start_m * BLOCK_M + ..., result)

Stream-Ordered Memory Allocator

CUDA 11.2 introduced the stream-ordered memory allocator (cudaMallocAsync / cudaFreeAsync), which simplifies memory management in dynamic workloads and integrates with CUDA Graphs:

cudaStream_t stream;
cudaMallocAsync(&ptr, size, stream);
kernel<<<grid, block, 0, stream>>>(ptr);
cudaFreeAsync(ptr, stream);

Profiling device-side launches

Nsight Systems: child graphs appear as separate rows on the GPU timeline. Use NVTX markers. Enable --gpu-metrics-device=all.

Nsight Compute: child kernels launched via DP are profiled individually. Use --kernel-id.

Practical checklist for device-side launches:

  1. Ensure the graph is uploaded via cudaGraphUpload.
  2. Verify cudaGraphInstantiate was called with cudaGraphInstantiateFlagDeviceLaunch.
  3. Verify concurrent launch counts don't exceed limits.
  4. Check device stack usage.
  5. Ensure the host doesn't synchronize inside the device-side launch loop.

XKey takeaways

  • Dynamic scheduling with atomic queues in L2. Atomic ops in the L2 cache are extremely fast on modern GPUs. Use batched increments to balance irregular workloads. This can raise throughput up to 2× in extreme imbalance cases, but typically 10–30%.
  • CUDA Graphs for pipeline batching. Capture repeating sequences of kernels, copies and events into a single graph. This cuts launch overhead several-fold (up to 5–10× for short kernels) and eliminates CPU scheduling gaps.
  • Device-side orchestration. Launch work from the GPU itself via tail-launch CUDA Graphs or dynamic parallelism. Conditional nodes (IF, WHILE, SWITCH) move branching logic to the GPU.
  • Multi-processor overlap. Always overlap communication with compute. Use separate streams for peer-to-peer transfers, NCCL collectives, CUDA-aware MPI, or NVSHMEM one-sided ops.
  • NVSHMEM for fine-grained coordination. For irregular, data-dependent workloads NVSHMEM provides one-sided put/get ops and fine-grained signaling.
  • Roofline as a decision guide. Determine whether your workload is memory-, compute-, launch-latency-, or communication-bound. Pick the tool accordingly.

XIConclusion

In this chapter we went beyond single-kernel optimizations and explored end-to-end orchestration techniques. We saw how to run work entirely on the device via dynamic parallelism, capture complex workflows in CUDA Graphs, move branching logic via conditional nodes, and coordinate many GPUs with NCCL and NVSHMEM.

Each technique serves the same goal: keep feeding work to every engine, hide latency, and eliminate host-device gaps so your hardware runs at full capacity.

Modern NVIDIA platforms increasingly blur the CPU-GPU boundary. Grace Blackwell and Vera Rubin superchips connect CPUs to multiple GPUs with coherent NVLink at enormous bandwidth. In such systems the traditional "host manages, device computes" split gives way to a paradigm where GPUs manage their own pipeline — from scheduling and dispatch to synchronization and communication.

This is not just technological evolution — it is a programming-paradigm shift. Engineers who master device-side orchestration techniques will be able to extract performance unavailable to those staying within the host-managed model. The tools we covered — atomic queues, CUDA Graphs with device-side launch, conditional nodes, dynamic parallelism and NVSHMEM — are the building blocks for the next generation of pipelines, in which the GPU operates as an autonomous coprocessor rather than a subordinate peripheral.

• • •
Summary

GPU orchestration goes beyond the single kernel. Dynamic scheduling, CUDA Graphs, device-side launches, conditional nodes, dynamic parallelism and NVSHMEM are the building blocks for next-generation pipelines where the GPU works as an autonomous coprocessor. Mastering them lets you extract performance unreachable in the traditional host-managed model.