Julia integration

LLVM.jl offers several tools to interoperate with Julia's LLVM-based code generator. These tools are part of the LLVM.Interop submodule, and need to be imported explicitly (using LLVM.Interop). Unlike the vocabularies, which bring LLVM.jl's own functionality into scope, Interop builds on top of LLVM.jl to integrate with Julia.

Type conversion

Julia types can be converted to their LLVM counterparts using the convert function.

julia> convert(LLVMType, Int64)
i64

By default, this conversion rejects boxed types, as the result would often be surprising. It is possible to query whether a type would be boxed using the isboxed function, and/or allow the conversion by passing allow_boxed=true to convert:

julia> isboxed(Int64)
false

julia> isboxed(String)
true

julia> convert(LLVMType, String; allow_boxed=true)
ptr addrspace(10)

Another useful query is isghosttype, which returns whether a type is a ghost type, i.e., a sizeless type:

julia> isghosttype(Nothing)
true

julia> isghosttype(LLVM.VoidType())
true

Generated IR functions

With generated functions, it is possible to manually generate Julia IR when a function is visited by the Julia compiler. LLVM.jl extends this with the ability to define functions that generate LLVM IR, which is very useful to generate code that is not easily expressible in Julia. The @llvmgenerated macro defines such a function: its body runs once per specialization, with an IR builder positioned in a new function whose LLVM signature is derived from the Julia one, and with the function arguments bound to LLVM values:

@llvmgenerated builder function add(x::T, y::T)::T where {T}
    add!(builder, x, y)
end

@code_llvm debuginfo=:none add(1, 2)

add(1,2)

# output

; Function Signature: add(Int64, Int64)
define i64 @julia_add_3944(i64 signext %"x::Int64", i64 signext %"y::Int64") #0 {
top:
  %0 = add i64 %"y::Int64", %"x::Int64"
  ret i64 %0
}
3

The value returned by the body, which should have the LLVM type that the return type lowers to, is returned from the function. The return type annotation is required, as it determines the signature of the generated function.

Arguments whose value is known at compile time, like Val instances or types, are not passed to the generated function but are bound to their value instead. Combined with static parameters, this makes it possible to specialize the generated code:

@llvmgenerated builder function read_sreg(::Val{name})::Int32 where {name}
    ft = LLVM.FunctionType(LLVM.Int32Type())
    f = LLVM.Function(current_module(builder), "llvm.nvvm.read.ptx.sreg.$name", ft)
    call!(builder, ft, f)
end

Here, current_module returns the module containing the function being generated, which is where intrinsics and other functions should be declared. The body can also emit control flow, in which case it should emit its own ret! terminators.

Refer to the documentation of @llvmgenerated for more details. For generators that need more control, e.g., because they sometimes return Julia code instead, the generate_llvmcall function offers the same functionality while returning the llvmcall expression, which can be returned from a regular @generated function or spliced into an @eval'd definition.

The body of an @llvmgenerated function is compiled once, not for every specialization: it doesn't specialize on the types of the arguments or the values of the static parameters. Generating code for a new specialization only has to run it, and a precompilation workload that calls one specialization of the function also precompiles the body for the others. A callback passed to generate_llvmcall, however, is compiled for its own type and for the types of its arguments, so a closure that captures the static parameters of a generator is compiled for every specialization. Refer to the documentation of generate_llvmcall for how to avoid that.

Inline assembly

An extension of this mechanism is the @asmcall macro, which allows embedding inline assembly in Julia functions:

julia> add(x::Int, y::Int) = @asmcall("add \$0, \$1, \$2", "=r,r,r",
                                      Int64, Tuple{Int64,Int64},
                                      x, y);

julia> add(1,2)
3

julia> @code_native add(1,2)
	; InlineAsm Start
	add	x0, x0, x1
	; InlineAsm End
	ret

LLVM pointers

Julia's pointer type Ptr only keeps track of the element type, and not the address space. Julia has Core.LLVMPtr to track address space information, with the necessary codegen support, but no utility functions. LLVM.jl provides the functionality that's commonly needed when working with pointers. Most of it are methods of Base functions, which are available as soon as LLVM.jl is loaded, without having to import LLVM.Interop:

  • unsafe_load(ptr, [i=1], [Val(align)]) and unsafe_store!(ptr, x, [i=1], [Val(align)]): load or store the ith element, with the given alignment (1 by default)
  • basic pointer arithmetic: conversions to/from integers, addition, subtraction, comparison, etc.
  • LLVM.Interop.addrspacecast(Core.LLVMPtr{T,AS}, ptr): convert between pointers with different address spaces
  • LLVM.Interop.volatile_load(ptr, [i=1], [Val(align)]) and LLVM.Interop.volatile_store!(ptr, x, [i=1], [Val(align)]): like unsafe_load and unsafe_store!, using volatile memory accesses
julia> a = [1];

julia> ptr = Core.LLVMPtr{Int,0}(pointer(a));

julia> unsafe_load(ptr)
1

julia> unsafe_store!(ptr, 42);

julia> a
1-element Vector{Int64}:
 42

Intrinsics

LLVM.jl also provides Julia functions for common intrinsics, allowing them to be used in regular Julia code. For example, the assume function can be used to emit llvm.assume intrinsic calls:

julia> max(a, b) = a < b ? b : a;

julia> @code_llvm debuginfo=:none max(1,2)
; Function Signature: max(Int64, Int64)
define i64 @julia_max_22435(i64 signext %"a::Int64", i64 signext %"b::Int64") #0 {
top:
  %"a::Int64.b::Int64" = call i64 @llvm.smax.i64(i64 %"a::Int64", i64 %"b::Int64")
  ret i64 %"a::Int64.b::Int64"
}

julia> function max(a, b)
         assume(a > b)
         a < b ? b : a
       end;

julia> @code_llvm debuginfo=:none max(1,2)
; Function Signature: max(Int64, Int64)
define i64 @julia_max_22441(i64 signext %"a::Int64", i64 signext %"b::Int64") #0 {
top:
  %0 = icmp slt i64 %"b::Int64", %"a::Int64"
  call void @llvm.assume(i1 %0)
  ret i64 %"a::Int64"
}

To abort execution, the trap() function can be used, generating a call to @llvm.trap.

Optimization passes

Julia's LLVM passes are usable in the same way as LLVM's passes, and are automatically available to any PassBuilder. Similarly, the default Julia optimization pipeline can be used through the JuliaPipeline pipeline object.

julia> run!(JuliaPipeline(), mod)

This object supports many keyword arguments to configure the pipeline; refer to the API documentation for more information.