From e2051c5a41596dfc45f8ab9c92af5b442e0fe18a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 14:38:36 +0200 Subject: [PATCH 01/21] cultmath map: Pass 9c root cause of the span_fbm/span_ridged 1-ulp diff (entry -O3 reassociation, lavapipe use-count fusion); strict-form probes Co-Authored-By: Claude Opus 5.5 --- docs/cultmath-interval-ground-cut.md | 29 ++++++++++ .../native-shader-probes/strict-form/nc.py | 11 ++++ .../strict-form/probe1.sh | 19 +++++++ .../strict-form/probe2.sh | 46 ++++++++++++++++ .../strict-form/probe3.sh | 53 +++++++++++++++++++ .../strict-form/probe4.sh | 53 +++++++++++++++++++ 6 files changed, 211 insertions(+) create mode 100644 docs/research/native-shader-probes/strict-form/nc.py create mode 100644 docs/research/native-shader-probes/strict-form/probe1.sh create mode 100644 docs/research/native-shader-probes/strict-form/probe2.sh create mode 100644 docs/research/native-shader-probes/strict-form/probe3.sh create mode 100644 docs/research/native-shader-probes/strict-form/probe4.sh diff --git a/docs/cultmath-interval-ground-cut.md b/docs/cultmath-interval-ground-cut.md index 2cce2d75..8e86b70e 100644 --- a/docs/cultmath-interval-ground-cut.md +++ b/docs/cultmath-interval-ground-cut.md @@ -2340,6 +2340,35 @@ and 16:50 UTC (18:30 to 18:50 CEST). Logs are in the Imagination scratchpad the committed GLSL is 80, 9, 10, 18. `--relax-nan-checks` changes no lavapipe bit. So the cleaned route is closer to the HLSL than today's GLSL is, and "bit-identical to today's lowering" cannot be its gate. With apt's spirv-tools 2025.1~rc1 the figures for this route are the same. +- **Why `span_fbm` and `span_ridged` differ by 1 ulp (Pass 9c).** Imagination `imagination-ag4-span-ulp`, + 2026-10-17, on 23ac4e89, same image and tools as `cut-glsl-build-dxc.h1`, probes in the session + scratchpad `logs/ag4-ulp/` (`probe1.sh` to `probe4.sh`). Only the curvature word (word 4 of 6) differs, in + 41 and 42 of 1536 words. Two causes, both measured: + - **dxc's entry-point optimiser reassociates.** `cultmath_snoise_hessian` ends every entry in + `105.0 * (...)`. In the `-O3` entry module the 105 is hoisted out: nine squares are summed bare and + the sum is multiplied once (`OpFMul %11025 %sum`, then `Sqrt`), where the source squares `105 * a` + nine times. That is real arithmetic, not float arithmetic. Neither `NoContraction` nor + `--relax-nan-checks` touches it. `dxc -Gis` (IEEE strictness) stops it. + - **The driver contracts mul+add by use count.** lavapipe (NIR) fuses an `OpFMul` into the `OpFAdd` + that consumes it only when the product has one use. dxc's CSE shares the symmetric Hessian's + off-diagonal squares (two uses each), so the entry module fuses some of the nine pairs; the `-Od` + library module has nine single-use products and fuses all nine. The `OpFAdd` order is the same in + both (left fold, as the C# `span_frobenius`). + - Decorating every `OpFMul`, `OpFAdd`, `OpFSub`, `OpVectorTimesScalar` and `OpDot` `NoContraction` in + both final modules, f103 and f104: `-Od` HLSL equals the built GLSL (0 words differ); `-O3` + HLSL still differs in 31 and 44 (the hoist). `-Gis` (with `-O3`) plus `NoContraction` equals the built GLSL on + 127 of 127 families, and so does `-Od` plus `NoContraction`. Plain `-Od` HLSL differs from the built GLSL in 664 words + (contraction depends on use structure), so `-Od` alone is worse than `-O3`: your suspicion of `-Od` + on the HLSL route is not a fix. + - **Which is right.** Against the C# fixture, exact cases of 256 for f103 are 3 (HLSL) and 4 (GLSL) plain, + and 151 for both under `NoContraction`; for f104, 7 and 10 plain, 141 under it. Of the first 60 families whose + count moved, the strict form is nearer the C# in all but one, which moved by 1. Strict IEEE, no contraction, no + reassociation is what C# computes, so it is "right"; the plain routes are each one driver's + fusion pattern. The built GLSL is source-faithful (nothing in the `-Od` route reassociates). + - **What makes them agree.** The equality gate (`GLSL_DIR` mode) compares both routes in the strict + form: HLSL entry built with `-Gis`, and a harness pass that adds `NoContraction` to every float op of + both modules (about 12 lines of `spirv-dis` and `spirv-as`; `tools/device/nc.py` in the probe). That is + 127 of 127. The shipped GLSL and the C# bounds judge are unchanged. See question `glsl-gate-strict-form`. - **Capability strip.** `spirv-opt --trim-capabilities` (spirv-tools 2025.1~rc1) leaves all 14 capabilities and both extensions in place, and SPIRV-Cross still refuses ("RayQuery requires Vulkan GLSL 460"). Stripping every capability except Shader and Linkage and every OpExtension in the diff --git a/docs/research/native-shader-probes/strict-form/nc.py b/docs/research/native-shader-probes/strict-form/nc.py new file mode 100644 index 00000000..4771b66c --- /dev/null +++ b/docs/research/native-shader-probes/strict-form/nc.py @@ -0,0 +1,11 @@ +import re,sys +src,dst=sys.argv[1:3] +L=open(src).read().split('\n') +ids=[] +for l in L: + m=re.match(r'\s*(%\w+) = Op(FMul|FAdd|FSub|VectorTimesScalar|Dot)\b',l) + if m: ids.append(m.group(1)) +i=next(k for k,l in enumerate(L) if re.search(r'= OpType',l)) +L[i:i]=[' OpDecorate %s NoContraction'%x for x in ids] +open(dst,'w').write('\n'.join(L)) +print(src,len(ids)) diff --git a/docs/research/native-shader-probes/strict-form/probe1.sh b/docs/research/native-shader-probes/strict-form/probe1.sh new file mode 100644 index 00000000..f0baa154 --- /dev/null +++ b/docs/research/native-shader-probes/strict-form/probe1.sh @@ -0,0 +1,19 @@ +set -e +apt-get update -qq >/dev/null; apt-get install -y -qq --no-install-recommends python3 spirv-tools >/dev/null 2>&1 +P=packages/cultmath/tools +python3 - <<'PY' +p='packages/cultmath/tools/device/run-lavapipe.sh' +s=open(p).read() +k='python3 "$DEV/gen.py" "$W/work" "$CM"\n' +s=s.replace(k,k+'sed -i -E "s/[.](cos|sin)([^A-Za-z0-9_])/._\1\2/g" "$W"/work/*.comp\ngrep -E "^f10[34]\t" "$W/work/manifest.txt" >"$W/work/m2"; mv "$W/work/m2" "$W/work/manifest.txt"\n') +open(p,'w').write(s) +PY +bash $P/glsl/build-glsl.sh /tmp/glslout >/dev/null +GLSL_DIR=/tmp/glslout bash $P/device/run-lavapipe.sh 2>&1 | tail -4 || true +W=/tmp/device/work +for t in f103 f104; do for r in h g; do + spirv-dis --raw-id $W/$t.$r.spv -o /tmp/$t.$r.dis + echo "== $t $r: NoContraction $(grep -c NoContraction /tmp/$t.$r.dis) Fma $(grep -c ' Fma ' /tmp/$t.$r.dis) FMul $(grep -c OpFMul /tmp/$t.$r.dis) FAdd $(grep -c OpFAdd /tmp/$t.$r.dis) FSub $(grep -c OpFSub /tmp/$t.$r.dis) lines $(wc -l /dev/null; apt-get install -y -qq --no-install-recommends python3 spirv-tools >/dev/null 2>&1 +cat >/tmp/nc.py <<'PYEOF' +import re,sys +src,dst=sys.argv[1:3] +L=open(src).read().split('\n') +ids=[] +for l in L: + m=re.match(r'\s*(%\w+) = Op(FMul|FAdd|FSub|VectorTimesScalar|Dot)\b',l) + if m: ids.append(m.group(1)) +i=next(k for k,l in enumerate(L) if re.search(r'= OpType',l)) +L[i:i]=[' OpDecorate %s NoContraction'%x for x in ids] +open(dst,'w').write('\n'.join(L)) +print(src,len(ids)) +PYEOF +P=packages/cultmath/tools +python3 - <<'PY' +p='packages/cultmath/tools/device/run-lavapipe.sh' +s=open(p).read() +k='python3 "$DEV/gen.py" "$W/work" "$CM"\n' +s=s.replace(k,k+'sed -i -E "s/[.](cos|sin)([^A-Za-z0-9_])/._\1\2/g" "$W"/work/*.comp\ngrep -E "^f10[34]\t" "$W/work/manifest.txt" >"$W/work/m2"; mv "$W/work/m2" "$W/work/manifest.txt"\n') +open(p,'w').write(s) +PY +bash $P/glsl/build-glsl.sh /tmp/glslout >/dev/null +GLSL_DIR=/tmp/glslout bash $P/device/run-lavapipe.sh 2>&1 | tail -4 || true +W=/tmp/device/work; R=/tmp/device +mkdir -p $R/nc/lavapipe-hlsl $R/nc/lavapipe-glsl +while IFS=$'\t' read -r tag _ _ n _ wout; do + for r in h g; do + spirv-dis --raw-id $W/$tag.$r.spv -o /tmp/$tag.$r.dis + python3 /tmp/nc.py /tmp/$tag.$r.dis /tmp/$tag.$r.nc.dis + spirv-as --target-env vulkan1.2 /tmp/$tag.$r.nc.dis -o $W/$tag.$r.nc.spv + spirv-val --target-env vulkan1.2 $W/$tag.$r.nc.spv + d=hlsl; [ $r = g ] && d=glsl + $R/vkrun $W/$tag.$r.nc.spv $W/$tag.in $R/nc/lavapipe-$d/$tag.out $n $wout >/dev/null + done + echo "$tag nc: hlsl==glsl $(cmp -s $R/nc/lavapipe-hlsl/$tag.out $R/nc/lavapipe-glsl/$tag.out && echo YES || echo NO); nc-hlsl==plain-hlsl $(cmp -s $R/nc/lavapipe-hlsl/$tag.out $R/results/lavapipe-hlsl/$tag.out && echo YES || echo NO); nc-glsl==plain-glsl $(cmp -s $R/nc/lavapipe-glsl/$tag.out $R/results/lavapipe-glsl/$tag.out && echo YES || echo NO)" +done <$W/manifest.txt +cp /tmp/device/vkrun /tmp/ 2>/dev/null || true +export CULTMATH_DEVICE_NAME=$(sort -u $R/device.txt | head -1 | sed 's/^device=//') CULTMATH_DEVICE_IDENTITY=x CULTMATH_WRITE_DEVICE_BOUNDS=1 +for v in results nc; do + export CULTMATH_DEVICE_RESULTS=$R/$v + dotnet test packages/cultmath/tests/CultMath.Tests --no-build --filter "FullyQualifiedName~GlslMirrorTests.DeviceResultsHoldFixture" >/tmp/judge.$v.log 2>&1 || true + echo "== judge $v"; grep -E "^(lavapipe-)" packages/cultmath/tests/CultMath.Tests/fixtures/device-bounds-lavapipe.txt | grep -E " 10[34] " | cut -f1,2,5,6 || tail -5 /tmp/judge.$v.log + tail -3 /tmp/judge.$v.log +done diff --git a/docs/research/native-shader-probes/strict-form/probe3.sh b/docs/research/native-shader-probes/strict-form/probe3.sh new file mode 100644 index 00000000..d1585a9e --- /dev/null +++ b/docs/research/native-shader-probes/strict-form/probe3.sh @@ -0,0 +1,53 @@ +set -e +apt-get update -qq >/dev/null; apt-get install -y -qq --no-install-recommends python3 spirv-tools >/dev/null 2>&1 +cat >/tmp/nc.py <<'PYEOF' +import re,sys +src,dst=sys.argv[1:3] +L=open(src).read().split('\n') +ids=[] +for l in L: + m=re.match(r'\s*(%\w+) = Op(FMul|FAdd|FSub|VectorTimesScalar|Dot)\b',l) + if m: ids.append(m.group(1)) +i=next(k for k,l in enumerate(L) if re.search(r'= OpType',l)) +L[i:i]=[' OpDecorate %s NoContraction'%x for x in ids] +open(dst,'w').write('\n'.join(L)) +print(src,len(ids)) +PYEOF +P=packages/cultmath/tools +python3 - <<'PY' +p='packages/cultmath/tools/device/run-lavapipe.sh' +s=open(p).read() +k='python3 "$DEV/gen.py" "$W/work" "$CM"\n' +s=s.replace(k,k+'sed -i -E "s/[.](cos|sin)([^A-Za-z0-9_])/._\1\2/g" "$W"/work/*.comp\ngrep -E "^f10[34]\t" "$W/work/manifest.txt" >"$W/work/m2"; mv "$W/work/m2" "$W/work/manifest.txt"\n') +open(p,'w').write(s) +PY +bash $P/glsl/build-glsl.sh /tmp/glslout >/dev/null +GLSL_DIR=/tmp/glslout bash $P/device/run-lavapipe.sh 2>&1 | tail -4 || true +W=/tmp/device/work; R=/tmp/device +DXC=$(find $R/dxc -name dxc -type f | head -1); export LD_LIBRARY_PATH=$(dirname $DXC)/../lib:$(dirname $DXC) +CM=packages/cultmath +cat >/tmp/cnt.py <<'PYEOF' +import sys,struct +a=open(sys.argv[1],'rb').read();b=open(sys.argv[2],'rb').read() +n=len(a)//4;x=struct.unpack('<%dI'%n,a);y=struct.unpack('<%dI'%n,b) +d=[i for i in range(n) if x[i]!=y[i]] +print(len(d),'of',n,'cols',sorted(set(i%6 for i in d))) +PYEOF +run() { # name spv tag n wout + $R/vkrun $2 $W/$3.in /tmp/o.$1.$3 $4 $5 >/dev/null; } +OPT="--target-env=spv1.5 --eliminate-local-single-store --eliminate-dead-code-aggressive" +while IFS=$' ' read -r tag _ _ n _ wout; do + for r in h g; do spirv-dis --raw-id $W/$tag.$r.spv -o /tmp/$tag.$r.dis; python3 /tmp/nc.py /tmp/$tag.$r.dis /tmp/$tag.$r.nc.dis >/dev/null; spirv-as --target-env vulkan1.2 /tmp/$tag.$r.nc.dis -o $W/$tag.$r.nc.spv; done + # h variants + $DXC -T cs_6_0 -E main -HV 2021 -spirv -fspv-target-env=vulkan1.2 -Od -I $CM/shaders $W/$tag.hlsl -Fo $W/$tag.hOd.spv >/dev/null 2>&1 + spirv-opt $OPT $W/$tag.hOd.spv -o $W/$tag.hOdO.spv + for v in hOd hOdO; do spirv-dis --raw-id $W/$tag.$v.spv -o /tmp/$tag.$v.dis; python3 /tmp/nc.py /tmp/$tag.$v.dis /tmp/$tag.$v.nc.dis >/dev/null; spirv-as --target-env vulkan1.2 /tmp/$tag.$v.nc.dis -o $W/$tag.$v.nc.spv; done + for v in h g hOd hOdO; do run $v $W/$tag.$v.spv $tag $n $wout; run $v.nc $W/$tag.$v.nc.spv $tag $n $wout; done + for v in h hOd hOdO; do + echo "$tag $v vs g: $(python3 /tmp/cnt.py /tmp/o.$v.$tag /tmp/o.g.$tag)" + echo "$tag $v.nc vs g.nc: $(python3 /tmp/cnt.py /tmp/o.$v.nc.$tag /tmp/o.g.nc.$tag)" + done + echo "$tag h.nc vs h: $(python3 /tmp/cnt.py /tmp/o.h.nc.$tag /tmp/o.h.$tag)" + echo "$tag g.nc vs g: $(python3 /tmp/cnt.py /tmp/o.g.nc.$tag /tmp/o.g.$tag)" + echo "$tag hOd.nc vs hOd: $(python3 /tmp/cnt.py /tmp/o.hOd.nc.$tag /tmp/o.hOd.$tag)" +done <$W/manifest.txt diff --git a/docs/research/native-shader-probes/strict-form/probe4.sh b/docs/research/native-shader-probes/strict-form/probe4.sh new file mode 100644 index 00000000..5b521cf9 --- /dev/null +++ b/docs/research/native-shader-probes/strict-form/probe4.sh @@ -0,0 +1,53 @@ +set -e +apt-get update -qq >/dev/null; apt-get install -y -qq --no-install-recommends python3 spirv-tools >/dev/null 2>&1 +cat >/tmp/nc.py <<'PYEOF' +import re,sys +src,dst=sys.argv[1:3] +L=open(src).read().split('\n') +ids=[] +for l in L: + m=re.match(r'\s*(%\w+) = Op(FMul|FAdd|FSub|VectorTimesScalar|Dot)\b',l) + if m: ids.append(m.group(1)) +i=next(k for k,l in enumerate(L) if re.search(r'= OpType',l)) +L[i:i]=[' OpDecorate %s NoContraction'%x for x in ids] +open(dst,'w').write('\n'.join(L)) +print(src,len(ids)) +PYEOF +P=packages/cultmath/tools +python3 - <<'PY' +p='packages/cultmath/tools/device/run-lavapipe.sh' +s=open(p).read() +k='python3 "$DEV/gen.py" "$W/work" "$CM"\n' +s=s.replace(k,k+'sed -i -E "s/[.](cos|sin)([^A-Za-z0-9_])/._\\1\\2/g" "$W"/work/*.comp\n') +open(p,'w').write(s) +PY +bash $P/glsl/build-glsl.sh /tmp/glslout >/dev/null +GLSL_DIR=/tmp/glslout bash $P/device/run-lavapipe.sh 2>&1 | tail -4 || true +set +e +W=/tmp/device/work; R=/tmp/device +DXC=$(find $R/dxc -name dxc -type f | head -1); export LD_LIBRARY_PATH=$(dirname $DXC)/../lib:$(dirname $DXC) +CM=packages/cultmath +for d in plain ncod gis; do mkdir -p $R/$d/lavapipe-hlsl $R/$d/lavapipe-glsl; done +while IFS=$'\t' read -r tag _ _ n _ wout; do + spirv-dis --raw-id $W/$tag.g.spv -o /tmp/g.dis; python3 /tmp/nc.py /tmp/g.dis /tmp/g.nc.dis >/dev/null; spirv-as --target-env vulkan1.2 /tmp/g.nc.dis -o /tmp/g.nc.spv + $DXC -T cs_6_0 -E main -HV 2021 -spirv -fspv-target-env=vulkan1.2 -Od -I $CM/shaders $W/$tag.hlsl -Fo /tmp/hOd.spv >/dev/null 2>&1 + spirv-dis --raw-id /tmp/hOd.spv -o /tmp/hOd.dis; python3 /tmp/nc.py /tmp/hOd.dis /tmp/hOd.nc.dis >/dev/null; spirv-as --target-env vulkan1.2 /tmp/hOd.nc.dis -o /tmp/hOd.nc.spv + $DXC -T cs_6_0 -E main -HV 2021 -spirv -fspv-target-env=vulkan1.2 -Gis -I $CM/shaders $W/$tag.hlsl -Fo /tmp/hGis.spv >/dev/null 2>&1 + spirv-dis --raw-id /tmp/hGis.spv -o /tmp/hGis.dis; python3 /tmp/nc.py /tmp/hGis.dis /tmp/hGis.nc.dis >/dev/null; spirv-as --target-env vulkan1.2 /tmp/hGis.nc.dis -o /tmp/hGis.nc.spv + cp $R/results/lavapipe-hlsl/$tag.out $R/plain/lavapipe-hlsl/; cp $R/results/lavapipe-glsl/$tag.out $R/plain/lavapipe-glsl/ + $R/vkrun /tmp/hOd.nc.spv $W/$tag.in $R/ncod/lavapipe-hlsl/$tag.out $n $wout >/dev/null + $R/vkrun /tmp/g.nc.spv $W/$tag.in $R/ncod/lavapipe-glsl/$tag.out $n $wout >/dev/null + $R/vkrun /tmp/hGis.nc.spv $W/$tag.in $R/gis/lavapipe-hlsl/$tag.out $n $wout >/dev/null + cp $R/ncod/lavapipe-glsl/$tag.out $R/gis/lavapipe-glsl/ + for d in plain ncod gis; do cmp -s $R/$d/lavapipe-hlsl/$tag.out $R/$d/lavapipe-glsl/$tag.out || echo "EQ-FAIL $d $tag"; done +done <$W/manifest.txt +export CULTMATH_DEVICE_WORK=$W CULTMATH_DEVICE_NAME=$(sort -u $R/device.txt | head -1 | sed 's/^device=//') CULTMATH_DEVICE_IDENTITY=x CULTMATH_WRITE_DEVICE_BOUNDS=1 +B=packages/cultmath/tests/CultMath.Tests/fixtures/device-bounds-lavapipe.txt +for v in plain ncod gis; do + export CULTMATH_DEVICE_RESULTS=$R/$v + dotnet test packages/cultmath/tests/CultMath.Tests --no-build --filter "FullyQualifiedName~GlslMirrorTests.DeviceResultsHoldFixture" >/tmp/judge.$v.log 2>&1 || true + grep -aE "Error Message" -A8 /tmp/judge.$v.log | head -14; ls -la $B; cp $B /tmp/bounds.$v; echo "judge $v: $(tail -3 /tmp/judge.$v.log | tr '\n' ' ')" +done +for v in plain ncod gis; do awk -F'\t' -v v=$v '{k=$1; u[k]+=$5; if($5+0>m[k]+0)m[k]=$5; c[k]++} END{for(k in c) print "BOUNDS",v,k,"families",c[k],"maxulp",m[k],"sumulp",u[k]}' /tmp/bounds.$v; done +for v in plain ncod; do awk -F'\t' '{print $1"/"$2"\t"$5}' /tmp/bounds.$v | sort > /tmp/b.$v; done +join -t$'\t' /tmp/b.plain /tmp/b.ncod | awk -F'\t' '$2!=$3{print "ULPDIFF",$1,"plain",$2,"ncod",$3}' | head -60 From 3cd6815575ba3e8efba07ace6c372b616c6105b0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 15:55:03 +0200 Subject: [PATCH 02/21] Research: variable-length shader inputs (array+count vs macro vs constants), for shader-lattice-loop; map section shader-lattice-loop Co-Authored-By: Claude Opus 5.5 --- docs/cultmath-interval-ground-cut.md | 20 ++++ docs/research/shader-variable-inputs.md | 111 ++++++++++++++++++ .../shader-variable-inputs/entry-cbuffer.hlsl | 15 +++ .../shader-variable-inputs/entry.comp.tail | 16 +++ .../shader-variable-inputs/entry.hlsl | 16 +++ docs/research/shader-variable-inputs/judge.py | 14 +++ .../shader-variable-inputs/lattice-array.hlsl | 7 ++ .../macro-accessor.hlsl | 12 ++ docs/research/shader-variable-inputs/probe.sh | 55 +++++++++ .../shader-variable-inputs/prototype.hlsl | 75 ++++++++++++ .../shader-variable-inputs/ref/Program.cs | 50 ++++++++ .../shader-variable-inputs/ref/Ref.csproj | 4 + 12 files changed, 395 insertions(+) create mode 100644 docs/research/shader-variable-inputs.md create mode 100644 docs/research/shader-variable-inputs/entry-cbuffer.hlsl create mode 100644 docs/research/shader-variable-inputs/entry.comp.tail create mode 100644 docs/research/shader-variable-inputs/entry.hlsl create mode 100644 docs/research/shader-variable-inputs/judge.py create mode 100644 docs/research/shader-variable-inputs/lattice-array.hlsl create mode 100644 docs/research/shader-variable-inputs/macro-accessor.hlsl create mode 100644 docs/research/shader-variable-inputs/probe.sh create mode 100644 docs/research/shader-variable-inputs/prototype.hlsl create mode 100644 docs/research/shader-variable-inputs/ref/Program.cs create mode 100644 docs/research/shader-variable-inputs/ref/Ref.csproj diff --git a/docs/cultmath-interval-ground-cut.md b/docs/cultmath-interval-ground-cut.md index 8e86b70e..16de3bcf 100644 --- a/docs/cultmath-interval-ground-cut.md +++ b/docs/cultmath-interval-ground-cut.md @@ -2461,3 +2461,23 @@ and 16:50 UTC (18:30 to 18:50 CEST). Logs are in the Imagination scratchpad It settles `span-allowance-gpu-margin`'s six CPU survivors, `iv-exp-gpu-widening`'s measurement and `device-p2-pin-notes`, and closes `glsl-browser-parity-run` and `affine-device-runner-seed`. It waits on 2b, because a capture of the old lowering goes stale at the switch. + +### shader-lattice-loop (follow-ups `shader-weighted-lattice-loop`, `lattice-shader-pins`) + +Written by Imagination (agent `imagination-ag4-shader-loop`, session `self-2026-10-16-ag4`) against `origin/main` +`e2051c5a` and the build branch at `23ac4e89`. Research and probes: `docs/research/shader-variable-inputs.md`. +Spec: `cultmath-tapes:cut_spec:cut-shader-lattice-loop.r1`. Question: `shader-site-capacity` (gates the spec). + +- **Bodies reach the shader as `float4 sites[CULTMATH_MAX_SITES]` plus `int count`, by value.** A caller accessor + macro cannot survive the library build (the built GLSL has no consumer macros), and library-owned uniforms would + take a binding the consumer owns. dxc, the SPIR-V edit, SPIRV-Cross and a WebGL2-style uniform-array consumer all + accept the array form (probes P1-P3). +- **The shader owns the whole search.** `cultmath_weighted_lattice_cells` and `cultmath_power_border` replace the two + comment recipes; the four steps stay public. The box pass recomputes distances (no shader cache), the ring loop is + bounded by 37 with C#'s exit. +- **The lavapipe run kills the exit and the normaliser** (probe table, research section 3): M1-M6 fail the C# device + judge; M7, a late exit, is equivalent by the window-independence proof. M1 needs several far-ring points. +- **Smooth-step cap point** joins its family (m = f1 + smoothing, d past it, smoothing >= 0.5). +- **Yggdrasil's sdk:10.0 Ubuntu image owns `glsl-parity.json`'s platform values** with the lavapipe bounds; a write + on another OS is refused. +- Left: `shader-span-functions-array-form` (power_cells, multiplicative_cells, soft_gabriel_gate array forms). diff --git a/docs/research/shader-variable-inputs.md b/docs/research/shader-variable-inputs.md new file mode 100644 index 00000000..c8ea3296 --- /dev/null +++ b/docs/research/shader-variable-inputs.md @@ -0,0 +1,111 @@ +# Shader variable-length inputs: how shader libraries take a span, and what CultMath's build route allows + +For `cultmath-tapes:follow_up:shader-weighted-lattice-loop` and `cultmath-tapes:follow_up:lattice-shader-pins` (campaign `cultmath-tapes`). +Imagination pass, agent `imagination-ag4-shader-loop`, session `self-2026-10-16-ag4`, 2026-10-17, against CultLib `origin/main` `e2051c5a` +and the build branch `hands/cultmath-glsl-build-dxc` `23ac4e89`. Probes are in `docs/research/shader-variable-inputs/`. + +Tags: [src] primary source text read this pass; [web] search summary only; [probe] run this pass, output quoted below. + +## 1. The question + +C#'s `weighted_lattice_cells` reads the body span once per lattice cell (the weight is the exact power border of the +cell centre), and the ring search around it is the part a consumer cannot safely write by hand: the exit bound, +the two passes and the box-independent fold order. The shader port has the four steps only. A shader function cannot +take `ReadOnlySpan`, the library targets GLSL ES 3.00 fragment shaders (WebGL2: no storage buffers), and the GLSL is +built from the HLSL by dxc `lib_6_3` SPIR-V plus SPIRV-Cross (ruling `glsl-es-via-dxc-spirv-cross`). +So how do bodies reach a shader `weighted_lattice_cells`? + +## 2. Prior art: how shader code that ships as a library takes a variable number of inputs + +- [src] Unity URP (`ShaderLibrary/Input.hlsl`, `RealtimeLights.hlsl`, Graphics `master`): a fixed capacity + `MAX_VISIBLE_LIGHTS` chosen per platform (`MAX_VISIBLE_LIGHT_COUNT_LOW_END_MOBILE` under `SHADER_API_GLES30`, + `_MOBILE` for GLES3/WebGPU, `_DESKTOP` otherwise), constant-buffer arrays `float4 _AdditionalLightsPosition[MAX_VISIBLE_LIGHTS]` + and the like, with `StructuredBuffer` only behind `USE_STRUCTURED_BUFFER_FOR_LIGHT_DATA` (defined 0). The + count is a uniform read through `GetAdditionalLightsCount()`, and `GetAdditionalLight(uint i, float3 positionWS)` is + the accessor; `LIGHT_LOOP_BEGIN(lightCount)` / `LIGHT_LOOP_END` macros hide the cluster versus plain loop. The + pipeline owns the storage and the accessor; user shaders call them. Capacity plus count, storage owned by the include. +- [src] Filament (`shaders/src/surface_light_punctual.fs`, `main`): the loop is the engine's, over a froxel record + (`froxelsUniforms.records`, then `froxelRecordUniforms.records`) giving `recordOffset` and `count`; each light is + `lightsUniforms.lights[lightIndex]`, a uniform-block array. Material code never sees the loop. The engine owns both + the storage and the loop because it also owns the uniform layout. +- [src] three.js (`ShaderChunk/lights_fragment_begin.glsl.js`, `dev`): `for ( int i = 0; i < NUM_POINT_LIGHTS; i ++ )` + under `#pragma unroll_loop_start`, reading `pointLights[ i ]`; `NUM_POINT_LIGHTS` is a define the renderer writes, + so the shader is regenerated per light count. Count as a compile-time constant; the library is source text. +- [web] Bevy: clustered forward needs storage buffers; on WebGL2 it falls back to a uniform buffer with a capped + object count (`MAX_UNIFORM_BUFFER_CLUSTERABLE_OBJECTS` 204 in an older `bevy_pbr`, asserted to fit 16384 bytes; the + Many Lights example says 256 lights under `WGPU_SETTINGS_PRIO=webgl2`). Capacity plus count where storage is absent. +- [web] lygia (README conventions, via search): reuse by `#ifndef` / `#define` options the caller sets before the + include (`SAMPLER_FNC(TEX, UV)`, `MYFUNC_TYPE`, per-function sampler overrides). Caller-supplied accessor macros; the + library ships as source text, which is what lets a macro reach inside a function body. + +The pattern that splits them: a caller-supplied accessor (lygia, URP's accessor functions, three.js's define) works +where the library is source text the caller's preprocessor sees. Where the library is compiled or its storage layout is +fixed (URP's GLES path, Bevy's WebGL2 fallback, Filament's uniform blocks), the input is a fixed-capacity array plus a +count, and the capacity is a named library constant. + +## 3. What the build route allows (probes) + +| Candidate | HLSL (dxc) | Library SPIR-V and SPIRV-Cross GLSL | C# mirror (`TransformHlslToCSharp`) | Verdict | +|---|---|---|---|---| +| Fixed-size array parameter plus count | compiles, `lib_6_3` and `cs_6_0` [probe P1, P3] | see P2 | needs one transform (`T x[N]` parameter to `T[] x`) | see section 4 | +| Caller accessor macro (`CULTMATH_BODY(i)`) | the library build defines no accessor, so the function is absent [probe P4] | absent from the built GLSL: SPIRV-Cross output carries no consumer macro | the mirror would need the macro too | refused by the route | +| Bodies packed into library-owned constants | a library cbuffer or uniform | a uniform block in a function-only library; a binding the consumer does not own | a static field | refused (P5, by construction, not run): a library may not own a consumer's binding | + +Probes (files in `shader-variable-inputs/`): + +- [probe] P1, local, dxc 2026_07_29 (`.tools/dxc`): `lattice-array.hlsl` (CultMath.hlsl, `prototype.hlsl`, one export) + compiles with `-T lib_6_3 -HV 2021 -spirv -fspv-target-env=universal1.5 -Od`. The SPIR-V types the array as + `OpTypeArray %v4float %uint_16` behind a Function pointer, and `-Od` copies it at each call (`param_var_bodies`). +- [probe] P3, local: `entry-cbuffer.hlsl` passes a cbuffer array directly and a `StructuredBuffer` copied into a local + array; both compile to SPIR-V (`cs_6_0`) and to DXIL. +- [probe] P4, local: `macro-accessor.hlsl` with no `CULTMATH_BODY` defined fails, `use of undeclared identifier`: a + macro-parameterised function does not exist in a library compiled without the caller's macros, so the built GLSL + cannot carry it. Shipping it would need a hand-kept GLSL text beside the build, a second owner the ruling removed. +- [probe] P2, Yggdrasil job (`probe.sh` at `23ac4e89`, sdk:10.0, 2026-10-17): with the prototype appended to + `CultMath.hlsl` and two build-tool edits (`wrappers.py` strips `[N]` from an argument name; `partition.py` accepts + `static const int`), `build-glsl.sh` is green ("build shape ok", "suffixed ids: 0; inout: 0") and emits + `const int CULTMATH_MAX_SITES = 16;`, `vec4 cultmath_power_border(vec3 p, vec4 sites[16], int count)` and + `vec4 cultmath_weighted_lattice_cells(vec2 p, float time, vec4 bodies[16], int count, ...)`. A `#version 300 es` + fragment consumer passing `uniform vec4 zone[CULTMATH_MAX_SITES]` compiles with glslang. +- [probe] Same job, lavapipe (`llvmpipe (LLVM 20.1.2, 256 bits)`), 257 cases from `ref/Program.cs` (the ring-17 point + of `TheCapIsReachedAndMatchesTheFullBox` and 7 neighbours, four looks, five normals including (3e-30, -4e-30) and + (1e30, 2e30), a zero normal at stretch 1, counts 0, 1, 3, 6, 16), judged against C#'s bits at 2.2e-6 max(|v|, 1): + +| Variant (HLSL route unless named) | bit-exact | over bound | worst | +|---|---|---|---| +| built GLSL | 237 | 0 | 1.19e-6 | +| HLSL (equal to built GLSL bit for bit) | 237 | 0 | 1.19e-6 | +| M1 exit at `0.5 k` | 236 | 1 | 4.28e-3 | +| M2 exit ignores the smooth band (`>= f2`) | 226 | 13 | 8.33e-2 | +| M3 normaliser drops the divide by the largest component | 148 | 96 | 1e30 | +| M4 normaliser drops the divide by length | 1 | 208 | 0.259 | +| M5 smooth step loses its `min` cap | 153 | 85 | 2.26e-3 | +| M6 box pass skips its first row | 228 | 9 | 0.807 | +| M7 exit one ring late (`0.5 (k - 2)`) | 237 | 0 | 1.19e-6 | + +M7 is equivalent: the result does not depend on the box (the proof above `weighted_lattice_cells`), so a late exit +costs rings and changes nothing. M1 is killed by one case of 257, the ring-17 point: an early exit only matters where +a far ring wins, so the device family must carry several such points. + +## 4. Decision + +The fixed-size array plus count is the only candidate the route admits, and it is the shape the compiled-library +prior art uses (URP's GLES path, Bevy's WebGL2 fallback, Filament's uniform blocks). The capacity is a named library +constant, `CULTMATH_MAX_SITES`, shared by every span-taking shader function. Its value is +`cultmath-tapes:question:shader-site-capacity` (Aetheria zones: p50 17, p99 57, max 80 bodies, gravity map B28). + +The shader recomputes each distance in the box pass instead of caching it: C#'s 75 x 75 stack cache is not a shader +option, and recomputing gives the same value, as the probe's bit-exact HLSL and built-GLSL agreement shows. +Reflection cannot pass a `ReadOnlySpan`, so the mirror and the fixture reach the C# span functions through one +explicit adapter table rather than `MethodInfo.Invoke`. + +## 5. lattice-shader-pins + +- The smooth-step family draws m in [f1, f1 + smoothing), so the cap never binds. The cap binds when m = f1 + smoothing, + d = f1 + x smoothing with x around 1.3 to 1.9, and smoothing above about 0.25: next - m = (d - m) h - h (1 - h) + smoothing / (1 + 3 smoothing) is positive there (for smoothing 1 and d - m = 0.5, h = 0.156 and next - m = +0.045). + M5 above is killed by the whole function already; the step family should kill it on its own. +- Platform values: `8b31e47e` rewrote 7 platform-family entries of `glsl-parity.json` from "Ubuntu 24.04.5 LTS" to + "Microsoft Windows 10.0.26200", while `7ddb1040` recorded the lavapipe bounds on Yggdrasil against that fixture. + The bounds, the lavapipe job and the build route all run in Yggdrasil's sdk:10.0 Ubuntu image, so that image + owns the fixture's platform values too. A write on another OS is refused rather than reviewed. diff --git a/docs/research/shader-variable-inputs/entry-cbuffer.hlsl b/docs/research/shader-variable-inputs/entry-cbuffer.hlsl new file mode 100644 index 00000000..7e6ced39 --- /dev/null +++ b/docs/research/shader-variable-inputs/entry-cbuffer.hlsl @@ -0,0 +1,15 @@ +// Probe C: a compute entry passing a constant-buffer array, and one passing a StructuredBuffer copied to a local array. +#include "lattice-array.hlsl" +cbuffer Zone : register(b0) { float4 zoneBodies[CULTMATH_MAX_SITES]; int zoneCount; }; +StructuredBuffer bufferBodies : register(t1); +RWStructuredBuffer result : register(u2); +[numthreads(64, 1, 1)] +void main(uint3 id : SV_DispatchThreadID) +{ + float2 p = float2(id.x * 7.0, id.y * 11.0); + float4 local[CULTMATH_MAX_SITES]; + for (int i = 0; i < CULTMATH_MAX_SITES; i++) + local[i] = i < zoneCount ? bufferBodies[i] : float4(0.0, 0.0, 0.0, 1.0); + result[id.x] = cultmath_weighted_lattice_cells(p, 1.0, zoneBodies, zoneCount, 260.0, 0.3, 1.6, 120.0, 0.35, float2(1.0, 2.0), 1.5) + + cultmath_weighted_lattice_cells(p, 1.0, local, zoneCount, 260.0, 0.3, 1.6, 120.0, 0.35, float2(1.0, 2.0), 1.5); +} diff --git a/docs/research/shader-variable-inputs/entry.comp.tail b/docs/research/shader-variable-inputs/entry.comp.tail new file mode 100644 index 00000000..c6713929 --- /dev/null +++ b/docs/research/shader-variable-inputs/entry.comp.tail @@ -0,0 +1,16 @@ +layout(local_size_x = 64) in; +layout(std430, binding = 0) readonly buffer IB { uint a[]; } In; +layout(std430, binding = 1) buffer OB { uint a[]; } Out; +void main() +{ + uint i = gl_GlobalInvocationID.x; if (i >= NCASES) return; uint b = i * 76u; + vec4 bodies[CULTMATH_MAX_SITES]; + for (int k = 0; k < CULTMATH_MAX_SITES; k++) + bodies[k] = uintBitsToFloat(uvec4(In.a[b + 2u + 4u * uint(k)], In.a[b + 3u + 4u * uint(k)], In.a[b + 4u + 4u * uint(k)], In.a[b + 5u + 4u * uint(k)])); + uint c = b + 67u; + vec4 r = cultmath_weighted_lattice_cells(uintBitsToFloat(uvec2(In.a[b], In.a[b + 1u])), uintBitsToFloat(In.a[c]), bodies, int(In.a[b + 66u]), + uintBitsToFloat(In.a[c + 1u]), uintBitsToFloat(In.a[c + 2u]), uintBitsToFloat(In.a[c + 3u]), uintBitsToFloat(In.a[c + 4u]), + uintBitsToFloat(In.a[c + 5u]), uintBitsToFloat(uvec2(In.a[c + 6u], In.a[c + 7u])), uintBitsToFloat(In.a[c + 8u])); + uvec4 w = floatBitsToUint(r); + Out.a[i * 4u] = w.x; Out.a[i * 4u + 1u] = w.y; Out.a[i * 4u + 2u] = w.z; Out.a[i * 4u + 3u] = w.w; +} diff --git a/docs/research/shader-variable-inputs/entry.hlsl b/docs/research/shader-variable-inputs/entry.hlsl new file mode 100644 index 00000000..91b21046 --- /dev/null +++ b/docs/research/shader-variable-inputs/entry.hlsl @@ -0,0 +1,16 @@ +// Probe compute entry (HLSL route): 76 words in per case (p, 16 bodies, count, 9 parameters), 4 words out. +#include "CultMath.hlsl" +[[vk::binding(0)]] StructuredBuffer In; +[[vk::binding(1)]] RWStructuredBuffer Out; +[numthreads(64, 1, 1)] void main(uint3 id : SV_DispatchThreadID) +{ + uint i = id.x; if (i >= NCASES) return; uint b = i * 76u; + float4 bodies[CULTMATH_MAX_SITES]; + for (int k = 0; k < CULTMATH_MAX_SITES; k++) + bodies[k] = float4(asfloat(In[b + 2 + 4 * k]), asfloat(In[b + 3 + 4 * k]), asfloat(In[b + 4 + 4 * k]), asfloat(In[b + 5 + 4 * k])); + uint c = b + 67u; + float4 r = cultmath_weighted_lattice_cells(float2(asfloat(In[b]), asfloat(In[b + 1])), asfloat(In[c]), bodies, asint(In[b + 66]), + asfloat(In[c + 1]), asfloat(In[c + 2]), asfloat(In[c + 3]), asfloat(In[c + 4]), asfloat(In[c + 5]), + float2(asfloat(In[c + 6]), asfloat(In[c + 7])), asfloat(In[c + 8])); + Out[i * 4] = asuint(r.x); Out[i * 4 + 1] = asuint(r.y); Out[i * 4 + 2] = asuint(r.z); Out[i * 4 + 3] = asuint(r.w); +} diff --git a/docs/research/shader-variable-inputs/judge.py b/docs/research/shader-variable-inputs/judge.py new file mode 100644 index 00000000..97b23985 --- /dev/null +++ b/docs/research/shader-variable-inputs/judge.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Compare a device output with C#'s reference bits: cases over the lavapipe bound 2.2e-6 max(|v|, 1), and the largest. +usage: judge.py