Skip to content

Native code's conventions and IR where GPUCompiler depends on them - #25

Draft
vchuravy wants to merge 27 commits into
vc/jc-23-debuginfofrom
vc/jc-24-gpu-gaps
Draft

vchuravy wants to merge 27 commits into
vc/jc-23-debuginfofrom
vc/jc-24-gpu-gaps

Conversation

@vchuravy

@vchuravy vchuravy commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #24. These fixes come from running GPUCompiler's test suite with codegen=:juliacodegen (JuliaGPU/GPUCompiler.jl#1036). Each commit makes JuliaCodegen.jl emit what native code emits where GPUCompiler (or its tests) depends on it.

On Julia 1.12, the whole GPUCompiler suite went from 149 failures and 15 errors to 10 failures and no errors (4446 of 4460 pass; Julia's own code generator: 4456). The 10 that remain are IR-shape tests, listed at the end.

Conventions

  • specsig, varargs methods: take their arguments one by one, as native code passes them. A signature ending in Vararg is still called in the jlcall convention.
  • specsig, union results: as native code's union results. A member without references comes back as its type index, with its data in the union_bytes_return buffer. A union of singletons comes back as just the index. Others come back boxed. A value that has a box (a constant) is returned as that box, with UNION_BOX_MARKER. This holds for our specsig entries and for our calls of specsig functions (cfunction, jlcall entries). From Julia 1.14 on (implement sret_union ABI for pointer-ful types JuliaLang/julia#55045), members with references are also returned unboxed: their bytes go in the buffer, and their references go in a return_roots argument marked julia.return_roots, which GC lowering roots in the caller's stack slots. This is keyed on JuliaIR's dialect.gc_return_roots === :attribute.
  • Inline aggregates by reference: functions take inline aggregates without references by reference (ptr addrspace(11), readonly), as native code's specsig functions do. Values keep the memory they were loaded from (a reference argument, an immutable box). Calls pass that memory on instead of copying to the stack, and dynamic indices read in place.
  • jlcall entries: they call the specsig entry, as jfptr_f_N calls julia_f_N, instead of inlining the function. They have native code's attributes: a nonnull result and a noalias nocapture noundef readonly argument array.
  • @noinline methods: their functions are noinline.

Builtins and intrinsics

  • getfield with an index known only at run time (emit_getfield_unknownidx), on:

    • inline tuples of a single type: extracted from a vector, selected among a few elements, or loaded from memory;
    • tuples of references;
    • objects whose fields are all references, or all of one type.

    Constants are read from a private global, which GPU back ends place in constant memory.

  • Core.svec, allocated inline (as native code from 1.12 on), and Core._svec_len.

  • Atomic fields: loads with an order, and modifications as a cmpxchg loop.

  • Fast-math flags on the _fast intrinsics.

  • throw goes through jl_throw even where inference knows the call throws.

Error paths through the runtime

These replace calls of Julia functions that construct the exceptions, which allocate on GPU back ends, where these throws lower to traps:

  • bounds errors: jl_bounds_error_int with the boxed value, or jl_bounds_error_unboxed_int with the data of an inline value without references;
  • undefined references and division by zero: the runtime's exception objects, through jl_throw;
  • undefined locals: jl_undefined_var_error;
  • llvmcall of an unknown name: jl_error;
  • type checks of pointerset values: jl_type_error.

llvmcall and ccall

  • Base.llvmcall passes Ptr values as integers of their size, and structs of fields of one type as arrays, as native code does. Only ccall(…, llvmcall, …) passes LLVM structs.
  • llvmcall convention: extern functions, and overloads of intrinsics by their base name.
  • Runtime symbols go through their ijl_ aliases. Other symbols are bound lazily, as native code does: jlplt_<sym>_N_got slots, and jl_dlfind for the runtime's libraries.

Objects and globals

  • The runtime's objects (jl_true, jl_nothing, …) through the runtime's globals.
  • Types with a small tag from their entry in jl_small_typeof.
  • Other objects' globals are named as native code's (+Mod.T#N, -Mod.f#N, jl_sym#s#N, jl_global#N) and carry julia.constgv. GPUCompiler derives relocation names from these.
  • Unions keep the box of a constant member, or the box a value came from.
  • Other modules' bindings are read through the runtime. Typed assignments go through the binding.

Debug information

  • The statement index in the column, from Julia 1.14 on, as native code.
  • Linkage names are the specsig entries' names.
  • debug_info=:no_debug emits locations in a compile unit of emission kind NoDebug, as native code does for debug_info_kind NoDebug (unless Julia runs with -g0). GPUCompiler's backtraces need these on PTX.

GPUCompiler tests that still fail (IR shape)

  • nested insertvalue (SPIR-V, 4): native code's nested-index insertvalues come from SROA of aggregates built in stack memory. JuliaCodegen.jl builds them in SSA, and LLVM's C API can't emit nested indices.
  • replace i128 allocas (SPIR-V, 4): native code returns the aggregate through sret into a stack slot. JuliaCodegen.jl's internal convention returns it by value, so there is no alloca to rewrite.
  • merged relocation slots (Metal): native code loads an object's address where it is used, and LLVM merges those loads into one from a phi of slots. JuliaCodegen.jl loads each once, at the top of the function.
  • smalltag boxed union constant demotion (Metal): internal calls carry the union's payload, so the kernel never reads the constant's box, and nothing needs to be demoted to an alloca.

Tests

Every fix has tests (differential runs, and FileCheck where the shape is the point). Tested locally with julia +1.12, +1.13, +1.14-nightly and +nightly: 6114, 6120, 6120 and 6120 tests pass. On nightly, one allocation-count check in memory.jl failed only while two suites ran at once, and passes on its own.

🤖 Generated with Claude Code

… them

The IR of `Base.llvmcall` gets `Ptr` arguments, and returns `Ptr` results, as
integers of the pointer's size (`inttoptr i64 %0`), as Julia's code generator
passes them. They were `ptr`s, so IR written for native code (GPU code's,
GPUCompiler's tests) didn't parse. Pointers and integers are converted with
ptrtoint/inttoptr. `ccall(..., llvmcall, ...)` keeps pointers.

Assisted-by: Claude Code (Opus 5.5)
…e name

- `ccall("extern name", llvmcall, ...)` calls the external function `name`, as
  native code does; GPU runtimes call their functions so (`malloc`, AIR's
  builtins, `deferred_codegen`).
- The base name of an overloaded intrinsic (`llvm.powi`) is the overload of
  the call's signature (`llvm.powi.f64.i32`), as native code mangles it,
  with LLVM.jl's `Function(mod, intrinsic, ft)`.

Assisted-by: Claude Code (Opus 5.5)
- `getfield(x, f, order)` of an atomic field of a bits type of 1 to 8 bytes,
  or a reference, is an atomic load with the order (with the undefined-
  reference check), as native code; mismatches between the field and the
  order stay the runtime's ConcurrencyViolationError.
- `Expr(:invoke_modify, ci, modifyfield!, obj, field, op, x, order)` (`@atomic
  x.f += 1`) of such a field is a loop of the field's atomic compare-and-
  exchange with `op(old, x)`, calling the CodeInstance of `op` directly, with
  the write barrier for references, returning `old => new`: native code's
  loop on Julia 1.12 (from 1.13 on, native code emits `julia.atomicmodify`
  for a pass to expand). A result of a type that can't be stored, and other
  fields, call `modifyfield!`.

Assisted-by: Claude Code (Opus 5.5)
Calls of type `Union{}` went to the runtime's dynamic dispatch, `throw`
too, which native code (and so GPU compilers, which lower `jl_throw` to
their exception reporting) compiles to `jl_throw`. `throw` now always
reaches `emit_throw`.

Assisted-by: Claude Code (Opus 5.5)
The `_fast` arithmetic and comparison intrinsics, `sqrt_llvm_fast` and the new
`min_float_fast`/`max_float_fast` (`llvm.minimum`/`llvm.maximum`) get all
fast-math flags, as native code emits them: back ends choose approximate
instructions from them (PTX's `div.approx`, `rsqrt.approx`, SPIR-V's `fmin`).

Assisted-by: Claude Code (Opus 5.5)
`true`, `false`, `nothing`, `()` and `Core.svec()` are referred to through
the runtime's globals (`jl_true`, `jl_false`, `jl_nothing`, `jl_emptytuple`,
`jl_emptysvec`), external constants as native code declares them
(`global_jlvalue_to_llvm`), rather than literal globals of the module:
GPUCompiler relocates them by name, and validates them as libjulia globals.

Also: the memory test's allocation count is the least of ten measurements
(`@allocated` counts what other threads allocate meanwhile; three still
flaked in the full suite).

Assisted-by: Claude Code (Opus 5.5)
A value converted to an unboxed union keeps its box in the union's box slot
(with the box marker): the object of a constant, as native code keeps isbits
constants in unions boxed (JuliaLang/julia#55045), or the box an inline value
came from (when it isn't null). Boxing the union then reuses it instead of
allocating, as native code; GPUCompiler relocates such boxes as interior
records.

Assisted-by: Claude Code (Opus 5.5)
…alues

- An llvmcall of a name that isn't an intrinsic LLVM knows, or an `extern`
  function, is an error at run time (`jl_error("llvmcall only supports
  intrinsic calls")`), as native code compiles it, rather than a declaration
  (or Unsupported): GPUCompiler reports it as an unknown intrinsic.
- `pointerset` of a value of another type than the element type checks it
  (`emit_typecheck!`, as native `emit_typecheck`: `jl_type_error`) and
  unboxes it, rather than calling the intrinsic through the runtime.

Assisted-by: Claude Code (Opus 5.5)
…tive code

A C symbol without a library is the runtime's only if libjulia-internal has
its `ijl_` alias, as native code decides. Otherwise it's bound lazily on
first use from the library `jl_dlfind` finds:
- the runtime's own libraries are named by `jl_dlfind`'s sentinels
  (`JL_EXE_LIBNAME` etc.), which were taken for strings, and are passed with
  their handle's global (`jl_libjulia_internal_handle`, ...), as native code
  passes them;
- the slot is named as native code's lazily bound functions
  (`jlplt_name_N_got`), which GPUCompiler reports as such.

The check of the library's own symbols called `jl_dlsym` with a
`search_deps` argument that Julia 1.12's doesn't have: there it found C
library functions (`time`) through the runtime's dependencies.

Assisted-by: Claude Code (Opus 5.5)
…he binding

- A global that isn't a plain global of the world of the code (imported, not
  yet resolved, undefined) is read with `jl_get_binding_value_seqcst` on its
  binding, with `UndefVarError` when it is null, as native code reads it
  (GPUCompiler reports it as a delayed binding), rather than through a call
  of `getglobal`.
- An assignment of a global with a declared type of a value of that type goes
  through the binding (release store, write barrier), as native code; values
  that need converting still go through `setglobal!`.

Assisted-by: Claude Code (Opus 5.5)
A varargs method instance's function takes its varargs in a tuple, as its
IR has them (`function_argtypes`):
- an `:invoke` of a specialized one packs the trailing arguments, as native
  code calls it;
- jlcall entries unpack them from the arguments array: by the signature,
  or, for a signature ending in `Vararg`, with the runtime's `jl_f_tuple`;
- stubs pass them one by one to native code again (splatting a tuple of
  unknown length).

Varargs methods were stubs that called native code. Ones whose signature ends
in `Vararg` (as Julia's inference keeps many varargs calls) are still called
through `jl_invoke`, with the arguments one by one.

The JIT driver collects varargs callees too, and takes the CodeInstances
that `:invoke`s refer to as they are: inferring their method instance again
can give a CodeInstance of another, normalized, one (`Core.TypeEgal`).

Assisted-by: Claude Code (Opus 5.5)
…entries

- The column of a statement's innermost location is the statement's index in
  the IR, as native code encodes it from Julia 1.14 on: GPUCompiler maps
  errors in the LLVM IR back to the Julia statement they come from by it.
- `emit_into!` names the subprogram of a function by its specsig entry
  (`cgm.debug_linkage`), by which tools look up the function a location is in.

Assisted-by: Claude Code (Opus 5.5)
…n, as native code

Assisted-by: Claude Code (Opus 5.5)
The specsig entry of a varargs method instance whose signature doesn't end in
`Vararg` takes its arguments one by one, as native code declares it, and
packs the varargs for the function. Signatures ending in `Vararg` are called
in the jlcall convention, as native code does. GPU kernels with varargs
(`kernel(args...)`) get their entry.

Assisted-by: Claude Code (Opus 5.5)
With a specsig entry, the jlcall entry unboxes its arguments and calls it,
instead of inlining the function, as native code's jfptr_f_N calls julia_f_N:
GPUCompiler's slow ABI (entry_abi=:func) looks for that call.

Assisted-by: Claude Code (Opus 5.5)
As native code's emit_getfield_unknownidx: of the elements of an inline
tuple of a single type (extracted from a vector, selected among a few, or
loaded from memory: a constant from a private global, which GPU back ends
place in constant memory), and of objects whose fields are all references or
all of a single type, with the bounds check of the boundscheck argument.
These were calls of the runtime's getfield.

Assisted-by: Claude Code (Opus 5.5)
Functions take inline aggregates without references by reference to memory
that doesn't change (ptr addrspace(11), readonly), as native code's specsig
functions, and values keep the memory they were loaded from (a reference
argument, an immutable box): calls pass that on rather than copying the value
to the stack, and getfield with an index known at run time reads the element
where it is. GPUCompiler's back ends then index kernel arguments in place.

Assisted-by: Claude Code (Opus 5.5)
nonnull results, and a noalias nocapture noundef readonly argument array, as
jfptr_f_N has them (GPUCompiler's slow ABI test checks the declaration).

Assisted-by: Claude Code (Opus 5.5)
Bounds errors call jl_bounds_error_int with the boxed value, or
jl_bounds_error_unboxed_int with the data of an inline one without
references (as emit_bounds_check), undefined references and division by
zero throw the runtime's exception objects through jl_throw
(raise_exception_unless), and undefined local variables call
jl_undefined_var_error: rather than calls of Julia functions that construct
the exceptions, which allocate on GPU back ends, where these throws lower to
traps.

getfield with an index known at run time also reads constant tuples from
memory, as native code, and selects among the references of inline tuples
of different types.

Assisted-by: Claude Code (Opus 5.5)
svec allocates the vector inline (as native code from Julia 1.12 on), with
the elements boxed first, stored with release ordering and write barriers;
_svec_len loads the length. These were calls of the runtime, which GPU
back ends reject as dynamic calls rather than as unsupported allocations.

Assisted-by: Claude Code (Opus 5.5)
debug_info=:no_debug emits the locations in a compile unit of the emission
kind NoDebug, as native code for debug_info_kind NoDebug unless Julia runs
with -g0: GPUCompiler's error messages read backtraces from them for
targets without debug information, then strip them.

Assisted-by: Claude Code (Opus 5.5)
As native code, which lays out structs as LLVM structs only for ccall with
the llvmcall convention (julia_struct_to_llvm for llvmcall); the conversion
also took the length of an array type with length (GPUCompiler's kernel
state, a struct of one Int).

Assisted-by: Claude Code (Opus 5.5)
…l_typeof

The globals that hold the addresses of objects are named as native code's
(julia_pgv): +Mod.T# for types, -Mod.f# for methods and method instances,
jl_sym#s# for symbols and jl_global# for others, numbered, and carry the
julia.constgv metadata; types with a small tag are read from their entry in
the runtime's jl_small_typeof (literal_pointer_val_slot). GPUCompiler derives
its relocation names from these, and resolves the small type table.

Assisted-by: Claude Code (Opus 5.5)
A union whose members carry data is returned as native code's union results:
the box, or for a member without references the type index and its data in
the buffer union_bytes_return (with UNION_BOX_MARKER for a box); a union of
singletons as its type index; others boxed. Both the specsig entries and
calls of specsig functions (native code's, from cfunction and jlcall
entries) use it. From Julia 1.14 on, native code also returns members with
references unboxed, with their roots (JuliaLang/julia#55045): such unions
are Unsupported, called in the jlcall convention.

Assisted-by: Claude Code (Opus 5.5)
As native code, a union value that has a box (a constant, which unions keep
boxed, JuliaLang/julia#55045) is returned as that box, with
UNION_BOX_MARKER and its type index, rather than unboxed: jlcall entries
returned new boxes of such constants.

Assisted-by: Claude Code (Opus 5.5)
…14 on

From Julia 1.14 on (JuliaLang/julia#55045), native code's union results also return
members with references unboxed: their bytes but the references in the
`union_bytes_return` buffer, their references (in the order of their offsets) in a
`return_roots` argument with the `julia.return_roots` attribute, which GC lowering
roots in the caller's null-initialized stack slots. A member that is only references
is returned in the roots alone, with a buffer of their size. The type indices are
those of all inline members.

Keyed on the dialect's `gc_return_roots === :attribute`, as JuliaIR.jl's GC lowering
reads that attribute. Such unions were `Unsupported` on Julia 1.14 and later.

Assisted-by: Claude Code (Opus 5.5)
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