Skip to content

Commit 584255c

Browse files
LostBeardclaude
andcommitted
Packed QInt4 nibble STORE on desktop: OpenCL + CUDA atomic-word-RMW, CPU fail-loud
A packed `dst[i] = (QInt4)v` writes ONE nibble of a 2-nibbles-per-byte buffer. Adjacent elements share a byte and adjacent threads write the two nibbles of the SAME 32-bit word concurrently, so a plain byte read-modify-write races and clobbers. The fix is an ATOMIC word RMW on the containing u32: each thread only clears + sets ITS nibble, and because the per-thread nibble masks are disjoint the atomicAnd/atomicOr pair composes correctly under any interleaving (the same contract the WebGPU/WebGL sub-word stores already use). Without it the store was wrong on every desktop backend (OpenCL wrote a full byte at the wrong position; CUDA launch-failed). - OpenCL: new `_qint4_store` emulation helper using generic-address-space C11 atomics (atomic_fetch_and / atomic_fetch_or on atomic_uint*, matching the rest of the backend - a __global-qualified helper param rejected the __generic buffer pointers). word = base[index>>3], shift = (index&7)*4. - PTX/CUDA: red.global.and.b32 / red.global.or.b32 on the containing word. The keep-index LEA left `address` as the byte ptr (base+index>>1) + _qint4LEAShift as the in-byte nibble shift; the word addr = byteAddr & ~3 (base is 4-aligned) and the in-word shift = (byteAddr&3)*8 + nibbleShift. - CPU (IL): the backend runs the literal managed kernel, whose ref-returning ArrayView indexer cannot write a single nibble in place - a packed `dst[i]=v` would silently lose the write. Fail loud instead: ILBackend.Compile scans the kernel IR (KernelMethod + callees; the backendContext tuple-enumerator yields only the NON-kernel methods, so KernelMethod is scanned explicitly) for a Store whose target view element type is QInt4 and throws NotSupportedException. Reading packed views on CPU stays fully supported (the by-value nibble decode shipped in 3975c4b). New harness packed-qint4-store-verify writes dst[i]=(QInt4)src[i] over N=8192 (large enough that every byte's two nibbles are written by concurrent adjacent threads - a small N can false-pass a racy store) and reads back the raw packed bytes. CUDA + OpenCL 4096/4096 packed bytes correct; CPU asserts the loud throw. No regression: packed-qint4 LOAD (CPU+OpenCL+CUDA 32/32), packed-alloc, fp4-verify (Float4E2M1 stays unpacked, unaffected), and representative CPU kernels all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3975c4b commit 584255c

6 files changed

Lines changed: 283 additions & 0 deletions

File tree

ILGPU/Backends/IL/ILBackend.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
using ILGPU.Backends.IL.Transformations;
1414
using ILGPU.IR;
1515
using ILGPU.IR.Transformations;
16+
using ILGPU.IR.Types;
17+
using ILGPU.IR.Values;
1618
using ILGPU.Resources;
1719
using ILGPU.Runtime;
1820
using ILGPU.Runtime.CPU;
@@ -149,6 +151,14 @@ protected sealed override CompiledKernel Compile(
149151
in BackendContext backendContext,
150152
in KernelSpecialization specialization)
151153
{
154+
// The CPU (IL) backend executes the original managed kernel method directly, so an
155+
// in-kernel `packedView[i] = value` runs the managed ArrayView indexer, whose ref model
156+
// cannot address a single nibble of a packed sub-byte buffer (QInt4/QUInt4). A nibble
157+
// store would silently write to a per-thread scratch and lose the result. Fail loud at
158+
// compile time instead. (Reading packed views IS supported - the indexer decodes the
159+
// nibble by value; only in-kernel writes are unsupported on this backend.)
160+
VerifyNoPackedSubByteStores(backendContext);
161+
152162
// Build the custom strongly type task type and define the kernel method
153163
var taskType = GenerateAcceleratorTask(
154164
entryPoint.Parameters,
@@ -202,6 +212,47 @@ protected sealed override CompiledKernel Compile(
202212
backendContext.SharedMemorySpecification.StaticSize);
203213
}
204214

215+
/// <summary>
216+
/// Throws if the kernel stores into a packed sub-byte view (e.g. QInt4/QUInt4) - the CPU (IL)
217+
/// backend runs the managed array-view indexer, which returns a ref and cannot write a single
218+
/// nibble in place, so such a store would be silently lost. Reading packed views is supported.
219+
/// </summary>
220+
private static void VerifyNoPackedSubByteStores(in BackendContext backendContext)
221+
{
222+
// The kernel method itself (the enumerator below yields only the OTHER, non-kernel
223+
// methods) plus every callee.
224+
ScanMethodForPackedStores(backendContext.KernelMethod);
225+
foreach (var (method, _) in backendContext)
226+
ScanMethodForPackedStores(method);
227+
}
228+
229+
/// <summary>
230+
/// Throws <see cref="NotSupportedException"/> if the given IR method stores into a packed
231+
/// sub-byte (QInt4/QUInt4) view - unsupported on the CPU backend (see
232+
/// <see cref="VerifyNoPackedSubByteStores"/>).
233+
/// </summary>
234+
private static void ScanMethodForPackedStores(Method method)
235+
{
236+
foreach (var block in method.Blocks)
237+
{
238+
foreach (var valueEntry in block)
239+
{
240+
if (valueEntry.Value is Store store &&
241+
store.Target.Type is PointerType pt &&
242+
pt.ElementType.BasicValueType == BasicValueType.QInt4)
243+
{
244+
throw new NotSupportedException(
245+
"Packed sub-byte views (QInt4/QUInt4) do not support in-kernel " +
246+
"element stores on the CPU backend: the managed array-view indexer " +
247+
"cannot address a single nibble in place. Use a GPU backend for " +
248+
"packed in-kernel writes, or build the packed buffer via an " +
249+
"ArrayView<byte>/<uint> with explicit nibble packing. Reading a " +
250+
"packed view (e.g. (int)packed[i]) IS supported on the CPU backend.");
251+
}
252+
}
253+
}
254+
}
255+
205256
/// <summary>
206257
/// Generates the actual kernel code.
207258
/// </summary>

ILGPU/Backends/OpenCL/CLBackend.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,25 @@ public CLBackend(
295295
extensionBuilder.AppendLine("}");
296296
extensionBuilder.AppendLine();
297297

298+
// Packed 4-bit (QInt4/QUInt4) STORE: write a single nibble into a 2-nibbles-per-byte
299+
// buffer. Adjacent elements share a byte and adjacent threads write the two nibbles of
300+
// the SAME 32-bit word concurrently, so a plain byte read-modify-write would race and
301+
// clobber. Do an ATOMIC word RMW: each thread only ever clears + sets ITS nibble, and
302+
// since the nibble masks across threads are disjoint the atomicAnd/atomicOr pair composes
303+
// correctly regardless of interleaving (same contract as the WebGPU/WebGL sub-word store).
304+
// base must be 4-byte aligned (buffer allocations are); word = base[index>>3].
305+
// Generic-address-space C11 atomics (atomic_uint / atomic_fetch_*), matching the rest of
306+
// the backend's atomics - the buffer pointers are __generic, so a __global-qualified
307+
// helper param would reject them. base[index>>3] is the containing 32-bit word.
308+
extensionBuilder.AppendLine(
309+
"static inline void _qint4_store(uchar* base, int index, int value) {");
310+
extensionBuilder.AppendLine(" volatile atomic_uint* w = (volatile atomic_uint*)base + (index >> 3);");
311+
extensionBuilder.AppendLine(" int shift = (index & 7) << 2;");
312+
extensionBuilder.AppendLine(" atomic_fetch_and(w, ~(0xFu << shift));");
313+
extensionBuilder.AppendLine(" atomic_fetch_or(w, ((uint)(value & 0xF)) << shift);");
314+
extensionBuilder.AppendLine("}");
315+
extensionBuilder.AppendLine();
316+
298317
extensions = extensionBuilder.ToString();
299318
}
300319

ILGPU/Backends/OpenCL/CLCodeGenerator.Values.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,21 @@ public void GenerateCode(Store store)
708708
return;
709709
}
710710

711+
// Int4 PACKED emulation: write the (index&1) nibble of byte (index>>1) via the atomic
712+
// word RMW helper (adjacent threads write the two nibbles of one byte concurrently). The
713+
// value's low 4 bits are the stored nibble; signedness is irrelevant on store.
714+
if (_qint4EmulatedLEAs.TryGetValue(address.ToString(), out var int4StoreLea))
715+
{
716+
using var statement = BeginStatement("_qint4_store(");
717+
statement.AppendArgument(int4StoreLea.BasePtr);
718+
statement.AppendCommand(", ");
719+
statement.AppendArgument(int4StoreLea.Index);
720+
statement.AppendCommand(", ");
721+
statement.AppendArgument(value);
722+
statement.AppendCommand(")");
723+
return;
724+
}
725+
711726
using var statement2 = BeginStatement(CLInstructions.DereferenceOperation);
712727
statement2.AppendArgument(address);
713728
statement2.AppendCommand(CLInstructions.AssignmentOperation);

ILGPU/Backends/PTX/PTXCodeGenerator.Values.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1456,6 +1456,80 @@ public void GenerateCode(Store store)
14561456
return;
14571457
}
14581458

1459+
if (targetType.ElementType.BasicValueType == BasicValueType.QInt4)
1460+
{
1461+
// Packed 4-bit STORE: write the (index&1) nibble of byte (index>>1) WITHOUT disturbing
1462+
// the adjacent nibble. `address` is the byte ptr (base+index>>1, from the keep-index
1463+
// LEA) and _qint4LEAShift holds the in-byte nibble shift (0 or 4). Adjacent elements
1464+
// share a byte and adjacent threads write the two nibbles of the SAME 32-bit word, so a
1465+
// plain st.u8 races (clobbers the neighbor). Do an ATOMIC word RMW: each thread only
1466+
// clears + sets ITS nibble (disjoint masks compose correctly under any interleaving -
1467+
// the same contract as the OpenCL/WebGPU sub-word store). The containing word is
1468+
// byteAddr & ~3 (base is 4-byte aligned); the in-word shift is (byteAddr&3)*8 + nibble.
1469+
var valueReg = EnsureHardwareRegister(value.AsNotNullCast<PrimitiveRegister>());
1470+
bool ptr64 = Backend.PointerBasicValueType == BasicValueType.Int64;
1471+
string addrSuffix = ptr64 ? "b64" : "b32";
1472+
1473+
// wordAddr = byteAddr & ~3
1474+
var wordAddr = AllocatePlatformRegister(out var _);
1475+
using (var c = BeginCommand("and." + addrSuffix))
1476+
{ c.AppendArgument(wordAddr); c.AppendArgument(address); c.AppendConstant(~3L); }
1477+
1478+
// byteInWord = (u32)(byteAddr & 3)
1479+
var lowBits = AllocatePlatformRegister(out var _);
1480+
using (var c = BeginCommand("and." + addrSuffix))
1481+
{ c.AppendArgument(lowBits); c.AppendArgument(address); c.AppendConstant(3L); }
1482+
var byteInWord = AllocateRegister(BasicValueType.Int32, PTXRegisterKind.Int32);
1483+
if (ptr64)
1484+
using (var c = BeginCommand("cvt.u32.u64")) { c.AppendArgument(byteInWord); c.AppendArgument(lowBits); }
1485+
else
1486+
using (var c = BeginCommand("mov.u32")) { c.AppendArgument(byteInWord); c.AppendArgument(lowBits); }
1487+
1488+
// shift = byteInWord*8 + nibbleShift
1489+
var shift = AllocateRegister(BasicValueType.Int32, PTXRegisterKind.Int32);
1490+
using (var c = BeginCommand("shl.b32")) { c.AppendArgument(shift); c.AppendArgument(byteInWord); c.AppendConstant(3); }
1491+
if (_qint4LEAShift.TryGetValue(store.Target, out var keptShift))
1492+
using (var c = BeginCommand("add.u32")) { c.AppendArgument(shift); c.AppendArgument(shift); c.AppendArgument(keptShift); }
1493+
1494+
// mask = 0xF << shift ; clearMask = ~mask
1495+
var mask = AllocateRegister(BasicValueType.Int32, PTXRegisterKind.Int32);
1496+
using (var c = BeginCommand("mov.u32")) { c.AppendArgument(mask); c.AppendConstant(0xF); }
1497+
using (var c = BeginCommand("shl.b32")) { c.AppendArgument(mask); c.AppendArgument(mask); c.AppendArgument(shift); }
1498+
var clearMask = AllocateRegister(BasicValueType.Int32, PTXRegisterKind.Int32);
1499+
using (var c = BeginCommand("not.b32")) { c.AppendArgument(clearMask); c.AppendArgument(mask); }
1500+
1501+
// setBits = (value & 0xF) << shift
1502+
var vnib = AllocateRegister(BasicValueType.Int32, PTXRegisterKind.Int32);
1503+
using (var c = BeginCommand("and.b32")) { c.AppendArgument(vnib); c.AppendArgument(valueReg); c.AppendConstant(0xF); }
1504+
using (var c = BeginCommand("shl.b32")) { c.AppendArgument(vnib); c.AppendArgument(vnib); c.AppendArgument(shift); }
1505+
1506+
// red.<space>.and.b32 [wordAddr], clearMask ; red.<space>.or.b32 [wordAddr], setBits
1507+
var asp = targetType.AddressSpace;
1508+
using (var c = BeginCommand(PTXInstructions.GetAtomicOperation(AtomicKind.And, false)))
1509+
{
1510+
c.AppendNonLocalAddressSpace(asp);
1511+
c.AppendSuffix(PTXInstructions.GetAtomicOperationSuffix(AtomicKind.And, ArithmeticBasicValueType.UInt32));
1512+
c.AppendArgumentValue(wordAddr);
1513+
c.AppendArgument(clearMask);
1514+
}
1515+
using (var c = BeginCommand(PTXInstructions.GetAtomicOperation(AtomicKind.Or, false)))
1516+
{
1517+
c.AppendNonLocalAddressSpace(asp);
1518+
c.AppendSuffix(PTXInstructions.GetAtomicOperationSuffix(AtomicKind.Or, ArithmeticBasicValueType.UInt32));
1519+
c.AppendArgumentValue(wordAddr);
1520+
c.AppendArgument(vnib);
1521+
}
1522+
1523+
FreeRegister(wordAddr);
1524+
FreeRegister(lowBits);
1525+
FreeRegister(byteInWord);
1526+
FreeRegister(shift);
1527+
FreeRegister(mask);
1528+
FreeRegister(clearMask);
1529+
FreeRegister(vnib);
1530+
return;
1531+
}
1532+
14591533
// A low-precision-TYPED value (bf16 / FP8 / FP4 - all held in an f32 register on PTX) stored
14601534
// to a NON-matching buffer (the target-matching cases were handled above). The widening
14611535
// Convert (`(float)bf16` etc.) is a no-op alias that preserves the low-precision IR type, so
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
using System;
2+
using System.Linq;
3+
using System.Threading.Tasks;
4+
using ILGPU;
5+
using ILGPU.Runtime;
6+
7+
/// <summary>
8+
/// Validates the packed 4-bit QInt4 STORE path on the desktop GPU backends (CUDA/OpenCL):
9+
/// a kernel writes dst[i] = (QInt4)src[i] into an ArrayView&lt;QInt4&gt; (2 nibbles/byte), then the
10+
/// raw packed bytes are read back and unpacked to confirm every nibble landed in the right byte and
11+
/// position. N is large so that for every byte the two adjacent threads write its two nibbles
12+
/// CONCURRENTLY - a non-atomic byte read-modify-write would race and clobber, so this exercises the
13+
/// atomic-word-RMW requirement (a small-N test can false-pass a racy store).
14+
///
15+
/// The CPU (IL) backend runs the literal managed indexer, whose ref model cannot write a nibble in
16+
/// place; packed in-kernel stores must fail loud there rather than silently lose writes, so this
17+
/// harness asserts CPU THROWS (and treats a throw as the expected, correct behavior).
18+
///
19+
/// Run: dotnet run --project SpawnDev.ILGPU.DemoConsole -c Release -- packed-qint4-store-verify
20+
/// </summary>
21+
internal static class PackedQInt4StoreVerify
22+
{
23+
private static void StoreKernel(Index1D i, ArrayView<int> src, ArrayView<QInt4> dst) =>
24+
dst[i] = (QInt4)src[i];
25+
26+
public static Task<int> Run()
27+
{
28+
Console.WriteLine("=== Packed QInt4 nibble STORE verification (CPU/CUDA/OpenCL) ===");
29+
30+
// Large N to force concurrent adjacent-nibble writes into the same byte (race stress).
31+
int n = 8192;
32+
int[] src = new int[n];
33+
for (int i = 0; i < n; i++)
34+
src[i] = (i % 16) - 8; // -8..7, sign-extended round-trip
35+
byte[] expectedPacked = new byte[(n + 1) / 2];
36+
for (int k = 0; k < expectedPacked.Length; k++)
37+
{
38+
int lo = src[2 * k] & 0xF;
39+
int hi = src[2 * k + 1] & 0xF;
40+
expectedPacked[k] = (byte)(lo | (hi << 4));
41+
}
42+
43+
int totalFails = 0;
44+
using var ctx = Context.Create(b => b.Default().EnableAlgorithms());
45+
foreach (var dev in ctx)
46+
{
47+
var type = dev.AcceleratorType;
48+
if (type != AcceleratorType.CPU && type != AcceleratorType.Cuda && type != AcceleratorType.OpenCL)
49+
continue;
50+
51+
using var acc = dev.CreateAccelerator(ctx);
52+
53+
// CPU (IL) backend: the literal managed ref indexer cannot address a nibble for a write.
54+
// The correct behavior is a loud failure (UnsupportedKernelFeatureException), not a silent
55+
// lost write. Accept either a compile/dispatch throw OR a detectable wrong result -> the
56+
// former is the intended contract.
57+
if (type == AcceleratorType.CPU)
58+
{
59+
bool threw = false;
60+
try
61+
{
62+
using var xBufCpu = acc.Allocate1D<int>(src);
63+
using var dBufCpu = acc.Allocate1D<QInt4>(n);
64+
var kCpu = acc.LoadAutoGroupedStreamKernel<Index1D, ArrayView<int>, ArrayView<QInt4>>(StoreKernel);
65+
kCpu((int)n, xBufCpu.View, dBufCpu.View);
66+
acc.Synchronize();
67+
}
68+
catch (Exception ex)
69+
{
70+
threw = true;
71+
Console.WriteLine($" [CPU] STORE fail-loud OK ({ex.GetType().Name})");
72+
}
73+
if (!threw)
74+
{
75+
Console.WriteLine(" [CPU] STORE did NOT fail loud - packed in-kernel write must throw on CPU FAIL");
76+
totalFails++;
77+
}
78+
continue;
79+
}
80+
81+
try
82+
{
83+
using var srcBuf = acc.Allocate1D<int>(src);
84+
using var dstBuf = acc.Allocate1D<QInt4>(n);
85+
// Pre-zero the packed buffer so a missed write is detectable.
86+
var rawZero = ((IContiguousArrayView)dstBuf.View.BaseView).AsRawArrayView();
87+
rawZero.CopyFromCPU(new byte[rawZero.Length]);
88+
89+
var kernel = acc.LoadAutoGroupedStreamKernel<Index1D, ArrayView<int>, ArrayView<QInt4>>(StoreKernel);
90+
kernel((int)n, srcBuf.View, dstBuf.View);
91+
acc.Synchronize();
92+
93+
var raw = ((IContiguousArrayView)dstBuf.View.BaseView).AsRawArrayView();
94+
byte[] got = new byte[raw.Length];
95+
raw.CopyToCPU(got);
96+
int fails = 0;
97+
for (int k = 0; k < expectedPacked.Length; k++)
98+
if (got[k] != expectedPacked[k]) fails++;
99+
if (fails == 0)
100+
Console.WriteLine($" [{type}] STORE PASS ({expectedPacked.Length}/{expectedPacked.Length} packed bytes correct, N={n})");
101+
else
102+
{
103+
var firstBad = Enumerable.Range(0, expectedPacked.Length).First(k => got[k] != expectedPacked[k]);
104+
Console.WriteLine($" [{type}] STORE FAIL ({fails}/{expectedPacked.Length}); first bad byte {firstBad} got=0x{got[firstBad]:X2} want=0x{expectedPacked[firstBad]:X2}");
105+
}
106+
totalFails += fails;
107+
}
108+
catch (Exception ex)
109+
{
110+
Console.WriteLine($" [{type}] EXCEPTION: {ex.GetType().Name}: {ex.Message.Split('\n')[0]}");
111+
totalFails++;
112+
}
113+
}
114+
115+
Console.WriteLine(totalFails == 0
116+
? "=== PACKED QInt4 STORE PASS (CUDA + OpenCL atomic-word-RMW; CPU fail-loud) ==="
117+
: $"=== PACKED QInt4 STORE: {totalFails} problems ===");
118+
return Task.FromResult(totalFails == 0 ? 0 : 1);
119+
}
120+
}

SpawnDev.ILGPU.DemoConsole/Program.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@
6262
if (args.Length > 0 && args[0] == "packed-qint4-verify")
6363
return await PackedQInt4Verify.Run();
6464

65+
// Packed QInt4 nibble STORE (CUDA/OpenCL atomic-word-RMW; CPU fail-loud): dst[i] = (QInt4)src[i].
66+
if (args.Length > 0 && args[0] == "packed-qint4-store-verify")
67+
return await PackedQInt4StoreVerify.Run();
68+
6569
// FP8 conversions vs the ml_dtypes reference (float8_e4m3fn / e5m2) - answers the
6670
// overflow-convention question flagged in Float8E4M3.cs with evidence.
6771
if (args.Length > 0 && args[0] == "fp8-oracle")

0 commit comments

Comments
 (0)