Graph updates and conditional nodes
Here is the convergence loop, as this course has written it since day 34:
do {
cudaGraphLaunch(exec, stream); // day 56's fix
cudaMemcpy(&r, d_residual, 4, cudaMemcpyDeviceToHost);
} while (r > tol);
Day 56 already grouped the launches into one graph, but the loop still has the wait shown on day 41: that four-byte copy cannot return until everything queued before it finishes. The host must wait once per iteration, deciding something the device already knows. By the end of this page the whole loop is one launch, the decision runs on the device, and you can change a parameter frozen inside the graph without rebuilding it.
A loop the device runs
Since CUDA 12.4, a graph can contain a conditional node: a node that owns a body graph and decides at run time, on the device, whether to run it. The WHILE flavour re-evaluates after every pass, so a body that holds one solver iteration becomes a loop with no host in it. CUDA 12.8 added IF-ELSE and SWITCH flavours; everything on this page needs only 12.4 (https://developer.nvidia.com/blog/dynamic-control-flow-in-cuda-graphs-with-conditional-nodes/ , checked 2026-09-01).
The API has three parts. A handle, created against the parent graph
with cudaGraphConditionalHandleCreate, is the variable the condition
reads. The node is added with cudaGraphAddNode and
cudaGraphNodeTypeConditional; creating it returns its empty body
graph in phGraph_out[0], and capturing into that body with
cudaStreamBeginCaptureToGraph adds the kernels.
A kernel in
the body calls the device function cudaGraphSetConditional(handle, value)
to set the next evaluation: nonzero runs the body again, zero stops.
One default makes the shape. Created with defaultLaunchValue = 1 and
cudaGraphCondAssignDefault, the handle resets to 1 on every launch of
the parent graph, so the body always runs at least once and the node is a
do-while. That matches a solver, which cannot know its residual before the
first sweep, and it matches the host loop this page replaces, so the two
can be required to agree.
Hardware. Conditional nodes need CUDA 12.4+ and, on Linux, driver 550.54.14 or newer (https://docs.nvidia.com/cuda/cuda-toolkit-release-notes/index.html#cuda-driver , checked 2026-09-01); any card this course supports runs them, including the sm_75 floor. On an older toolkit the fallback is this page's strategy 2, the host-checked loop with one graph launch per iteration, and the program compiles the conditional path out and says so rather than failing to build.
Diagram: three ways to stop a solver, on one timeline. Three horizontal bands, each a CUDA API row above a GPU kernel row, one shared time axis, iterations marked by ticks. Band 1, host loop: per iteration, four short launch bars, then a
cudaMemcpybar spanning the kernels queued before it, then a gap on the kernel row. Caption "4 launches, 1 round trip, 1 gap, every iteration." Band 2, graph per iteration: onecudaGraphLaunchbar, the samecudaMemcpybar and the same gap. Caption "1 launch, still 1 round trip." Band 3, conditional WHILE: a singlecudaGraphLaunchbar at the left, then an unbroken kernel row to the end, no copies. Caption "1 launch, 0 round trips, the iteration count decided on the device." Alt text: "Three timelines of one Jacobi solve. A host-checked loop pays one blocking four-byte copy per iteration and the kernel row shows a gap each time. A conditional WHILE graph launches once and the kernel row runs unbroken."
What a graph freezes, and what it does not
A graph stores some values but follows pointers to memory. This distinction depends on whether a kernel argument is passed by value or through a pointer.
A kernel argument passed by value is copied into the node's parameters at capture, so the instantiated executable keeps the old value however often you edit the variable that supplied it. Memory is the opposite: a graph records pointers, not contents, so anything a kernel reads through a pointer can change between launches with no update at all. The residual, iteration counter, and solution buffers in this program change through pointers.
A scalar that must change on each launch can also live behind a pointer, which avoids a graph update.
When a stored value must change, cudaGraphExecUpdate patches
the instantiated executable in place. You hand it a second graph with
identical topology and new parameters, and it checks compatibility and
applies the change, skipping instantiation: "Whole graph update allows the
user to supply a topologically identical cudaGraph_t object whose nodes
contain updated parameters"
(https://docs.nvidia.com/cuda/cuda-programming-guide/04-special-topics/cuda-graphs.html
, section 4.2.3, checked 2026-09-01). Prediction 4 tests whether skipping
instantiation saves time on this small graph.
The result is stored in a
cudaGraphExecUpdateResultInfo that you must read, because a failed
update returns an error and leaves the old parameters running.
Deeper. Before conditional nodes, device-driven control flow meant dynamic parallelism, kernels launching kernels. It still exists (https://docs.nvidia.com/cuda/cuda-programming-guide/04-special-topics/dynamic-parallelism.html , checked 2026-09-01), and in 2026 it is rarely the right answer: a convergence loop wants a conditional graph, a pipeline wants streams or a graph, and a truly data-dependent grid usually wants a second launch. This course teaches it as one paragraph on purpose.
One solver, three deciders
Full program in
code/day57-graph-update/graph_update.cu.
The comparison is fair for three reasons.
Only the decision mechanism moves. All three strategies run the same
two Jacobi sweeps and the same residual reduction per iteration, in the
same order, from the same zeroed start. The program requires the three
iteration counts to match exactly and the three final states to be
bit-identical, and it checks the true |A x - b| residual in double on
the host, so three strategies agreeing on a wrong answer still fail.
Every gate is a branch, and the loop has a cap. The device-side decision kernel enforces a 500-iteration ceiling as well as the tolerance, because a WHILE body with a bug in its condition is a loop with no ctrl-C.
Events time it, the timeline explains it.
Whole solves are timed with CUDA events, mean of ten after three warm-ups,
and every phase carries an NVTX range so
Nsight Systems can put widths on
instantiate, recapture and exec-update.
The conditional graph is built in one function: handle, node, then a capture into the node's body graph on a created stream:
CUDA 13 added dependencyData to cudaGraphAddNode; CUDA 12.6 still uses
the earlier five-argument form. The small CUDART_VERSION gate below keeps
one source compatible with both signatures. The CUDA 13 run and a fresh
CUDA 12.6 compatibility run both pass every gate.
cudaGraphConditionalHandle handle;
CUDA_CHECK(cudaGraphConditionalHandleCreate(&handle, graph, 1,
cudaGraphCondAssignDefault));
cudaGraphNodeParams params = {};
params.type = cudaGraphNodeTypeConditional;
params.conditional.handle = handle;
params.conditional.type = cudaGraphCondTypeWhile;
params.conditional.size = 1;
cudaGraphNode_t node = nullptr;
#if CUDART_VERSION >= 13000
CUDA_CHECK(cudaGraphAddNode(&node, graph, nullptr, nullptr, 0, ¶ms));
#else
CUDA_CHECK(cudaGraphAddNode(&node, graph, nullptr, 0, ¶ms));
#endif
// The body graph is owned by the conditional node; capturing into it is
// how kernels get inside. It is never instantiated or destroyed here.
cudaGraph_t body = params.conditional.phGraph_out[0];
cudaStream_t stream;
CUDA_CHECK(cudaStreamCreate(&stream));
CUDA_CHECK(cudaStreamBeginCaptureToGraph(stream, body, nullptr, nullptr, 0,
cudaStreamCaptureModeRelaxed));
enqueueSweeps(d_x, d_y, d_b, d_partials, omega, stream);
decideAndCount<<<1, kReduceBlocks, 0, stream>>>(d_partials, d_residual,
d_iterations, kTolerance,
kMaxIterations, handle);
CUDA_CHECK(cudaStreamEndCapture(stream, nullptr));
CUDA_CHECK(cudaStreamDestroy(stream));
The decision itself is eight lines at the end of the body's last kernel, after a reduction that is line for line the one the host-checked strategies run, which is what entitles the program to demand identical iteration counts:
if (tid == 0) {
residual[0] = tile[0];
const int done = *iterations + 1;
*iterations = done;
const unsigned int keepGoing =
(tile[0] > tol && done < maxIterations) ? 1u : 0u;
cudaGraphSetConditional(handle, keepGoing);
}
And the update pass changes the relaxation weight from 1.0 to 0.7 in the
already-instantiated per-iteration graph, by capturing the same four
launches again with the new value and handing the result to
cudaGraphExecUpdate:
nvtxRangePushA("recapture");
cudaGraph_t updatedGraph = captureIterationGraph(d_x, d_y, d_b, d_partials,
d_residual, kOmegaSecond);
nvtxRangePop();
nvtxRangePushA("exec-update");
cudaGraphExecUpdateResultInfo info = {};
CUDA_CHECK(cudaGraphExecUpdate(iterationExec, updatedGraph, &info));
nvtxRangePop();
CUDA_CHECK(cudaGraphDestroy(updatedGraph));
if (info.result != cudaGraphExecUpdateSuccess) {
std::fprintf(stderr, "cudaGraphExecUpdate result %d, not success\n",
static_cast<int>(info.result));
ok = false;
}
At omega 0.7 the solve converges more slowly on purpose. A changed iteration count is the one observable that proves the update reached the kernel's frozen argument, and the program fails if the count does not move.
Results
Re-verified 2026-09-02 on the same Tesla T4 with driver 580.173.02 and
CUDA 13.0 (V13.0.88); the program printed conditional nodes: available (CUDART 13000). A same-day CUDA 12.6 compatibility run printed CUDART
12060 and passed after the source gained the version gate for CUDA 13's
new cudaGraphAddNode dependency-data parameter.
The original and both fresh transcripts, plus the CUDA 13 report, are listed
in front matter. The published CUDA 12.6 capture and CSV exports are at
content/profiles/graph-updates/: day57-graph.nsys-rep with
day57-graph_nvtx_sum.csv, day57-graph_cuda_api_sum.csv,
day57-graph_cuda_api_trace.csv and day57-graph_cuda_gpu_kern_sum.csv.
| Strategy | Iterations | Total (ms) | Per iteration (ms) |
|---|---|---|---|
| host-loop-sync | 26 | 14.409 | 0.554 |
| graph-per-iteration | 26 | 14.024 | 0.539 |
| conditional-graph | 26 | 13.881 | 0.534 |
Claims the transcript will grade
- All three strategies report the same iteration count, between 15 and 40, and the three final states are bit-identical. The count is set by the Jacobi contraction factor, 0.8 per sweep at omega 1.0, and by nothing the decider touches. After the update to omega 0.7 the count grows by a factor between 1.2 and 2.
- In the
cuda_api_traceexport, the conditional strategy's solves make zerocudaMemcpycalls: between eachcudaGraphLaunchof the conditional graph and the synchronize that follows it, the trace shows no copy at all. The host-loop solves show onecudaMemcpyper iteration, each spanning the kernels queued before it, exactly day 41's four-byte container bar. - The ordering is host-loop-sync slowest, conditional-graph fastest, with the whole spread inside 15 percent, because one iteration here is roughly half a millisecond of kernel work and a round trip costs tens of microseconds. If the conditional graph wins by more than 30 percent, my cost model of the round trip is wrong, which would be the more interesting result.
- The
exec-updateNVTX range is narrower thaninstantiate. That is the claim in the API's name. If it is not, updating buys convenience and not time at this graph size, and the page will say so.
What the run said
- Held. All three strategies stopped at 26 iterations with residual
6.914e-06, inside the 15 to 40 band, and the bit-identity gate passed.
After
cudaGraphExecUpdatemoved omega to 0.7, the count grew to 36, a factor of 1.38, inside the predicted 1.2 to 2. - Held, and countable in the published CUDA 12.6 trace. In
cuda_api_tracethere are exactly 14 launch-to-synchronize windows containing zerocudaMemcpycalls, and they are the conditional graph's 14 whole-solve launches (three warm-ups, ten timed, one correctness). Every other launch-to-sync window in the capture, all 364 of them, carries at least one copy: the four-byte residual crossing per iteration. - Held. host-loop-sync slowest at 14.409 ms, conditional-graph fastest at 13.881, a spread of 3.8 percent, inside the 15 percent bound. At half a millisecond of kernel work per iteration, the round trips this page deletes were never the bill.
- Held in the published CUDA 12.6 capture. The
exec-updateNVTX range spans 12,290 ns againstinstantiate's 110,150: updating the frozen graph cost a ninth of building it.
The timeline matters more than the measured milliseconds. The conditional kernel row has no gaps or API calls between launch and synchronize. This removes a fixed cost that matters more as kernels get shorter and launch overhead stays fixed.
Run it yourself
Use a CUDA 12.4 or newer toolkit for the conditional path. From the README:
nvcc -std=c++17 -O3 -arch=sm_75 -lineinfo -o graph_update graph_update.cu
nsys profile -t cuda,nvtx -o profile/day57-graph ./graph_update
On CUDA 12.0 to 12.3 the same file builds with strategy 3 compiled out
and the update pass intact. If you cannot run nsys, read
/setup/learn-cuda-without-a-gpu and
work from the shipped reports.
Exercise
Checking the residual costs two reduction kernels per decision, and the device loop makes it cheap to check less often. Change the decision cadence to four double-sweeps per residual check, in all three strategies, so the gates still have three like-for-like solves to compare. Report both timing tables, before and after.
Time: 30 to 45 minutes. Submit: your modified graph_update.cu,
the two conditional-graph rows, and one sentence on when checking less
often would be wrong.
Check: the program's own gates grade you, and they must all hold:
your version prints all gates passed and exits 0, or it names the gate
that tripped on stderr, with the iteration counts or the measured
|A x - b|_inf next to its printed bound. On top of that,
cuda_gpu_kern_sum from your own capture must show about one
residualPartial launch per four relaxJacobi pairs; if it shows one
per pair, you changed a constant somewhere without changing the graphs.
Hint 1
The body graph is a graph like any other, and nothing says it must hold one iteration. What exactly does the WHILE node re-run, and which lines in this program decide what one pass of each strategy contains?
Hint 2
The change is one loop of four around the sweep pair, in three places:
the host loop, the captured iteration graph and the conditional body.
Then look at decideAndCount: its counter now has to advance by four per
pass, or the 500 ceiling stops binding where the host strategies stop.
Solution
Wrap enqueueSweeps in a for loop of four inside solveHostLoop,
inside captureIterationGraph's capture and inside
buildConditionalGraph's capture, and make decideAndCount add 4 to the
counter, with the host loops counting the same way. All three strategies
now overshoot the tolerance by up to three double-sweeps, but they
overshoot identically, so the counts still match, the states are still
bit-identical, and the |A x - b| gate passes with more room than
before, because the extra sweeps tightened the answer.
A device-side loop lets you choose how often to check convergence. Measure the cost of the check kernels against the sweeps between them. The best interval depends on the GPU and workload.
Pitfalls
Your WHILE body never runs. The handle was created with the default
value 0 and nothing upstream set it, so the condition is false on entry
and the node is skipped, silently and successfully. Create the handle
with defaultLaunchValue = 1 and cudaGraphCondAssignDefault for a
do-while, or add a kernel before the node that calls
cudaGraphSetConditional first.
The graph launch never returns. A device-decided loop with an
unreachable tolerance spins forever, and there is no host loop to break
out of. Add an iteration cap to the decision kernel next to the
tolerance, the way decideAndCount does, before you ever need it.
You changed the variable and the graph kept the old value. Kernel
arguments pass by value and are frozen into the executable at
instantiation. Re-capture and call cudaGraphExecUpdate, or move the
scalar behind a pointer so no update is needed at all.
cudaGraphExecUpdate returned an error and you did not look at why.
The updating graph must be topologically identical to the one that was
instantiated; an added kernel, a dropped one or a changed dependency
fails the whole update, and cudaGraphExecUpdateResultInfo::result names
the reason while the executable keeps running the old parameters. Gate on
cudaGraphExecUpdateSuccess, as the program does.
The build fails on the conditional types with CUDA 12.0 to 12.3. The
conditional API does not exist there, so the compiler rejects the
identifiers. The page's program guards that path with
#if CUDART_VERSION >= 12040; the fallback is the host-checked loop with
one graph launch per iteration, which is strategy 2 unchanged.
Go deeper
- CUDA Programming Guide, "CUDA Graphs", sections 4.2.3 "Whole Graph Update" and 4.2.4 "Conditional Graph Nodes": https://docs.nvidia.com/cuda/cuda-programming-guide/04-special-topics/cuda-graphs.html (checked 2026-09-01)
- NVIDIA blog, "Dynamic Control Flow in CUDA Graphs with Conditional Nodes", for the IF, ELSE and SWITCH flavours and their versions: https://developer.nvidia.com/blog/dynamic-control-flow-in-cuda-graphs-with-conditional-nodes/ (checked 2026-09-01)
cuda-samples,Samples/3_CUDA_Features/graphConditionalNodes: https://github.com/NVIDIA/cuda-samples/tree/master/Samples/3_CUDA_Features/graphConditionalNodes (checked 2026-09-01)- CUDA Runtime API, "Graph Management", for every signature this page uses, in the 12.6 book matching the verification toolkit: https://docs.nvidia.com/cuda/archive/12.6.2/cuda-runtime-api/group__CUDART__GRAPH.html (checked 2026-09-01)
Next
Day 58 moves the overlap question inside a single stream: programmatic dependent launch lets a second kernel start while its predecessor is still running. The device-side loop you built today returns in day 60's frame pipeline, where the thing the host must never do per frame is what this page removed per iteration. Day 48's fusion remains the other answer when the launches themselves are the cost.