1 Numbers and Precision
dxtr edited this page 2026-10-18 19:16:40 +02:00

Numbers and Precision

On scalars every operator means what it means in Common Lisp, as far as a GPU's numbers allow: integers of 32 bits, floats of 32, doubles where the device has them, complex numbers of either, and float pairs for positions far from the origin. This page says what each computes, and what clsl defines where SPIR-V leaves a result undefined.

Integers

:int and :uint are integers of 32 bits, signed and unsigned, and wrap: (+ i 1) of an :int that is 2147483647 is -2147483648. An integer written in the code that does not fit in 32 bits is refused, its arithmetic done exactly first, as Lisp does it:

(define-function too-big ()
  (+ 1 4294967296))
In TOO-BIG: 4294967297 does not fit in 32 bits, as clsl's integers do.
  in: (+ 1 4294967296)

/ does not divide integers, which in Lisp would make a ratio: truncate, floor, ceiling and round do, and give two values, the quotient and the remainder, as Lisp's do. The bit functions, ldb, ash, logand and the others, are Lisp's on 32 bits: see Language Functions.

Floats

A :float is a single-float, an IEEE float of 32 bits. A float function keeps its type, and gives what the GPU gives outside its domain, usually a NaN or an infinity: (sqrt -1.0) is a NaN, where Lisp's is a complex number. Vulkan grants each function its precision, which differs from GPU to GPU within it.

Vulkan lets a driver assume that no float is a NaN or an infinity, unless a shader declares SignedZeroInfNanPreserve. A module whose results depend on them declares it for the floats it tests: a float converted to an integer with its guard, decode-float's significand and exponent, float-nan-p, float-infinity-p and scale-float. The program then needs a device property, which program-features names:

(define-shader nan-check :fragment ((x :float))
  (if (float-nan-p x) (vec4 1 0 0 1) (vec4 0 1 0 1)))

(program-features (compile-program '(:fragment nan-check) :target :vulkan))
;; => (:SIGNED-ZERO-INF-NAN-PRESERVE-FLOAT32)

Nearly every Vulkan 1.4 device has it.

Defined results

At the default safety every operation has a defined result, where SPIR-V leaves some undefined:

Case Result
An integer divided by zero, or the most negative :int by -1, in truncate, floor, ceiling, round, mod and rem the quotient is the dividend, and the remainder 0
+, - and * of integers overflowing they wrap
An index out of range, into a vector, a matrix or an array a read gives zero, and a write does nothing
A float converted to an integer it does not fit the nearest integer that fits
A NaN converted to an integer 0
isqrt of a negative :int 0
decode-float of an infinity or a NaN its magnitude, the exponent 0, and its sign
ash by 32 bits or more, and a byte of ldb and its kin past bit 31 as in Lisp: an :int's sign bits, or zeros
A size or position of a byte, or an index of logbitp, that is negative, computed by the code the :uint of its bits, past bit 31
A texel, a level, a layer or a sample outside a texture, in fetch zero
A level outside a texture, in texture-size a size of 0
A texel or a sample outside a storage image image-load gives zero, and image-store writes nothing
An element out of range, in an atomic nothing changes, and it gives 0
An index out of range, in input-position the position zero
Tessellation levels a control shader does not set 0, so the tessellator discards the patch
A vertex emitted past a geometry shader's :max-vertices it is not emitted

A guard is a comparison and a select, or, around a store, a comparison and a branch, and none is made where the operands are constants. A constant integer division by zero, a constant index out of range, or a negative size, position or index written in the code, is refused when the definition is evaluated:

(define-function constant-zero ((a :int))
  (floor a 0))
In CONSTANT-ZERO: FLOOR divides an integer by zero, whose quotient Lisp does not
define: clsl gives the dividend, as the quotient of a zero computed as the
shader runs.
  in: (FLOOR A 0)

(declare (optimize (safety 0))) leaves out the guards, where a measurement shows that they cost too much, and those results are then the GPU's. It leaves out the need for SignedZeroInfNanPreserve with them:

(define-shader whole-part :fragment ((x :float))
  (vec4 (float (truncate x)) 0 0 1))

(define-shader whole-part-fast :fragment ((x :float))
  (declare (optimize (safety 0)))
  (vec4 (float (truncate x)) 0 0 1))

(program-features (compile-program '(:fragment whole-part) :target :vulkan))
;; => (:SIGNED-ZERO-INF-NAN-PRESERVE-FLOAT32)
(program-features (compile-program '(:fragment whole-part-fast) :target :vulkan))
;; => NIL

Floating-point functions outside their domains, and a float divided by zero, give what the GPU gives, at either safety.

Doubles

A :double is a double-float, and :dvec2 to :dvec4 and :dmat2 to :dmat4x3 hold them. GPUs compute doubles' arithmetic, square roots, rounding, min and its kin, geometry and matrices, but trigonometry, exponentials and logarithms of :float values only, so these refuse doubles:

(define-function sine ((d :double))
  (sin d))
In SINE: SIN takes a :FLOAT, or a vector of them, or a :COMPLEX, not :DOUBLE.
GPUs compute it of :FLOATs only: (float x 1.0) makes the doubles of X the
:FLOATs nearest them.
  in: (SIN D)

Floats and doubles do not mix: (+ x d) of a :float and a :double is refused, with a message that says (float x 1d0) makes the float a double, and (+ (float x 1d0) d) adds them as doubles.

A program of doubles needs the device's :float64 feature, which program-features names, and cl-gfx refuses a shader module of doubles on a device without it. A vertex buffer gives a vertex shader its doubles as their 32-bit words, which every GPU reads, two words to a double, the low first, as a double-float is stored:

(define-shader precise-vertex :vertex ((position :dvec3) (scale :double))
  "Scale POSITION, in doubles, and place it."
  (vec4 (float (* position scale) 1.0) 1))

(let ((program (compile-program '(:vertex precise-vertex) :target :vulkan)))
  (values (program-features program)
          (program-vertex-inputs program)
          (program-vertex-buffers program)))
;; => (:FLOAT64)
;;    (#S(VERTEX-INPUT :NAME POSITION :LOCATION 0 :TYPE :DVEC3 :FORMATS (:UINT32X4 :UINT32X2))
;;     #S(VERTEX-INPUT :NAME SCALE :LOCATION 2 :TYPE :DOUBLE :FORMATS (:UINT32X2)))
;;    ((:STRIDE 32 :ATTRIBUTES ((0 :UINT32X4 0) (1 :UINT32X2 16) (2 :UINT32X2 24)) :STEP :VERTEX))

So the bytes of a vector of double-floats are what the attributes read. Three or four doubles take two locations, and doubles pass between stages :flat, as GPUs interpolate none.

Vulkan grants doubles' arithmetic only the precision of single-floats, though GPUs that have doubles compute them as IEEE does. Doubles are slow: GPUs made for games compute them 32 to 64 times slower than floats, or in software, and many of Intel's have none. Keep them to code that needs IEEE doubles, rather than code that runs for each vertex or pixel of a frame; for positions far from the origin, float pairs are faster.

Complex numbers

A :complex holds two :float parts, as Lisp's (complex single-float) does, and a :dcomplex two doubles. #c(1.0 2.0) written in the code is a :complex, and (complex x y) makes one; a real meets a complex number of its kind as Lisp's contagion has it.

(define-function rotate ((z :complex) (angle :float))
  "Z turned by ANGLE radians about the origin."
  (* z (cis angle)))

A complex number's arithmetic, abs, signum, sqrt, exp, log, expt and its trigonometric and hyperbolic functions and their inverses mean what they mean in Lisp, with Lisp's branch cuts: they are W. Kahan's formulas, as SBCL computes them, written in clsl's own language, and a module includes each that it calls once. On a cut, the side the sign of a zero part picks is the GPU's. A :dcomplex has the arithmetic, abs, signum and sqrt, which need no trigonometry:

(define-function double-exp ((z :dcomplex))
  (exp z))
In DOUBLE-EXP: EXP takes a :FLOAT, or a vector of them, or a :COMPLEX, not
:DCOMPLEX.  GPUs compute it of :FLOATs only: (float x 1.0) makes the doubles of
X the :FLOATs nearest them.
  in: (EXP Z)

A complex number is laid out as its two parts, a :vec2 or a :dvec2, in uniform blocks, storage buffers and vertex buffers, and passes between stages interpolated as its parts are; a fragment shader's :complex goes to two channels of a render target.

Float pairs

A :dfloat is a float pair: a :float, its high part, and the :float nearest what the high part misses, its low part. Their sum holds about 14 significant digits, with a float's range, and pairs compute at the speed of floats on every GPU, where doubles are slow or missing: a position 10,000 km from the origin, which a :float holds to a metre, a pair holds to well under a micrometre. Unreal's DoubleFloat library and Godot's large worlds keep positions so. :dfvec2 to :dfvec4 are vectors of pairs, which are no :dvec values: those hold doubles.

(define-shader far-vertex :vertex ((position :dfvec3)
                                   &uniform (eye :dfvec3 :group :frame)
                                            (view-projection :mat4 :group :frame))
  "Place POSITION, far from the origin, as seen from EYE."
  ;; Relative to the eye, the position is small, and a float holds it.
  (* view-projection (vec4 (float (- position eye)) 1)))

(let ((program (compile-program '(:vertex far-vertex) :target :vulkan)))
  (values (program-features program)
          (program-vertex-inputs program)
          (program-vertex-buffers program)))
;; => (:ROUNDING-MODE-RTE-FLOAT32)
;;    (#S(VERTEX-INPUT :NAME POSITION :LOCATION 0 :TYPE :DFVEC3 :FORMATS (:FLOAT32X3 :FLOAT32X3)))
;;    ((:STRIDE 24 :ATTRIBUTES ((0 :FLOAT32X3 0) (1 :FLOAT32X3 12)) :STEP :VERTEX))

A vertex buffer gives a pair as two float attributes, the high parts at the input's location and the low parts at the next. In Lisp, a pair is the double-float it holds: pack-value and pack-uniforms write the pair nearest a real, and dfloat-parts gives its two floats, as a vertex buffer holds them:

(dfloat-parts 6371000.123456789d0)  ; => 6371000.0, 0.12345679
(dfloat-parts 0.1d0)                ; => 0.1, -1.4901161e-9
(dfloat-parts 1.5)                  ; => 1.5, 0.0
Function Takes and gives
(dfloat number) number as a pair, or a vector of them: a :float, an :int or a :uint exactly, a :double as the pair nearest it, which needs the device's doubles, and a pair as it is. A number written in the code becomes the pair nearest it
(dfvec2 ...) to (dfvec4 ...) pairs and vectors of them, as the other constructors take theirs
(float pair [prototype]) the :float nearest the pair, its high part, or a vector of them; with 1d0, the :double nearest it
(+ ...), (- ...), (* ...), (/ ...) pairs of one type, a pair among vectors of them; (- p) negates, and (/ p) is the reciprocal
(sqrt pair), (abs pair) the square root, or the magnitude, component by component
(min pair ...), (max pair ...) as of numbers
=, /=, <, >, <=, >=, zerop, plusp, minusp scalar pairs, as of reals; vectors of them compare through every and its kin
truncate, floor, ceiling, round, their f kinds, mod and rem as of floats: the quotient an :int, a pair for the f kinds, and the remainder a pair

Floats and pairs do not mix:

(define-function mixed-pair ((p :dfloat) (x :float))
  (+ p x))
In MIXED-PAIR: + takes numbers of one type, :INT, :UINT, :FLOAT, :DOUBLE or
:DFLOAT, scalars or vectors, or a vector and a scalar of its components;
matrices of one type; or complex numbers, or a complex number and a real of its
parts' kind, not :DFLOAT, :FLOAT.  Float pairs and other floats do not mix:
(dfloat x) makes the :FLOATs or :DOUBLEs of X float pairs, and (float x) the
pairs of X the :FLOATs nearest them.
  in: (+ P X)

and (+ p (dfloat x)) adds them as pairs. Other functions refuse pairs.

Each operation is within a few units of the last of the 48 bits a pair holds. They are the formulas of the QD library of Hida, Li and Bailey, for pairs of doubles, made for floats: sums by Knuth's two-sum; products by Dekker's, which needs no fused multiply-add; quotients in three steps, each correcting those before, as Vulkan grants a division of floats only 2.5 units in the last place; and square roots by two of Newton's steps from the GPU's. A sum is about 20 operations of floats, and a product about 25. They are clsl's own shader functions, and a module includes each that it calls once. Their operations are decorated NoContraction, which forbids a driver to fuse them, as a product and a sum into a fused multiply-add, or to reorder or fold them, as any of these would lose the low part.

Pairs need the GPU to round +, - and * of floats to the nearest float, which Vulkan promises only to a shader that declares RoundingModeRTE: a module that uses pairs declares it for floats, and needs the device property :rounding-mode-rte-float32, as nearly every Vulkan 1.4 device has. A pair keeps its precision for magnitudes from about 10^-28 to a float's largest: below, the errors of its parts fall among the floats below 2^-126, which a GPU may flush to zero. Beyond the range, or divided by zero, a pair gives a NaN or an infinity, as floats do.

In a uniform block a pair is laid out as a :vec2 of its high and low parts, and a vector of pairs as an array of them, which std140 strides by 16 bytes: a :dfvec3 takes 48 bytes there, and 24 in a storage buffer, by std430. Pairs pass between stages :flat, as a GPU would interpolate a pair as two floats.