Настоящий материал представляет собой обзор главы 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 и открытых источниках. Цитаты приводятся в ограниченном объёме в целях обзора и критического анализа.
Глава 12 книги Fregly завершает блок, посвящённый низкоуровневой оптимизации CUDA. Если предыдущие главы были сосредоточены на внутриядерных техниках — арифметической интенсивности, Tensor Cores, специализации варпов и персистентных ядрах, — то глава 12 поднимается на уровень выше: как организовать параллельную работу между ядрами, между GPU и хостом, и между самими GPU. Ключевая идея главы: производительность GPU определяется не только плотностью загрузки SM, но и одновременной работой всех аппаратных подсистем — вычислительных SM, DMA-движков и интерконнектов.
«До сих пор мы занимались оптимизацией отдельных ядер — добивались максимальной пропускной способности вычислений и памяти на уровне одного запуска. Теперь настало время оркестрировать эти ядра так, чтобы графический процессор никогда не простаивал».
«В этой главе мы переходим от планирования на стороне хоста к планированию непосредственно на устройстве. Мы рассмотрим динамические очереди задач, управляемые быстрыми атомарными операциями в кэше L2, научимся сворачивать повторяющиеся запуски ядер и использовать CUDA Graphs для пакетирования фиксированных конвейеров и минимизации обращений к центральному процессору».
Глава делит материал на две взаимодополняющие части: механику динамического планирования и графов (первые разделы) и практические сценарии — условные узлы, динамический параллелизм и много-GPU оркестрацию (последующие разделы). В отличие от предыдущих глав, где рассматривались отдельные ядра, здесь акцент смещается на взаимодействие всех аппаратных подсистем GPU одновременно.
Отдельное внимание уделяется переходу от модели «хост управляет, устройство вычисляет» к модели, в которой GPU самостоятельно принимает решения о запуске следующей работы. Это принципиальное изменение парадигмы, которое становится возможным благодаря device-side запуску CUDA Graphs и динамическому параллелизму.
Неравномерное распределение работы между потоками может привести к тому, что одни 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 выполняются чрезвычайно быстро, оставаясь на кристалле.
Супербыстрые атомарные операции в кэше L2 GPU
В разделе 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 (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;
}
}
Атомарная очередь — балансировка работы между SM и warp
Одиночная глобальная очередь хорошо работает для умеренных дисбалансов, но при экстремальной конкуренции (сотни активных варпов одновременно обращаются к одному счётчику) может потребоваться иерархический подход.
Идея: каждый блок потоков сначала обрабатывает локальный пул задач, выделенный ему при запуске. Когда локальный пул исчерпан, блок атомарно запрашивает следующий чанк из глобальной очереди. Это двухуровневая схема:
__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_request | N/A | ~1,02 |
| Количество atomicAdd на GPU | 0 | ~32768 |
В Nsight Compute проверьте: Warp Stall Reasons — если преобладает Stall Long Scoreboard (stall_long_sb, ожидание памяти), пакетирование может усугубить задержку; Register Pressure — дополнительный цикл while(true) увеличивает использование регистров; Achieved Occupancy — если динамическая версия показывает заполняемость ниже 60%, возможно, размер пакета слишком мал.
Атомарные операции в L2 на современных GPU исключительно быстры. Пакетные инкременты снижают конкуренцию и балансируют нерегулярные нагрузки.
В Nsight Compute проверяются atomic_transactions_per_request (должен быть около 1,0), достигнутая заполняемость и warp stall reasons.
Для экстремальных дисбалансов применяются иерархические очереди. __reduce_add_sync (sm_60+, redux.sync sm_80+) снижает атомарные операции ещё в 32 раза.
Когда ваш конвейер состоит из множества ядер, копирований, записей событий потока и обратных вызовов, их последовательный запуск на хосте на каждой итерации влечёт накладные расходы CPU. Типичная задержка запуска одного ядра с хоста составляет 5–10 мкс на современном GPU. Если ваш конвейер содержит 50 ядер (типично для inference-движков), это 250–500 мкс чистых накладных расходов на итерацию — при том, что вычисления могут занимать всего 1–2 мс. Накладные расходы составляют 12–25% общего времени.
CUDA Graphs позволяют захватить весь рабочий процесс один раз и воспроизводить его повторно практически без накладных расходов CPU — типичная задержка воспроизведения графа из 50 узлов составляет 2–5 мкс, что в разы (до 5–10× при коротких ядрах, в зависимости от нагрузки) меньше, чем при последовательных индивидуальных запусках.
Сравнение запусков ядер: без CUDA Graphs vs. с CUDA Graphs
Зачем использовать CUDA Graphs: сокращение накладных расходов на запуск; лучшая планировка на GPU; зависимости известны заранее; корректное перекрытие асинхронных копирований и ядер.
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, ¶msA);
cudaGraphNode_t depsB[] = {nodeA};
cudaGraphAddKernelNode(&nodeB, graph, depsB, 1, ¶msB);
cudaGraphNode_t depsC[] = {nodeB};
cudaGraphAddKernelNode(&nodeC, graph, depsC, 1, ¶msC);
Важным соображением является управление памятью с 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);
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 воспроизведений графа |
| Вызовов синхронизации хоста | 300 | 0 |
| Средний простой GPU между ядрами | ~3 мкс разрывов на итерацию | 0 мкс (непрерывное выполнение) |
| Сквозная задержка итерации | ~1,00 мс | ~0,75 мс (на 25% быстрее) |
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()
CUDA Graphs сокращают накладные расходы запуска в разы — до 5–10× при коротких ядрах. Захват возможен как через потоковый API, так и через явное построение.
PyTorch использует статические пулы памяти для графов. Triton-ядра также захватываются. Динамическое обновление через cudaGraphExecUpdate позволяет избежать повторного захвата.
Память должна быть предварительно выделена вне графа. Динамические формы требуют обновления или пересоздания графа.
С помощью запуска CUDA Graph с инициированием с устройства работающее ядро GPU может запустить предварительно записанный граф непосредственно на устройстве, полностью минуя хост.
Чтобы включить запуск с устройства, сначала захватите граф как обычно на хосте. Затем создайте его экземпляр с флагом cudaGraphInstantiateFlagDeviceLaunch и загрузите с помощью cudaGraphUpload:
cudaGraphInstantiate(&graphExec, graph, nullptr, nullptr,
cudaGraphInstantiateFlagDeviceLaunch);
cudaGraphUpload(graphExec, stream);
Запуски графов с устройства убирают планирование хоста с критического пути. На практике они показывают примерно вдвое меньшую задержку запуска по сравнению с эквивалентными запусками со стороны хоста. Задержка запуска с устройства не зависит от количества узлов или параллельных ветвей в графе.
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): запущенный граф выполняется как равноправный с родительским графом, а не как дочерний.
Рассмотрим практический паттерн, объединяющий атомарные очереди и CUDA Graphs с device-side tail-запуском для планирования инференса LLM внутри постоянного ядра.
Лёгкое постоянное ядро-планировщик использует atomicAdd(&queueHead, 1) для захвата следующего элемента работы. Затем оно запускает через tail предварительно захваченный CUDA Graph декодирования и немедленно возвращается к следующему элементу в очереди.
Связывая tail-запуски в цепочку, каждый токен обрабатывается от начала до конца на устройстве. Поскольку CPU никогда не возвращается на критический путь, SM остаются загруженными, задержка на токен снижается, и можно адаптироваться к разным длинам последовательностей и размерам батчей на лету.
Отладка графов, запущенных с устройства, может быть сложной. Рекомендации:
cudaGraphLaunch помогают отметить, где происходят запуски.cudaGetLastError() внутри устройства (доступно с CUDA 12.0).printf внутри устройства работает, но может существенно замедлить выполнение.Device-side запуск графов убирает CPU с критического пути. Задержка запуска вдвое ниже хостовой и не зависит от размера графа.
Fire-and-forget — немедленный параллельный запуск. Tail — запуск после завершения родителя. Sibling — равноправный запуск.
Резидентный планировщик с tail-запуском позволяет обрабатывать токены LLM без возврата на CPU. Отладка ведётся через Nsight Systems и NVTX.
В традиционном CUDA Graph каждый узел и его зависимости фиксированы на момент захвата, что вынуждает всю логику принятия решений возвращать на хост. Условные узлы графа ломают эту жёсткость, откладывая принятие решений на сам GPU — на основе небольшого «дескриптора условия», связанного с узлом.
IF · IF/ELSE · WHILE · SWITCH — ветвление прямо на GPU без CPU
Выполняет тело один раз, если condition ≠ 0.
Выбирает одно из двух тел в зависимости от condition.
Повторяет тело, пока condition ≠ 0.
Выбирает i-е тело, если condition = i; иначе пропускает.
Пример создания условного узла 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);
}
}
Условные узлы (IF, IF/ELSE, WHILE, SWITCH) позволяют реализовать ветвление и циклы прямо на GPU без возврата на CPU.
Логика принятия решений переносится на устройство. Условия могут быть вложенными. Пример: адаптивный выбор алгоритма сжатия по энтропии данных.
Тела условных узлов не могут содержать динамический параллелизм или device-side запуск графов. Cooperative launches разрешены только без MPS.
Динамический параллелизм (DP) даёт вашим ядрам GPU способность порождать новую работу для себя, вместо того чтобы ждать CPU. В то время как CUDA Graphs требуют, чтобы вы знали весь конвейер заранее, DP позволяет работающему «родительскому» ядру исследовать свои выходные данные и решать на лету, сколько «дочерних» ядер запустить.
| Критерий | CUDA Graphs (device-launch) | Динамический параллелизм |
|---|---|---|
| Структура потока управления | Заранее известна | Возникает из данных во время выполнения |
| Накладные расходы на запуск | ~5 мкс (амортизированные) | ~20–25 мкс на каждый дочерний запуск |
| Гибкость формы | Фиксированные формы, обновляемые через API | Произвольные формы |
| Лимит запусков | 120 fire-and-forget, 255 tail | 2048 pending (настраиваемый) |
| Лучшее применение | Повторяющиеся конвейеры | Иерархические, нерегулярные задачи |
Каждый дочерний запуск использует ресурсы планирования GPU и требует дополнительного пространства стека. Чтобы избежать ошибок «переполнения стека», увеличьте размер стека:
cudaDeviceSetLimit(cudaLimitStackSize, 1024);
cudaDeviceSetLimit(cudaLimitDevRuntimePendingLaunchCount, 8192);
Версия с 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);
}
}
| Метрика | До (запуск с хоста) | После (запуск с устройства) |
|---|---|---|
| Всего запусков с хоста | 3 | 1 |
| Средние накладные расходы на запуск | ~20 мкс | ~25 мкс |
| Циклы простоя GPU | ~40% | ~5% |
| Общее время выполнения | 1,00 мс | 0,75 мс |
Динамический параллелизм особенно мощен в сочетании с NVSHMEM для нерегулярных задач в кластере. Например, в алгоритме обхода графа каждый GPU обрабатывает свою часть графа. Когда обнаруживаются рёбра, ведущие к вершинам на других GPU, ядро может:
Динамический параллелизм позволяет ядрам порождать дочерние ядра. Простоя GPU снижается с ~40% до ~5%.
DP применяется для иерархических, нерегулярных задач. Комбинируется с NVSHMEM для межузловой координации. Требуется настройка стека и лимита pending-запусков.
Каждый дочерний запуск несёт накладные расходы ~20–25 мкс. Для повторяющихся конвейеров лучше подходят CUDA Graphs.
Когда вы масштабируетесь с одного 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 мкс |
Когда вам нужна коллективная коммуникация, такая как 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);
Bucketed gradient reduction перекрывается с backward pass
Для рабочих нагрузок, требующих сверхплотной, управляемой событиями координации, NVSHMEM рассматривает каждый GPU как обрабатывающий элемент (PE) в разделяемом глобальном адресном пространстве (PGAS). GPU может напрямую писать в память другого GPU из кода устройства, минуя CPU.
GPU-initiated RMA: sender → receiver без участия CPU
Паттерн «отправить и сигнализировать»:
#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;
}
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 продолжают синхронно
}
Независимо от того, используете ли вы peer-копирования, кольца NCCL или односторонние операции NVSHMEM, паттерн масштабирования всегда один и тот же: система должна однократно диспетчеризовать ядра, конвейеризовать и перекрывать передачи данных с вычислениями и масштабироваться линейно — особенно с хостом CPU вне критического пути.
Каждый GPU вычисляет и одновременно обменивается данными — без idle и без застоя
Формула эффективности масштабирования:
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×.
NVSHMEM предоставляет односторонние put/get и work stealing на устройстве. NCCL-операции захватываются в графы.
Перекрытие коммуникации с вычислениями через сегментированный all-reduce. NVSHMEM выигрывает у NCCL на малых сообщениях (8–256 КБ — в 10–20 раз), но проигрывает на больших — выбирайте по размеру сообщения.
Эффективность масштабирования ограничена долей коммуникации. Формула Speedup(N) показывает практический потолок.
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) — далеко слева — ограничение памятью.
Когда ваше ядро ограничено памятью (OI < ridge point), производительность ограничена пропускной способностью памяти. Приоритеты:
cudaMemcpyAsync с pinned memory.Когда ваше ядро ограничено вычислениями (OI > ridge point), производительность ограничена пиковой вычислительной мощностью:
wmma или mma для FP16/BF16/FP8.Когда время выполнения ядра сопоставимо с накладными расходами запуска (< 50 мкс):
В многопроцессорных системах производительность может быть ограничена пропускной способностью межсоединений:
┌─ Узкое место ─┐
│ │
┌── Память ──┐ ┌── Вычисления ──┐
│ │ │ │
Слияние ядер Перекрытие CUDA Graphs Динамическое
+ Graphs копирований + DP планирование
│ │
┌── Запуск ──┐ ┌── Коммуникация ──┐
│ │ │ │
Device-side Условные NVSHMEM + NCCL в Graphs
запуск графов узлы перекрытие + конвейеризация
Roofline-модель определяет, ограничена ли нагрузка памятью, вычислениями, задержкой запуска или коммуникацией. Выбор инструмента следует из диагностики.
Для каждого сценария существует свой набор приоритетных техник. Дерево решений помогает выбрать правильный инструмент.
На практике нагрузка может ограничиваться несколькими факторами одновременно. Требуется итеративное профилирование.
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 — это язык программирования 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)
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);
Nsight Systems: дочерние графы отображаются как отдельные строки на временной шкале GPU. Используйте NVTX-маркеры. Включите --gpu-metrics-device=all.
Nsight Compute: дочерние ядра, запущенные через DP, профилируются индивидуально. Используйте --kernel-id.
Практический чек-лист для device-side запусков:
cudaGraphUpload.cudaGraphInstantiate вызван с флагом cudaGraphInstantiateFlagDeviceLaunch.В этой главе мы вышли за пределы оптимизаций отдельных ядер и исследовали сквозные техники оркестрации. Мы рассмотрели, как запускать работу полностью на устройстве с помощью динамического параллелизма, захватывать сложные рабочие процессы в 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 работает как самостоятельный сопроцессор. Освоив их, вы сможете извлекать производительность, недоступную в рамках традиционной хост-управляемой модели.
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.
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.
«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».
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.
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 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.
Ultra-fast atomic operations in the GPU's L2 cache
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.
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.
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.
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;
}
}
Atomic queue — balancing work between SMs and warps
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.
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.
| Metric | Static version | Dynamic version |
|---|---|---|
| Runtime (N = 1M) | ~200 ms | ~100 ms |
| Achieved occupancy | ~45% | ~78% |
| Active SM cycles | ~52% | ~85% |
| atomic_transactions_per_request | N/A | ~1.02 |
| Number of atomicAdd on GPU | 0 | ~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.
Atomic operations in L2 are extremely fast on modern GPUs. Batched increments reduce contention and balance irregular workloads.
In Nsight Compute, check atomic_transactions_per_request (should be near 1.0), achieved occupancy, and warp stall reasons.
For extreme imbalance, hierarchical queues are used. __reduce_add_sync (sm_60+, redux.sync sm_80+) reduces atomic operations by another 32×.
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).
Kernel launch comparison: without CUDA Graphs vs. with CUDA Graphs
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, ¶msA);
cudaGraphNode_t depsB[] = {nodeA};
cudaGraphAddKernelNode(&nodeB, graph, depsB, 1, ¶msB);
cudaGraphNode_t depsC[] = {nodeB};
cudaGraphAddKernelNode(&nodeC, graph, depsC, 1, ¶msC);
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
...
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);
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()
| Metric | Before CUDA Graphs | After CUDA Graphs |
|---|---|---|
| CPU launch calls per 100 iterations | 300 separate kernel launches | 100 graph replays |
| Host synchronization calls | 300 | 0 |
| Average GPU idle time between kernels | ~3 µs gaps per iteration | 0 µs (continuous execution) |
| End-to-end iteration latency | ~1.00 ms | ~0.75 ms (25% faster) |
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()
CUDA Graphs cut launch overhead several-fold — up to 5–10× for short kernels. Capture is possible via the stream API or explicit construction.
PyTorch uses static memory pools for graphs. Triton kernels are also captured. Dynamic updates via cudaGraphExecUpdate avoid recapture.
Memory must be preallocated outside the graph. Dynamic shapes require update or recreation of the graph.
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.
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.
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-launched graphs can be tricky. Recommendations:
cudaGraphLaunch calls help mark where launches occur.cudaGetLastError() on device (available from CUDA 12.0).printf on device works but can significantly slow execution.Device-side graph launch removes the CPU from the critical path. Launch latency is halved and independent of graph size.
Fire-and-forget — immediate parallel launch. Tail — launch after parent completes. Sibling — peer launch.
A resident scheduler with tail launch processes LLM tokens without returning to CPU. Debugging via Nsight Systems and NVTX.
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.
IF · IF/ELSE · WHILE · SWITCH — branching on the GPU without CPU
Runs its body once when condition ≠ 0.
Selects one of two bodies based on condition.
Repeats body while condition ≠ 0.
Selects i-th body if condition = i; otherwise skips.
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");
}
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 │ │ │
│ │ └────────────────────────────┘ │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────┘
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);
}
}
Conditional nodes (IF, IF/ELSE, WHILE, SWITCH) enable branching and loops directly on the GPU without returning to the CPU.
Decision logic is moved to the device. Conditions can be nested. Example: adaptive selection of a compression algorithm by data entropy.
Conditional body graphs cannot contain dynamic parallelism or device-side graph launch. Cooperative launches are allowed only without MPS.
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.
| Criterion | CUDA Graphs (device-launch) | Dynamic parallelism |
|---|---|---|
| Control flow structure | Known in advance | Emerged from data at runtime |
| Launch overhead | ~5 µs (amortized) | ~20–25 µs per child launch |
| Shape flexibility | Fixed shapes, updatable via API | Arbitrary shapes |
| Launch limit | 120 fire-and-forget, 255 tail | 2048 pending (configurable) |
| Best fit | Repeating pipelines | Hierarchical, irregular tasks |
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);
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);
}
}
| Metric | Before (host-launched) | After (device-launched) |
|---|---|---|
| Total host launches | 3 | 1 |
| Average launch overhead | ~20 µs | ~25 µs |
| GPU idle cycles | ~40% | ~5% |
| Total runtime | 1.00 ms | 0.75 ms |
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:
Dynamic parallelism allows kernels to spawn child kernels. GPU idle time drops from ~40% to ~5%.
DP is used for hierarchical, irregular tasks. It combines with NVSHMEM for inter-node coordination. Stack size and pending launch limit must be configured.
Each child launch carries ~20–25 µs overhead. For repeating pipelines, CUDA Graphs are preferable.
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);
| Interconnect | Bandwidth (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 |
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);
Bucketed gradient reduction overlaps with the backward pass
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.
GPU-initiated RMA: sender → receiver without CPU
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;
}
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]...
}
}
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
}
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.
Each GPU computes and simultaneously exchanges data — no idle, no stall
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×.
NVSHMEM provides one-sided put/get and on-device work stealing. NCCL ops are captured in graphs.
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.
Scaling efficiency is limited by the communication fraction. The Speedup(N) formula shows the practical ceiling.
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.
When your kernel is memory-bound (OI < ridge point), performance is limited by memory bandwidth. Priorities:
cudaMemcpyAsync with pinned memory.When your kernel is compute-bound (OI > ridge point), performance is limited by peak compute:
wmma or mma for FP16/BF16/FP8.When kernel runtime is comparable to launch overhead (< 50 µs):
In multi-GPU systems, performance may be limited by interconnect bandwidth:
┌─ 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
The Roofline model determines whether a workload is memory-, compute-, launch-latency-, or communication-bound. Tool selection follows diagnosis.
Each scenario has its own set of priority techniques. The decision tree helps choose the right tool.
In practice, workloads may be limited by multiple factors simultaneously. Iterative profiling is required.
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 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)
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);
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:
cudaGraphUpload.cudaGraphInstantiate was called with cudaGraphInstantiateFlagDeviceLaunch.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.
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.