1 The IR
dxtr edited this page 2026-10-18 19:16:53 +02:00

The IR

clsl/ir is the intermediate representation between clsl's language and the code it compiles to. Programs never need it; back ends, targets, tools and tests do, and program-ir shows what a program's shaders became. The examples on this page use a package of their own:

(defpackage #:ir-example
  (:use #:cl)
  (:local-nicknames (#:ir #:clsl/ir)
                    (#:spirv #:clsl/spirv)
                    (#:back-end #:clsl/spirv-back-end)))
(in-package #:ir-example)

Modules

A module is one stage of a program, :vertex, :tessellation-control, :tessellation-evaluation, :geometry, :fragment or :compute: its structs, its global variables, its functions and its entry point, with the options of its stage. Values are constants, globals, parameters and the results of instructions, each defined once and each of a type. Control flow is structured: an :if holds a then block and an else block, a :loop a body and a continuing block, and every block ends in exactly one terminator. Memory is explicit: a variable is a pointer, which :load, :store and :access reach through, and pointer-root returns the variable a pointer points into.

The stage's options, which set-entry-point takes and module-option returns, are a compute shader's :workgroup-size, a tessellation control shader's :output-vertices, a tessellation evaluation shader's :domain, :spacing, :winding and :point-mode, and a geometry shader's :input, :output, :max-vertices and :invocations; and every stage's float controls, :rounding-mode-rte, the kinds of floats, :float and :double, the GPU rounds to the nearest, and :signed-zero-inf-nan-preserve, those whose signed zeros, infinities and NaNs it keeps.

The external form

module-form writes a module as an s-expression, and read-module-form reads one, written by hand or by module-form, back into a module. This is a compute shader that doubles each number of a storage buffer:

(defparameter *form*
  '(:module :compute
    (:struct numbers (values (:array :uint)))
    (:global id :input :uvec3 :built-in :global-invocation-id)
    (:global data :storage numbers :group 0 :binding 0)
    (:function main () :void
     (%1 (:load id))
     (%2 (:extract %1 0))
     (%3 (:access data (:int 0) %2))
     (%4 (:load %3))
     (doubled (:multiply %4 (:uint 2)))
     (:store %3 doubled)
     (:return))
    (:entry-point main :workgroup-size (64 1 1))))

(let ((module (ir:read-module-form *form*)))
  (ir:verify-module module)
  (values (equal (ir:module-form module) *form*)
          (ir:module-stage module)
          (ir:module-option module :workgroup-size)))
;; => T
;;    :COMPUTE
;;    (64 1 1)

(length (back-end:translate-module (ir:read-module-form *form*) (ir:find-target :vulkan)))
;; => 158

The module translates into 158 words of SPIR-V, which spirv-val accepts for Vulkan 1.4.

A statement is an instruction, or a label and an instruction. A label is the value's name, which becomes its OpName, unless it is % and digits, which name nothing; :name after the instruction gives a name a label cannot. An operand is a label, a global's name, or a constant: its type and its data, as (:float 1.0), (:vec2 0.5 1.0) or (:mat2 (1.0 0.0) (0.0 1.0)). module-form labels a value by its name where no other value of the function and no global it uses has that label, and %1, %2 and so on otherwise, interned in *package* or the :package it is given, so that the same module always gives an equal form. read-module-form builds the module and folds its instructions, as the builder does, but does not verify it: verify-module checks it.

Types

Types are written by their designators, and interned in their module, so that two types of a module are the same exactly when they are eq.

Designator Type
:bool, :int, :uint, :float, :double a boolean; 32-bit integers, signed and unsigned, that wrap; IEEE floats of 32 and 64 bits
:vec2 to :vec4, :ivec2, :uvec3, :bvec4, :dvec2 and so on vectors of 2 to 4 scalars
:mat2 to :mat4, :mat2x3 (two columns of three), :dmat4x2 and so on matrices of floats or doubles, stored by column
(:array element length), (:array element) arrays, the second's length known only as the shader runs, as a storage buffer's last member may be
a symbol a struct of the module, which add-struct or (:struct ...) declares
(:pointer space type) a pointer into an address space
:sampler2d, :usampler-cube-array, :sampler2d-shadow and the others the language has a texture, with its sampler: handle-keyword-description says of what shape and kind, and texture-keywords lists them
(:image2d format) and the others of image-keywords a storage image, its texels stored in format, one of image-formats
:void what a function returns when it returns nothing

intern-type returns a module's type for a designator, type-designator a type's designator, and pointer-to a pointer type. The address spaces are :function, for a function's variables; :input and :output, the stage's interface; :uniform, a uniform block; :storage, a storage buffer; :workgroup, memory a compute shader's workgroup shares; :private, a variable of the invocation that every function sees; and :handle, where textures and storage images are. A texture or storage image is no value: :load reads it from its global for the operations that take it, and a function takes a pointer to it, never the texture itself.

Layout is not part of a type: type-size, type-alignment, array-stride, matrix-stride and member-offsets lay a type out by :std140, as uniform blocks are, or :std430, as storage buffers are.

(let ((module (ir:make-module :compute)))
  (ir:add-struct module 'light '((position :vec3) (color :vec3) (radius :float)))
  (list (ir:type-designator (ir:intern-type module :vec3))
        (eq (ir:intern-type module :vec3) (ir:intern-type module :vec3))
        (ir:type-size (ir:intern-type module '(:array :float 4)) :std140)
        (ir:type-size (ir:intern-type module '(:array :float 4)) :std430)
        (ir:member-offsets (ir:intern-type module 'light) :std140)
        (ir:type-designator (ir:pointer-to module :storage :float))
        (ir:handle-keyword-description :usampler2d-ms-array)))
;; => (:VEC3 T 64 16 (0 16 28) (:POINTER :STORAGE :FLOAT) (:TEXTURE :|2D| T T NIL :UINT))

Operations

An instruction is an operation and its operands; operations are uniform where SPIR-V's are, so a scalar factor is copied into a vector with :splat before it scales one component by component. Unless the table says otherwise, an operation takes scalars or vectors of one type and gives that type, component by component. operations lists all 169, and operation-kind, operation-arguments, operation-effect, operation-stages, operation-uniformity and operation-contractible-p describe each:

(list (ir:operation-kind :add) (ir:operation-kind :exit-if) (ir:operation-kind :loop)
      (multiple-value-list (ir:operation-arguments :construct))
      (ir:operation-effect :store) (ir:operation-stages :discard)
      (ir:operation-uniformity :dfdx))
;; => (:INSTRUCTION :TERMINATOR :CONSTRUCT (:TYPE 1 NIL 0 0) :WRITE (:FRAGMENT) :COLLECTIVE)

:construct takes a type first, then one operand or more, and no literal indexes; :store writes memory; :discard belongs to fragment shaders; and a derivative is collective, computed by neighbouring invocations together.

Operations Operands Result
:add, :subtract, :multiply, :divide, :mod, :rem two numbers of one type the same; integers wrap, :divide truncates them, :mod takes the divisor's sign and :rem the dividend's
:negate, :abs, :min, :max, :clamp numbers; :abs of no :uint, :clamp of three the same
:vector-times-scalar, :matrix-times-scalar a vector or matrix of floats, and a scalar the vector or matrix
:matrix-times-vector, :vector-times-matrix, :matrix-times-matrix, :outer-product, :transpose matrices and vectors of floats that fit as linear algebra gives
:dot two vectors of floats a scalar
:determinant, :inverse a square matrix a scalar, or a matrix
:floor, :ceiling, :truncate, :round, :sqrt, :inverse-sqrt floats the same; :round takes halves to the even neighbour
:exp, :exp2, :log, :log2, :sin, :cos, :tan, :asin, :acos, :atan, :sinh, :cosh, :tanh, :asinh, :acosh, :atanh :float values, never doubles the same
:power, :atan2 two :float values of one type the same; (:atan2 y x)
:fma, :mix, :smoothstep three floats of one type the same: (:mix x y a) is x(1 - a) + ya
:sign :int values or floats -1, 0 or 1 of their type
:norm, :distance one or two floats of one type a scalar
:normalize, :reflect, :face-forward one, two or three floats of one type the same
:cross two vectors of three floats the same
:refract two floats of one type, then a scalar the same
(:sample texture coordinates [offset]), :sample-bias, :sample-level, :sample-grad a texture of colours, its coordinates, a bias, a level or two derivatives, and an optional constant offset a texel, a vector of four; :sample in fragment shaders only
:sample-compare, :sample-compare-bias, :sample-compare-level, :sample-compare-grad a shadow texture, its coordinates, a depth, and the rest as above the share of its texels that pass, a :float
(:gather texture coordinates component [offset]), (:gather-compare texture coordinates depth [offset]) a two-dimensional or cube texture, its coordinates, a component or a depth a vector of four
(:fetch texture texel level [offset]), (:fetch-sample texture texel sample), (:fetch-buffer texture index) a texture, integer coordinates and a level, sample or index its texel, unfiltered
:texture-size, :texture-levels, :texture-samples, :texture-query-lod a texture, and a level or coordinates its size, levels, samples, or level of detail
:image-load, :image-store, :image-size, :image-samples a storage image, integer coordinates, a sample, and a value to store its texel, nothing, its size, or its samples
:atomic-add, :atomic-subtract, :atomic-min, :atomic-max, :atomic-and, :atomic-or, :atomic-xor, :atomic-exchange a pointer to an :int or :uint of a written :storage buffer or a :workgroup variable, and a value what it held
(:atomic-compare-exchange pointer expected new) the same pointer, and two values what it held, replaced by new if it was expected
:barrier nothing; compute shaders only
:memory-barrier, :memory-barrier-buffer, :memory-barrier-image, :memory-barrier-shared, :group-memory-barrier nothing; the last two in compute shaders only
:dfdx, :dfdy, :fwidth, and their -fine and -coarse kinds :float values derivatives across the quad, in fragment shaders only
:equal, :not-equal two numbers or booleans of one type booleans of their shape
:less, :less-equal, :greater, :greater-equal two numbers of one type booleans of their shape
:all, :any a vector of booleans a :bool
:and, :or, :not booleans the same
:logand, :logior, :logxor, :lognot, :shift-left, :shift-right integers; a shift's count of the same type the same; :shift-right keeps an :int's sign
:bit-count, :find-msb, :find-lsb, :bit-reverse integers the same
(:add-carry type a b), (:subtract-borrow type a b), (:multiply-extended type a b) two integers of one type a struct of type of two members of theirs
(:frexp type x) floats a struct of type of the significand and the exponent
:is-nan, :is-inf floats booleans of their shape
:pack-unorm-4x8 and the other packings, and their :unpack- kin a :vec4 or :vec2, or a :uint a :uint, or a :vec4 or :vec2
:interpolate-at-centroid, (:interpolate-at-sample input sample), (:interpolate-at-offset input offset) a pointer to a fragment shader's input of floats the input interpolated there
:select a condition and two values of one type the one the condition chooses
(:construct type part ...) a vector's components, a matrix's columns, an array's elements, a struct's members type
(:splat type scalar) a scalar a vector of copies of it
(:extract composite index ...), (:insert composite value index ...) a composite, and literal positions the member, or a copy with it replaced
(:swizzle vector index ...) a vector, and two to four literal components a vector of them
:extract-dynamic, :insert-dynamic a vector, a component for :insert-dynamic, and an index the component, or a copy
(:convert type value) a number of another kind of the same shape it converted: floats to integers truncate, integers to floats round
(:bitcast type value) a number of another type with as many bits the same bits
(:var type) a pointer to a new variable of the function
:load, :store a pointer, and for :store a value what it points to, or nothing
:access a pointer, and indexes: constants into a struct a pointer to the member they reach
:array-length a storage buffer whose last member has no length its length, a :uint
(:call function argument ...) the function's arguments what it returns
:discard nothing; fragment shaders only
:emit-vertex, :end-primitive nothing; geometry shaders only

Integer operations wrap. Where SPIR-V leaves a result undefined, such as an integer divided by zero, a shift by 32 bits or more, an index out of range or a texel outside a texture, so does the IR: the language guards each case, so that every operation in it has a defined result. The functions of floats give what the GPU gives outside their domains, and Vulkan grants each its precision. An instruction that instruction-exact says is exact, arithmetic or a conversion that gives floats, is computed as it is written: no compiler may contract it with another operation or reassociate it, as SPIR-V's NoContraction says, which float pairs need; operation-contractible-p says which operations may be.

Control flow

(:if condition [type] (:then ...) (:else ...)) runs one of its blocks, and (:loop [type] (:body ...) (:continuing ...)) runs its body, then its continuing block, until an exit leaves it. With a type, either gives a value, which its exits give. The terminators:

Terminator Ends Does
(:exit-if [value]) a block of an :if leaves the :if, giving its value
(:exit-loop [value]) a block of a :loop's body, or of an :if in it leaves the :loop, giving its value
(:continue) the same goes to the continuing block
(:next-iteration) a :loop's continuing block runs the body again
(:break-if condition [value]) a :loop's continuing block leaves the :loop if the condition is true, and runs the body again if not
(:return [value]) any block returns from the function
(:unreachable) any block is never reached

A value is defined in its block, after its statement, and in the blocks inside it. A construct no exit leaves never completes, and only (:unreachable) may follow it. construct-completes-p says whether one does, terminator-target which construct an exit leaves, and instruction-function the function an instruction is in.

Globals

(:global name space type option ...), or add-global, declares a global variable. An :input or :output has a :location, or is the :built-in it names, such as :position, :fragment-coord or :global-invocation-id: built-ins lists them. Between the stages, a value is interpolated perspective-correct unless its :interpolation is :linear or :flat, and at the pixel's centre unless its :sampling is :centroid or :sample; a fragment shader's input of integers or doubles is :flat. An input of a tessellation or geometry shader, and an output of a tessellation control shader, is an array of a value for each vertex, of the patch or primitive, as per-vertex-global-p says, unless it is :patch t, one value of the whole patch, as a tessellation control shader's output and a tessellation evaluation shader's input may be. A :uniform block or :storage buffer holds a struct of its members, at a :binding of a bind :group, and a storage buffer's :access is :read or :read-write. A :handle holds a texture or a storage image, at its :binding, a storage image's :access :read, :write or :read-write. A :workgroup variable starts at zero.

Building

make-module, add-struct, add-global, add-function and set-entry-point make a module. A builder, make-builder, adds instructions to the end of a function's body: build adds one and returns its value, build-if and build-loop add a construct, whose blocks if-then-block, if-else-block, loop-body-block and loop-continuing-block return, and with-block builds in one of them. build takes what the external form writes: the type or the callee the operation takes first, then the operands, values, then any literal indexes.

(let* ((module (ir:make-module :fragment))
       (color (ir:add-global module 'color :input :vec3 :location 0))
       (target (ir:add-global module 'target :output :vec4 :location 0))
       (main (ir:add-function module 'main '() :void))
       (builder (ir:make-builder main)))
  (let* ((rgb (ir:build builder :load color))
         (rgba (ir:build builder :construct :vec4 rgb (ir:constant module :float 1.0))))
    (ir:build builder :store target rgba)
    (ir:build builder :return))
  (ir:set-entry-point module main)
  (ir:verify-module module)
  (ir:module-form module))
;; => (:MODULE :FRAGMENT (:GLOBAL COLOR :INPUT :VEC3 :LOCATION 0)
;;     (:GLOBAL TARGET :OUTPUT :VEC4 :LOCATION 0)
;;     (:FUNCTION MAIN NIL :VOID (%1 (:LOAD COLOR)) (%2 (:CONSTRUCT :VEC4 %1 (:FLOAT 1.0)))
;;      (:STORE TARGET %2) (:RETURN))
;;     (:ENTRY-POINT MAIN))

A function with a construct, and a compute shader that calls it on each element of a storage buffer:

(let* ((module (ir:make-module :compute))
       (numbers (ir:add-struct module 'numbers '((values (:array :float)))))
       (data (ir:add-global module 'data :storage numbers :group 0 :binding 0))
       (id (ir:add-global module 'id :input :uvec3 :built-in :global-invocation-id))
       (sign (ir:add-function module 'sign-of '((x :float)) :float))
       (main (ir:add-function module 'main '() :void)))
  (let* ((builder (ir:make-builder sign))
         (x (first (ir:function-parameters sign)))
         (negativep (ir:build builder :less x (ir:constant module :float 0.0)))
         (if (ir:build-if builder negativep :float)))
    (ir:with-block (builder (ir:if-then-block if))
      (ir:build builder :exit-if (ir:constant module :float -1.0)))
    (ir:with-block (builder (ir:if-else-block if))
      (ir:build builder :exit-if (ir:constant module :float 1.0)))
    (ir:build builder :return if))
  (let* ((builder (ir:make-builder main))
         (index (ir:build builder :extract (ir:build builder :load id) 0))
         (element (ir:build builder :access data (ir:constant module :int 0) index)))
    (ir:build builder :store element (ir:build builder :call sign (ir:build builder :load element)))
    (ir:build builder :return))
  (ir:set-entry-point module main :workgroup-size '(64 1 1))
  (ir:module-form (ir:verify-module module)))
;; => (:MODULE :COMPUTE (:STRUCT NUMBERS (VALUES (:ARRAY :FLOAT)))
;;     (:GLOBAL DATA :STORAGE NUMBERS :GROUP 0 :BINDING 0)
;;     (:GLOBAL ID :INPUT :UVEC3 :BUILT-IN :GLOBAL-INVOCATION-ID)
;;     (:FUNCTION SIGN-OF ((X :FLOAT)) :FLOAT (%1 (:LESS X (:FLOAT 0.0)))
;;      (%2 (:IF %1 :FLOAT (:THEN (:EXIT-IF (:FLOAT -1.0))) (:ELSE (:EXIT-IF (:FLOAT 1.0)))))
;;      (:RETURN %2))
;;     (:FUNCTION MAIN NIL :VOID (%1 (:LOAD ID)) (%2 (:EXTRACT %1 0))
;;      (%3 (:ACCESS DATA (:INT 0) %2)) (%4 (:LOAD %3)) (%5 (:CALL SIGN-OF %4)) (:STORE %3 %5)
;;      (:RETURN))
;;     (:ENTRY-POINT MAIN :WORKGROUP-SIZE (64 1 1)))

After a terminator, the code that follows, which never runs, as Lisp code after a return, is built in an unreachable-block, which no construct holds:

(ir:build builder :exit-loop value)
(setf (ir:builder-block builder) (ir:unreachable-block builder))

When every operand is constant, build folds: composites always, and integer, boolean and conversion operations where the result is exactly defined. It does not fold floating-point arithmetic, whose results differ from GPU to GPU: (:add (:int 2) (:int 3)) builds the constant 5, and (:add (:float 2.0) (:float 3.0)) an instruction. While builder-exact is true, the arithmetic and conversions it adds that give floats are exact.

The verifier

verify-module checks a module against the IR's rules, and returns it, or signals an ir-error naming the function and the statement at fault, as the external form writes them, and what would fit. Every block ends in a terminator, and only there; each operand is defined before its use, in its block or one around it, and of the types its operation takes; each exit leaves a construct it is in, with a value of the construct's type; stores go only to memory that may be written; calls match their callees, and no function calls itself; operations appear only in the stages they belong to; pointers are passed only to whole variables; and the globals, structs and entry point are well formed.

In the function MAIN, (:STORE %1 (:UINT 1)): :store writes memory that may only
be read: DATA is a :STORAGE buffer with :ACCESS :READ.
In the function MAIN, the body of the function MAIN is empty: a block ends in a
terminator, such as (:RETURN).

Uniform control flow

A derivative, and :sample and its kin whose level of detail comes from derivatives, are computed by the invocations of a quad together, and :barrier waits for the invocations of a workgroup together: operation-uniformity says :collective of them. uniformity-failures runs WGSL's uniformity analysis over a module that verify-module accepts, and returns a uniformity-failure for each collective operation that may run where control flow is not uniform, those of the functions its entry point reaches, callees first. Its :severity function says, of each instruction that needs uniform control flow, :error, the default, :warning, or :off to leave it out. In a tessellation control module it runs over the invocations of one patch, and finds as well each :store to a :patch output, such as the tessellation levels, that the invocations of a patch may not all make, or make with values that differ. A failure has:

  • uniformity-failure-origin, the instructions at fault, from the call in the entry point to the collective operation or the store;
  • uniformity-failure-need, what that operation needs: :control-flow, or :stored, the same value stored by every invocation of the patch;
  • uniformity-failure-kind, :control-flow; :argument or :contents for an argument, or what it points to, that a callee needs uniform; or :stored for the value a store stores;
  • uniformity-failure-parameter, that argument's position;
  • uniformity-failure-path, the steps by which it may not be uniform, from the operation to what differs between invocations, each a list such as (:branch if) or (:value instruction);
  • uniformity-failure-severity, :error or :warning.
(let* ((module (ir:read-module-form
                '(:module :fragment
                  (:global uv :input :vec2 :location 0)
                  (:global target :output :vec4 :location 0)
                  (:function main () :void
                   (%1 (:load uv))
                   (%2 (:extract %1 0))
                   (%3 (:greater %2 (:float 0.5)))
                   (%4 (:if %3 :vec2
                        (:then (%5 (:fwidth %1)) (:exit-if %5))
                        (:else (:exit-if (:vec2 0.0 0.0)))))
                   (%6 (:construct :vec4 %4 (:float 0.0) (:float 1.0)))
                   (:store target %6)
                   (:return))
                  (:entry-point main))))
       (failure (first (ir:uniformity-failures (ir:verify-module module)))))
  (list (ir:uniformity-failure-severity failure)
        (ir:uniformity-failure-need failure)
        (ir:uniformity-failure-kind failure)
        (mapcar #'ir:instruction-operation (ir:uniformity-failure-origin failure))
        (mapcar (lambda (step)
                  (cons (first step)
                        (mapcar (lambda (part)
                                  (if (ir:instruction-p part) (ir:instruction-operation part) part))
                                (rest step))))
                (ir:uniformity-failure-path failure))))
;; => (:ERROR :CONTROL-FLOW :CONTROL-FLOW (:FWIDTH)
;;     ((:BRANCH :IF) (:VALUE :GREATER) (:VALUE :EXTRACT) (:VALUE :LOAD)))

The fwidth is in a branch of the :if, whose condition is the :greater of the :extract of the :load of uv, an input. The language reports each failure in the terms of the shader code: an error refuses the shader, and a warning is a uniformity-warning.

Targets

find-target returns a target by its name, target-compile makes its code of a module, target-features lists the device features that code needs, and target-disassemble prints it. Targets describes them, and define-target, which registers a target.