A signed saturating add through NAK
I was browsing Mesa issues when add.sat.s32 caught my
eye. It's signed addition with saturation instead of wraparound.
NVIDIA's compiler knew what to do with it; NAK didn't have a native
path. The issue already had a five-instruction solution, which looked
like a manageable thing to investigate. Famous last words.
Faith Ekstrand had already posted that five-instruction sequence in
the
Mesa issue. I wasn't discovering the arithmetic. I wanted to know how to make
NAK express those .SIGN operands, encode them, and keep
the later passes from undoing it.
Some names that will come up:
PTX NVIDIA's portable assembly language
SASS the machine instructions executed by an NVIDIA GPU
NIR Mesa's shared compiler intermediate representation
NAK Mesa's NVIDIA shader backend: NIR → SASS
GPR a 32-bit general-purpose register, one value per GPU lane
Pred a one-bit predicate register used for conditions
Five instructions. What could go wrong?
The issue contained this SASS:
IADD3 R0, R2, R5, RZ
PLOP3.LUT P0, PT, R5.SIGN, R2.SIGN, R0.SIGN, 0x2, 0x0
PLOP3.LUT P1, PT, R5.SIGN, R2.SIGN, R0.SIGN, 0x40, 0x0
SEL R9, R0, 0x7fffffff, !P0
SEL R9, R9, 0x80000000, !P1
An add, two Boolean lookups, two selects. Fine. But look at
R5.SIGN. That's a GPR sign bit, not an ordinary
predicate. NAK could already encode predicate PLOP3; this
was a different operand form. The disassembler knew its name. Did NAK
know how to represent it?
Why NAK never saw the instruction
NIR already had nir_op_iadd_sat. NAK requested generic
lowering unconditionally:
lower_uadd_sat: dev.sm < 70,
lower_usub_sat: dev.sm < 70,
lower_iadd_sat: true, // TODO
So the operation disappeared before my encoder could possibly see it. I let it survive on SM70+, then followed it through NIR translation, NAK IR, legalization, and encoding. Five machine instructions had already become a compiler-wide tour.
What are those two LUT values?
The two LUT constants looked like magic numbers until I wrote down the
three signs: x, y, and the wrapping sum. Two
positive inputs with a negative sum mean positive overflow; two
negative inputs with a nonnegative sum mean negative overflow. Only
two rows of the truth table matter.
x.sign y.sign sum.sign result
0 0 1 positive overflow → INT_MAX
1 1 0 negative overflow → INT_MIN
Those rows are 001 and 110 in the chosen
input order. Bits 1 and 6: 0x02 and 0x40.
Nice. Now for the less tidy question: what bytes does the GPU expect?
sum = IADD3(x, y, 0)
pos_overflow = PLOP3(x.sign, y.sign, sum.sign, LUT=0x02)
neg_overflow = PLOP3(x.sign, y.sign, sum.sign, LUT=0x40)
tmp = SEL(pos_overflow, INT_MAX, sum)
dst = SEL(neg_overflow, INT_MIN, tmp)
Let the existing compiler show its hand
I let NVIDIA's tools answer first. A tiny PTX kernel through
ptxas, then nvdisasm to see the machine
instructions and their encoding. No need to guess from the mnemonic.
.version 8.8
.target sm_89
.address_size 64
.visible .entry test_iadd_sat(
.param .u64 out_ptr,
.param .u64 in_ptr)
{
.reg .b64 %rd<4>;
.reg .s32 %r<4>;
ld.param.u64 %rd1, [out_ptr];
ld.param.u64 %rd2, [in_ptr];
add.u64 %rd3, %rd2, 4;
ld.global.s32 %r1, [%rd2];
ld.global.s32 %r2, [%rd3];
add.sat.s32 %r3, %r1, %r2;
st.global.u32 [%rd1], %r3;
ret;
}
The corresponding ptxas and
nvdisasm commands are:
ptxas -arch=sm_89 iadd_sat.ptx -o iadd_sat.cubin
nvdisasm --print-code --print-instruction-encoding iadd_sat.cubin |
rg 'IADD3|PLOP3|SEL'
The relevant output was the same five-instruction core shown in the issue:
IADD3 R2, R0, R3, RZ
PLOP3.LUT P0, PT, R0.SIGN, R3.SIGN, R2.SIGN, 0x2, 0x0
PLOP3.LUT P1, PT, R0.SIGN, R3.SIGN, R2.SIGN, 0x40, 0x0
SEL R7, R2, 0x7fffffff, !P0
SEL R7, R7, 0x80000000, !P1
The five-instruction core matched. I briefly considered reconstructing
the whole 128-bit PLOP3 format, but most of it was
already in NAK: the ordinary predicate variant, common ALU fields, and
destinations. I compared the known form with the CUDA-produced
.SIGN form and filled in what was missing.
The first encoder version reused NAK's common three-source ALU
encoding. The comparison left only the PLOP3 form, LUT,
and two predicate destinations to fill in:
e.encode_alu(0x01f, None,
Some(&self.srcs[0]),
Some(&self.srcs[1]),
Some(&self.srcs[2]));
e.set_field(72..80, self.ops[0].lut);
e.set_pred_dst(81..84, &self.dsts[0]);
e.set_pred_dst(84..87, &self.dsts[1]);
I then made NAK's disassembly test encode the IR and ask NVIDIA's decoder for the expected text:
export NAK_TEST=/path/to/mesa-build/src/nouveau/compiler/nak
PATH=/path/to/cuda/bin:$PATH \
"$NAK_TEST" \
--test --exact nvdisasm_tests::test_plop3 --test-threads 1
nvdisasm could read the new encoding. Good. It still
hadn't executed anything, though. For that I needed an actual GPU and
inputs near signed overflow.
A model that was just enough
My first IR model was deliberately small: one flag for either all-predicate sources or all-GPR-sign sources. Both overflow lookups needed three signs, so it covered the case in front of me. I didn't yet know whether mixed inputs needed their own representation.
The patch connected the whole vertical slice:
NIR iadd_sat
→ NAK builder: IADD3 + two PLOP3 + two SEL
→ OpPLop3 with sign-mode GPR sources
→ legalization
→ SM70+ encoding
→ AD107
The local hardware test stores the GPU result and compares it with
Rust's saturating_add for boundary inputs and 100
deterministic random pairs:
PATH=/path/to/cuda/bin:$PATH \
"$NAK_TEST" \
--test --exact hw_tests::test_iadd_sat --test-threads 1
The GPU test passed. CTS disagreed.
The focused GPU test passed. So did the decoder test. Then I ran CTS: 152 failures, all at a compiler assertion. Not exactly the victory lap I had in mind.
844 pass
152 fail
failure: NAK assertion during pipeline compilation
At least the GPU hadn't produced 152 mysterious wrong answers. It hadn't executed those shaders at all.
My small test used ordinary per-lane GPRs. CTS brought in values that
NAK could put in uniform registers, and NAK still considered every
PLOP3 uniform-capable. My .SIGN encoding
only accepted GPRs. The two assumptions met at the encoder assertion.
I left the assertion alone and fixed the path leading to it:
.SIGN PLOP3 was marked non-uniform, inputs
were copied to GPRs when needed, and predicate optimizations stopped
treating its sources as predicates. The CTS filter then passed all 996
cases.
// Ordinary predicate PLOP3 may be uniform; .SIGN PLOP3 may not.
Op::PLop3(op) => !op.src_sign,
// The encoder's .SIGN form consumes normal GPRs.
if self.src_sign {
for src in &mut self.srcs {
b.copy_alu_src_if_not_reg(src, RegFile::GPR, SrcType::GPR);
}
}
uniform value
→ copy to a normal GPR in each lane
→ warp PLOP3 R?.SIGN, R?.SIGN, R?.SIGN
export MESA_PREFIX=/path/to/patched-mesa-install
export VK_CTS=/path/to/VK-GL-CTS/build/external/vulkancts/modules/vulkan/deqp-vk
env VK_DRIVER_FILES="$MESA_PREFIX/share/vulkan/icd.d/nouveau_icd.x86_64.json" \
LD_LIBRARY_PATH="$MESA_PREFIX/lib" \
MESA_SHADER_CACHE_DISABLE=true \
"$VK_CTS" -n \
'dEQP-VK.spirv_assembly.instruction.compute.op*dotaccsatkhr.*' \
--deqp-log-filename=iadd_sat.qpa \
--deqp-log-images=disable
Passed: 996/996
Failed: 0
Is .SIGN really an instruction-wide switch?
Then I read Faith's proposed direction and Máté's work-in-progress.
They treated .SIGN as a modifier on each source, not one
switch for the whole instruction. That made my first model look rather
narrow. Fair enough; it was built for one operation.
With three sources, each predicate (P) or sign bit (S), there are eight logical layouts. I knew the all-predicate and all-sign forms. The mixed ones were the problem. Did the hardware really have a separate encoding for every layout?
I kept a known instruction fixed, varied its 12-bit opcode field
through all 4,096 possibilities, and kept the candidates that
nvdisasm decoded as PLOP3. Brute force, but
over twelve bits it is the polite kind.
PPP PPS PSP PSS SPP SPS SSP SSS
The scan only changed those 12 bits, so it could tell me about decoded opcode forms for that template—not every possible encoding or its runtime semantics.
0x81c PPP: predicate-only PLOP3
0x21d PSP: one .SIGN source
0x21e PSS: two .SIGN sources
0x21f SSS: three .SIGN sources
0xa1d–0xa1f constant-buffer source variants
0x89c uniform UPLOP3
The scanner kept the other 116 bits fixed, replaced bits 0–11, and
retained anything nvdisasm recognized as
PLOP3:
let base = [0x0200721f_u32, 0x00000003,
0x00700204, 0x000fc000];
for opcode in 0_u32..0x1000 {
let code = [
(base[0] & !0xfff) | opcode,
base[1], base[2], base[3],
];
// Write code as little-endian bytes, then:
nvdisasm -b SM89 --print-raw candidate.bin
}
For example, the controlled template changes like this:
logical form four 32-bit words low 12 bits
PPP 0200781c 00000003 00700204 000fc000 0x81c
SSS 0200721f 00000003 00700204 000fc000 0x21f
^ the scanned field is here
The scan gave me decoded forms, not execution semantics. Time to put them on the AD107.
Only four useful canonical forms turned up, not eight. The other
logical layouts could be reordered into those forms. There's a catch:
PLOP3 is a truth table, so swapping sources without
permuting the LUT changes the function. A perfectly valid instruction
can compute the wrong Boolean expression.
For a concrete reorder, suppose the logical operation is
SPP(a, b, c, lut): a is a GPR sign bit,
while b and c are predicates. The hardware
form puts the one sign source in the middle slot:
logical: SPP(a, b, c, lut)
physical: PSP(b, a, c, permute_xy(lut))
Only swapping a and b would make LUT input
x receive the old y. Applying the same
permutation to the LUT preserves all eight rows of the original
boolean function.
Now make the GPU check our reordering
I supplied Booleans as predicates or as GPR sign bits using
0 and INT_MIN, checked all eight layouts,
and chose asymmetric LUTs (0x1b and 0xe4) so
a mistaken swap wouldn't slip past. That's 128 result checks.
8 source layouts × 8 truth-table rows × 2 LUTs = 128 result checks
I also tried persuading ptxas to generate mixed-source
PLOP3 itself. It kept getting in the way, optimizing
simple probes into ISETP. Adding PTX
vote got me a PLOP3, but
ptxas materialized the sign as a predicate first. Still
no mixed-source example.
sx = (x < 0)
out = LUT(sx, p, q)
sign test + predicate logic → ISETP
opaque predicates via vote → predicate materialization → PPP PLOP3
I didn't preserve those exact exploratory PTX files, so I'm
deliberately showing the idea rather than pretending these are
reproducible original inputs. And this tells us only what
ptxas chose for my probes, not which mixed layouts the
hardware supports. The decoder experiment and hardware truth-table
test are the stronger evidence for that question.
Where I stopped
The narrow iadd_sat path passed the AD107 hardware test,
decoder test, and 996 CTS cases. I also tried a Fossilize corpus: no
shader-output changes, because none of those shaders contained a
surviving nir_op_iadd_sat. Useful coverage information,
not a performance result.
The broader per-source model isn't ready to merge. Its 128 truth-table checks passed, but two of 62 NAK tests still failed. I posted the findings on the issue and asked the maintainers about the representation and patch split. No MR yet; I'd rather resolve those failures than present a tidy ending I haven't earned.
References
- Mesa work item #14153 — original CUDA sequence, LUT values, Vulkan property scope, and follow-up discussion.
-
Mesa NIR documentation
— the shared IR in which
nir_op_iadd_satlives. -
PTX integer
add— semantics ofadd.sat.s32. -
CUDA Binary Utilities
—
nvdisasmusage and instruction-encoding output. - SPIR-V specification — signed, unsigned, and mixed-signedness dot-accumulate-saturate operations exercised by CTS.