Table of Contents
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;:argumentor:contentsfor an argument, or what it points to, that a callee needs uniform; or:storedfor 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,:erroror: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.