Skip to content

Formal on NVIDIA GPUs: integrated --gpu=cuda stack + hardware runbook - #10

Draft
certik wants to merge 29 commits into
formal-cuda-nvidia-basefrom
test/formal-cuda-nvidia
Draft

certik wants to merge 29 commits into
formal-cuda-nvidia-basefrom
test/formal-cuda-nvidia

Conversation

@certik

@certik certik commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Integration branch: LFortran's native --gpu=cuda backend running BerkeleyLab Formal on a real NVIDIA GPU. Not for merging upstream. The individual fixes go upstream as their own PRs (listed below). This branch combines them, validated together, with a runbook.

The base formal-cuda-nvidia-base is upstream main at f7ce402.

How to run it on hardware

See ci/cuda_nvidia/README.md. In short, on a machine with an NVIDIA driver, CUDA toolkit and Pixi:

git clone -b test/formal-cuda-nvidia https://github.com/certik/lfortran.git && cd lfortran
export PATH=/usr/local/cuda/bin:$PATH
CMAKE_BUILD_PARALLEL_LEVEL=16 pixi run -e llvm11 build '-DCMAKE_CXX_FLAGS_DEBUG=-g -O2' '-DCMAKE_C_FLAGS_DEBUG=-g -O2' > build.log 2>&1
ci/cuda_nvidia/run_formal_cuda.sh $PWD/../formal-cuda-work > formal.log 2>&1; tail -45 formal.log

The script clones Formal at the validated revision and checks all of the following, printing a PASS/FAIL summary:

  • Formal's test suite and both solvers under --gpu=cuda_cpu;
  • the same under native --gpu=cuda with PTX JIT disabled;
  • full-field comparisons against host LLVM and GFortran 16;
  • cuobjdump, nsys and compute-sanitizer evidence;
  • LFortran's integration_tests -b cuda.

Results (RTX PRO 6000 Blackwell, sm_120, driver 610.43.02, CUDA 13.3)

This is the end-to-end run of run_formal_cuda.sh at this branch's head: 28/28 steps PASS.

Check Result
Formal FPM tests, cuda_cpu 32/32, 0 skipped
Every do concurrent offloaded (whole package built with --gpu-allow-cpu-fallback, no loop reported on the CPU) 65/65
Formal FPM tests, native CUDA, CUDA_DISABLE_PTX_JIT=1 32/32, 0 skipped
Device code in the executable 14 sm_120 cubins
Workspace solver, Gaussian 17x13, 9 steps, native vs host LLVM max abs 1.16e-10, relative L2 7.9e-11
Workspace solver, nonzero-boundary nonsquare 13x11, 3 steps native == GFortran 16 == host LLVM bitwise; boundaries fixed; field evolves
compute-sanitizer memcheck/racecheck/synccheck/initcheck, both solvers 0 errors
integration_tests -b cuda (every cuda_cpu test also run natively) 485/485

Formal revision: ef4fb6e1cfb6eed69c588e1e92fbf739ebcb09bf (BerkeleyLab/formal#71, which includes lfortran#70).

What is merged here

PR Head What
lfortran#15546 33ce46e Separate compilation and static libraries for CUDA (Formal's library build)
lfortran#15559 b2f389b --gpu-arch; native CUDA defaults to -arch=native
lfortran#15560 5430573 Unique per-translation-unit kernel registrar; 18 cuda labels
lfortran#15562 6a5d5a1 Skip launches with an empty grid (CUDA rejects them)
lfortran#15563 24efaca cuda label on the 454 cuda_cpu tests that pass on a GPU
lfortran#15569 f13ef24 Per-thread copies of per-iteration arrays (a real-GPU data race)
lfortran#15570 8d5b1ed Declare only referenced variables in kernels (nvcc lfortran#177-D: 750 → 1)
lfortran#15578 0dadf0c Evaluate each operand of a defined operator once (lfortran#15572)
lfortran#15580 9021c8b Reuse CUDA scratch buffers across launches

Integration-only commits

Known gaps (next steps)

  • The operators solver is not fully offloaded yet. Every do concurrent runs as a GPU kernel, but array expressions outside do concurrent (RK combination, boundary assembly, operator arithmetic) still run on the host. An audit of every loop and array statement is in progress; offloading those comes next, then performance (data residency across procedures).
  • The workspace solver is fully on the GPU: all field arithmetic is in kernels, and transfers stay constant regardless of the step count.

Issues filed along the way

lfortran#15561, lfortran#15572, lfortran#15579.

certik added 29 commits October 18, 2026 15:04
nvcc was run without -arch, so it built only for its default sm_75 and
newer GPUs ran the kernels through driver PTX JIT. Pass -arch=native by
default (nvcc falls back to its default with a warning when no GPU is
present) and add --gpu-arch to select one explicitly. The flag goes on
the device compiler, so the kernel, runtime and link commands agree.
Under separate compilation the per-object CUDA device sources are
concatenated into one .cu file, and every one of them defined the same
_lfortran_cuda_registrar struct and _lfortran_cuda_reg variable, so nvcc
rejected the link. Name the registrar after the first kernel, as the
cuda_cpu emulation already does, and emit none for a translation unit
without kernels. Enable the cuda label on the separately compiled GPU
tests that now build.
An empty do concurrent iteration space gives a grid with a zero extent,
which cudaLaunchKernel rejects with 'invalid argument'. A launch of no
threads runs nothing, so lfortran_gpu_launch now returns before copying
anything in or launching when a grid or block extent is not positive.
The CPU emulation runtime does the same, so a negative extent is never
turned into a huge unsigned block count there either.
Only four integration tests carried the cuda label, so
`integration_tests -b cuda` checked almost nothing on a machine with an
NVIDIA GPU. Label the 454 cuda_cpu tests that pass with --gpu=cuda on a
real GPU (RTX PRO 6000 Blackwell, sm_120). The cuda_cpu tests that still
fail there are fixed and labeled separately.
A fixed-size array that every do concurrent iteration defines before it
references it is private to the iteration (F2018 11.1.7.5), but the GPU
offload pass passed it to the kernel as one buffer that every thread
wrote and read. On a real GPU the last writer won (gpu_metal_246 failed
with --gpu=cuda); cuda_cpu runs threads serially and hid the race.

The privatizer in gpu_offload_privatize.cpp already makes such arrays
locals of a BLOCK around the body, but its flow reader:

- treated any ASSOCIATE construct as a read, so `t = [...]` inside an
  ASSOCIATE kept `t` shared;
- only recognized whole-array and DO-nest definitions, so the temporary
  an array constructor is lowered to (`k = lbound(c, 1); c(k) = a;
  k = k + 1; ...`) and arrays defined element by element stayed shared.
  It now follows integer locals with known values and counts each
  element defined, allowing reads of elements already defined.

Variables with LOCAL locality (`local(t)`, the region's OMPPrivate
clause) were never privatized: the array stayed a shared buffer and a
scalar referenced after the loop produced a kernel that did not compile.
They now become BLOCK locals unconditionally, so the variable outside the
loop is also unaffected, as the standard requires. A LOCAL array a
thread cannot hold (e.g. of derived type) is reported as not offloadable
instead of racing.

gpu_metal_246 now carries the cuda label; gpu_metal_483 covers the
privatized cases with n = 100000; gpu_kernel_source_14 checks through
CUDA and Metal kernel references that no shared buffer is passed, which
CI can observe without an NVIDIA GPU; gpu_decline_17 checks the new
diagnostic. gpu_metal_474's CUDA kernel reference changes only by making
an array-constant temporary thread-local.
# Conflicts:
#	src/bin/lfortran.cpp
# Conflicts:
#	integration_tests/CMakeLists.txt
#	src/libasr/codegen/asr_to_cuda.cpp
# Conflicts:
#	integration_tests/CMakeLists.txt
…while merging

The CMakeLists.txt conflicts of the label PRs were resolved with our side;
re-add every cuda label those PRs carry.
# Conflicts:
#	integration_tests/CMakeLists.txt
Every generated CUDA kernel unpacked all scalar arguments and declared
all named constants and locals, even those the body never uses, so nvcc
warned with lfortran#177-D (and host c++ with -Wunused-variable for cuda_cpu).
Collect the declarations of a kernel, device function or block, emit its
body first, and write only the declarations the body (or another kept
declaration) refers to. The scalar argument struct keeps every field, so
the host/device launch layout is unchanged.
A type-bound defined operator such as `mk(3.) - mk(1.)` is represented
as a call whose passed-object argument and dispatch expression (`m_dt`)
are the same expression node. The array_struct_temporary and
subroutine_from_function passes saved the argument in a temporary but
then saved `m_dt` in a second temporary, so the left operand was
evaluated twice (three function calls instead of two). This also
affected operands returned as polymorphic allocatable results.

When the passed-object argument has been replaced by a temporary, use
that temporary as the dispatch expression too.

Fixes lfortran#15572
Review follow-up to the per-iteration array privatization.

The element tracking kept the known value of an integer local across an
assignment like `y = f(k)`, where `f` may change `k` through an
intent(inout) argument; `t(k) = ...` was then counted as the wrong
element and `t` taken as fully defined. That analysis also runs on the
statements after the loop, where impure calls are allowed, so a
conforming program lost a value the loop left in `t(1)`. Now only
`k = e` with `e` a known call-free value, and an element definition
whose value calls nothing but pure functions, keep what is known; any
other statement (assignment to anything else, including an associate
name of a local, a call, a loop, input) forgets every counter.

The diagnostic for an array of LOCAL locality that cannot be given a
copy per thread now says why: its type (e.g. type(pair)), that it is a
coarray, that an associate construct around the loop names it, or that
the body has an associate construct the kernel cannot inline.

Also factor the element-type check and the body-name collection into
helpers, and document the contract of locality_spec_variables.

New tests: gpu_kernel_source_16 (runs everywhere, with CUDA and Metal
kernel references) keeps `t` a shared buffer when a call changes the
counter after the loop and `u` shared when only part of it is defined
before an untouched element is read; gpu_decline_18 and gpu_decline_19
cover the two associate diagnostics.
# Conflicts:
#	integration_tests/CMakeLists.txt
#	tests/reference/gpu_cuda_kernel-gpu_metal_474-0583450.json
#	tests/tests.toml
lfortran_gpu_set_scratch_arg did a cudaMalloc for every scratch buffer
(each per-thread workspace) on every launch, freed at the next sync. A
kernel with several workspaces launched in a loop spent most of its
runtime API time in cudaMalloc/cudaFree.

Take the scratch buffer at launch from the memory the kernel's argument
slot keeps, as read-only buffers already do and as the Metal runtime does
for scratch. It grows when a launch needs more, the old memory being
freed at the next sync. What a launch leaves in a scratch buffer is dead
once it ends (a part the host reads back is read before the next launch),
and the default stream runs launches in order, so pending launches can
share it.

The new test runs two kernels with workspaces many times between syncs,
with workspace sizes that grow, shrink and are zero, and checks the
results against the same loops run on the host.
# Conflicts:
#	integration_tests/CMakeLists.txt
lfortran#15569 makes the array-constant temporary thread-local and lfortran#15570 drops
the unused named constant; the merged output has both changes. Also run
gpu_kernel_source_15 on the cuda backend.
… Formal

ci/cuda_nvidia/README.md gives the exact steps to build LFortran, check
the backend, run the GPU integration tests on a real GPU, and validate
BerkeleyLab Formal (tests, both solvers, full-field comparisons against
host LLVM and GFortran 16, cuobjdump, nsys and compute-sanitizer).
run_formal_cuda.sh performs all of it and prints a summary; fpm/ and
gfortran16/ are the Linux Pixi environments it uses.
Build all of Formal (library, tests, every example) with
--gpu-allow-cpu-fallback, which reports each loop LFortran would run on
the CPU instead of failing, and require that no loop is reported.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant