Skip to content

Device runtime: host-visible exceptions, a per-work-item malloc, and a compile-smoke grid - #621

Draft
michel2323 wants to merge 2 commits into
mainfrom
device-malloc
Draft

Device runtime: host-visible exceptions, a per-work-item malloc, and a compile-smoke grid#621
michel2323 wants to merge 2 commits into
mainfrom
device-malloc

Conversation

@michel2323

Copy link
Copy Markdown
Member

Fixes the ALCF CI failure on main after #610 and closes the class of bugs behind it.

Why CI went red

GPUCompiler 2.2.2 (JuliaGPU/GPUCompiler.jl#899, compilesig_invokes=false) stopped eliding the DomainError allocation inside Base.Math.exponent's local throw1 closure. sqrt(::Complex) reaches it through ssqs, and oneAPI.jl provided no device malloc, so the gpuarrays/statistics test died with

InvalidIRError: unsupported call to an unknown function (call to gpu_malloc)

Every throw path that src/device/quirks.jl does not cover was a latent instance of this; which ones surfaced depended on what the optimizer happened to delete.

Commit 1 — constructor overlays + compile-smoke grid

  • src/device/quirks.jl overlays the InexactError/DomainError constructors with @print_and_throw. Base builds these directly in several places (exponent, ssqs, _cpow, Int32(::Float32), round(Int, ::Float64)) without a throw helper that could be overridden one by one. The overlays are @inline and specialized on purpose: a @noinline/@nospecialize version boxes its Float32 argument, which is the same allocation by another route (and why sqrt(::ComplexF32) never compiled — the "doesn't work with ComplexF32 in oneAPI" that GPUArrays' statistics test skips around).
  • test/device/codegen.jl compiles ~200 (function, eltype) cells through the validating pipeline (compile_to_obj; the code_* reflection entry points skip validation) without launching anything, so the next "f(::T) stopped compiling" names its cell. The cells it marks broken are SPIR-V code-generator limitations: the Khronos translator rejects _cpow's i63 and llvm.smul.with.overflow, the LLVM back-end cannot select G_SADDO and mis-legalizes _cpow.

Commit 2 — a device runtime: host-visible exceptions and malloc

Device exceptions used to be silent (signal_exception was a no-op and the SPIR-V target lowers the trap behind it to a return), and anything allocating a Julia object on the device did not compile. Both are now served through a kernel state (GPUCompiler.kernel_state_type), the hidden first argument threaded to every device function:

  • Exceptions: signal_exception/report_oom set words in a 16-byte host-USM flag per (context, device); the host reads and atomically clears them whenever it synchronizes a stream (synchronize(), @sync, copies to the host) and raises a KernelException. The check is deliberately absent from synchronize_all_streams, which runs from finalizers. The state is prepended in the generated call, not in onecall, which stays a raw launch primitive for foreign SPIR-V.
  • malloc: a bump allocator over a per-work-item arena in private memory. A global-memory heap was tried first and does not work on this stack: Julia's boxed objects live in address space 0 once GPUCompiler strips its address spaces, which SPIR-V and IGC treat as private memory, and stores through such a pointer into a global buffer are silently lost (verified on a Max 1550 — the allocations happened, the values never arrived). The translator also rejects address-space-0 module globals, so add_private_heap! allocas the arena in the kernel entry after the kernel-state passes and patches its pointer into the state. Only kernels whose code reaches gpu_malloc carry the arena; it is fresh on every launch, nothing is freed, and exhausting PRIVATE_HEAP_SIZE (1 KiB) is reported as an out-of-memory KernelException rather than failing silently.

The constructor overlays stay: they print a reason, which the heap path does not, and keep allocations off the hot error paths.

Verification

Aurora debug node, LTS stack, GPUCompiler 2.2.2:

  • boxed values round-trip through the heap, 4096 concurrent work-items allocate correctly, exhaustion is reported, quirked throws print and raise, @sync/Array() surface the exception;
  • full suite: 12830 pass, 61 broken, 0 failures (broadcast Float16 fails only without the CI's -C native,-avx512fp16, the known host AVX512-FP16 issue).

Login node, both SPIR-V back-ends, GPUCompiler 2.2.1 and 2.2.2: the compile grid passes with the same broken set. Not run on the rolling stack.

…ompile-smoke grid

GPUCompiler 2.2.2 (JuliaGPU/GPUCompiler.jl#899, `compilesig_invokes=false`)
stopped eliding the `DomainError` allocation inside `Base.Math.exponent`'s
local `throw1` closure. `sqrt(::Complex)` reaches it through `ssqs`, and
oneAPI.jl provides no device `malloc`, so the gpuarrays/statistics test
died with

    InvalidIRError: unsupported call to an unknown function (call to gpu_malloc)

The allocation is the exception object. Base constructs `InexactError` and
`DomainError` directly in several places — `exponent`'s closures,
`ssqs`'s `Int(::Float)` conversion, `_cpow`, `Int32(::Float32)`,
`round(Int, ::Float64)` — none of which goes through a throw helper that
`src/device/quirks.jl` could override individually. An exception object
only exists to be thrown, so overlay the constructors themselves with
`@print_and_throw`; that covers every site at once. The overlays are
`@inline` and specialized on purpose: a `@noinline`/`@nospecialize`
version has to box its `Float32` argument, which is the same allocation
by another route (and the reason `sqrt(::ComplexF32)` never compiled).

`test/device/codegen.jl` compiles ~200 (function, eltype) cells through
the validating pipeline (`compile_to_obj`; the `code_*` reflection entry
points skip validation) without launching anything, so the next "f(::T)
stopped compiling" names its cell instead of failing deep inside a
GPUArrays test. The cells it still marks broken are SPIR-V code-generator
limitations, not allocations: the Khronos translator rejects `_cpow`'s
`i63` and `llvm.smul.with.overflow`, the LLVM back-end cannot select
`G_SADDO` and mis-legalizes `_cpow`.

Verified on an Aurora debug node with GPUCompiler 2.2.2 (LTS stack):
device/codegen, execution and gpuarrays/statistics pass (322/6 broken/0).
…p for malloc

Device exceptions used to be silent. `signal_exception` was a no-op and the
SPIR-V target lowers the trap behind it to a plain return, so a kernel that
threw — a bounds error, a domain error, anything — just stopped that
work-item and the host never heard about it. And anything that allocated
a Julia object on the device did not compile at all: GPUCompiler lowers
heap allocations to a back-end `malloc` that oneAPI.jl never provided, so
every throw path whose exception object the optimizer failed to delete
ended in

    InvalidIRError: unsupported call to an unknown function (call to gpu_malloc)

which is what GPUCompiler 2.2.2 exposed for `sqrt(::Complex)`.

Both are now served through a kernel state, the hidden first argument
GPUCompiler threads to every device function:

- `exception_flag` points at a 16-byte host USM buffer per (context,
  device). `signal_exception` and `report_oom` set its words; the host
  reads and atomically clears them whenever it synchronizes a stream —
  `synchronize()`, `@sync`, copying back to the host — and raises a
  `KernelException` (with a note when the heap ran out). The check is
  deliberately absent from `synchronize_all_streams`, which runs from
  finalizers. The state is prepended in the generated `call`, not in
  `onecall`, which stays a raw launch primitive for foreign SPIR-V.

- `heap` points at a bump arena in the work-item's private memory, and
  `malloc` bump-allocates from it without atomics. A global-memory heap
  was tried first and does not work: Julia's boxed objects live in
  address space 0 once GPUCompiler strips its address spaces, which is
  private memory to SPIR-V and to Intel's compiler, and stores through
  such a pointer into a global buffer are silently lost (verified on a
  Max 1550: allocations happened, the values never arrived). SPIR-V has
  no per-invocation module-scope storage either (the translator rejects
  address-space-0 globals), so `add_private_heap!` allocas the arena in
  the kernel entry after the kernel-state passes and patches its pointer
  into the state the entry hands on. Only kernels whose code reaches
  `gpu_malloc` carry the arena; the heap is fresh on every launch and
  nothing is freed. Exhausting the `PRIVATE_HEAP_SIZE` (1 KiB) is
  reported as an out-of-memory `KernelException`, never a silent
  failure.

The constructor overlays in quirks.jl stay: they print a reason, which the
heap path does not, and keep allocations off the hot error paths.

Verified on an Aurora debug node (LTS stack, GPUCompiler 2.2.2): boxed
values round-trip through the heap, 4096 concurrent work-items allocate
correctly, exhaustion is reported, and the full test suite passes
(12830 pass, 61 broken; the one failure, broadcast Float16, is the known
host AVX512-FP16 issue and passes with the CI's JIT target).
@michel2323
michel2323 marked this pull request as draft August 21, 2026 23:02
@michel2323

Copy link
Copy Markdown
Member Author

Fixes JuliaGPU/GPUCompiler.jl#906

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