m082: AMDGPULowerKernelArguments transplants range ParamAttr onto widened i32 kernarg load with mismatched operand type
Discovery method: code inspection.
AMDGPULowerKernelArguments.cpp:288-314 widens any sub-dword scalar
kernel-argument load to i32 (the DoShiftOpt path, since pre-GFX12
hardware has no sub-dword scalar loads):
bool DoShiftOpt = Size < 32 && !ArgTy->isAggregateType();
// ...
if (DoShiftOpt) {
ArgPtr = Builder.CreateConstInBoundsGEP1_64(
Builder.getInt8Ty(), KernArgSegment, AlignDownOffset, /*...*/);
AdjustedArgTy = Builder.getInt32Ty(); // <-- load is i32 now
}
// ...
LoadInst *Load = Builder.CreateAlignedLoad(AdjustedArgTy, ArgPtr, AdjustedAlign);Immediately after, lines 322-327 transplant the argument's range
ParamAttr onto the widened load without re-typing it:
if (Arg.hasAttribute(Attribute::Range)) {
const ConstantRange &Range =
Arg.getAttribute(Attribute::Range).getValueAsConstantRange();
Load->setMetadata(LLVMContext::MD_range,
MDB.createRange(Range.getLower(), Range.getUpper()));
}ConstantRange.getLower()/getUpper() return APInts of the
argument's bit width (e.g. i8 for i8 range(i8 0, 4) %a).
MDBuilder::createRange materialises a ConstantInt of that same
type. The result is !range !{i8 0, i8 4} attached to an i32 load.
This is two bugs in one:
-
The IR is invalid.
Verifier.cpp:4602requires the range MD's element type to equal the instruction's scalar type:Range types must match instruction type! %0 = load i32, ptr addrspace(4) %a.kernarg.offset.align.down, align 16, !range !0The IR verifier therefore aborts compilation if
--verify-eachruns. The Clang -O2 pipeline only verifies at the boundaries, so normal compiles slip through. -
It is semantically wrong. The widened i32 load reads the entire dword that contains
%a. With a sub-dword arg, the high bits of that dword contain whatever the next kernarg (%bin the reproducer) and/or padding contributed -- they are NOT constrained by%a's range attribute. Telling downstream passes "this i32 is in[0, 4)" lets them prove the high bits zero and fold away any computation that depends on%b. The narrow i8 view of%aonly recovers astrunc i32 %0 to i8, so by the time the actual i8 is live, range MD on the i32 load has already misinformed analysis on the un-truncated bits.
Compare with the nonnull/dereferenceable/align block above
(lines 329-356): those are guarded by if (isa<PointerType>(ArgTy)),
so widening doesn't affect them.
Compare also with Arg.hasAttribute(Attribute::NoUndef) at lines
319-320: noundef is set on the widened load unconditionally. That
is also unsound when the dword spans uninitialised padding between
explicit kernargs, but it's harder to weaponise than range.
/tmp/findbug/kernarg/reduced.ll:
target triple = "amdgcn-amd-amdhsa"
define protected amdgpu_kernel void @fuzz_kernel(i8 range(i8 0, 4) %a, i8 %b,
ptr addrspace(1) %out) #0 {
entry:
%za = zext i8 %a to i32
%zb = zext i8 %b to i32
%shl = shl i32 %zb, 8
%sum = or i32 %shl, %za
store i32 %sum, ptr addrspace(1) %out, align 4
ret void
}
attributes #0 = { nounwind "target-cpu"="gfx950" }$ /opt/rocm-7.1.1/lib/llvm/bin/opt -passes=amdgpu-lower-kernel-arguments \
-mtriple=amdgcn-amd-amdhsa -mcpu=gfx950 -S reduced.ll
Range types must match instruction type!
%0 = load i32, ptr addrspace(4) %a.kernarg.offset.align.down,
align 16, !range !0, !invariant.load !1
LLVM ERROR: Broken module found, compilation aborted!
Same with the FuzzX build (build/rocm-7.2.3-extract/.../opt) and
with ROCm 7.1.1's clang-20.
Stop-after the pass and dump the IR:
$ clang -O2 -mcpu=gfx950 -nogpulib -S -mllvm \
-stop-after=amdgpu-lower-kernel-arguments reduced.ll -o reduced.mir
...
%0 = load i32, ptr addrspace(4) %a.kernarg.offset.align.down,
align 16, !range !0, !invariant.load !1
...
!0 = !{i8 0, i8 4} ; <-- i8 range MD on i32 load
| Toolchain | Verifier rejects? | Bad MD reaches codegen? |
|---|---|---|
LLVM HEAD with the FuzzX patches (build/llvm-fuzzer) |
Yes | Yes (no in-pipeline verify) |
ROCm 7.2.3 (build/rocm-7.2.3-extract/.../opt) |
Yes | Yes |
ROCm 7.1.1 (/opt/rocm-7.1.1/lib/llvm/bin/opt) |
Yes | Yes |
Two minimally-invasive options, mirroring the same fixes available
for c007/m079:
- Drop the range MD on the
DoShiftOptpath -- thetruncto i8 that follows already discards bits ≥ Size, and InstCombine will propagate the i8 range from the trunc's user (zext i8 -> i32) anyway viacomputeKnownBits. - Build the range MD at the load's bit-width by zero-extending the
ConstantRange:
This is only sound when
ConstantRange Wide = Range.zeroExtend( cast<IntegerType>(AdjustedArgTy)->getBitWidth());OffsetDiff == 0AND no later kernarg spans bits[Size, 32)of the widened load -- i.e. effectively never for two adjacent sub-dword args. Option (1) is simpler.
nonnull/dereferenceable/dereferenceable_or_null/align: guarded byisa<PointerType>(ArgTy), never reach the widened-load path (pointers are always ≥ 32 bits, soDoShiftOptis false).noundef(lines 319-320): set on the widened load unconditionally. Unsound when the dword spans padding between explicit args, but amdhsa launch usually zero-initialises padding, so the worst case is a downstream poison-vs-undef refinement, not a runtime miscompile of an otherwise-correct program. Documented but not reproduced.IsV3widening (lines 307-311): the V3->V4 hack only applies whenSize >= 32, so it does not interact with the sub-dwordDoShiftOptmetadata path.AlignDownOffsetarithmetic:alignDown(EltOffset, 4)andcommonAlignment(KernArgBaseAlign=16, AlignDownOffset)are correct; the load'salignoperand on the widened load matches.- Byref args (line 253): take the address-cast path; no load emitted; no MD issue.
- Inreg / preload args (line 248): skipped entirely; preload pass handles them separately.
AMDGPULowerKernelAttributes.cpprange-MD annotations (annotateGridSizeLoadWithRangeMDand friends): each guards onLoad->getType()->isIntegerTy(N)matching itsAPInt(N, ...), so no width mismatch there.AMDGPUPreloadKernelArguments.cpp: doesn't emit loads, only marks argsinregand clones the function signature. Offset arithmetic matches the explicit-load offset arithmetic inAMDGPULowerKernelArguments.cpp.AMDGPULowerExecSync.cpp: not actually a kernel-argument pass (despite the directory) -- handles named-barrier LDS globals; no kernarg interaction.