ROCm Composable Kernel #10574 — Why block_sync_lds() Could Not Repair an Invalid noalias Contract
ROCm CK LDS Aliasing, Part 2 of 2 — Dynamic function invocations, cross-thread memory effects, and why an in-kernel barrier could not restore a dependency the compiler had been told did not exist
Part 1 examined a numerical failure in ROCm Composable Kernel’s gridwise GEMM path.
Several functions accepted their workgroup-shared LDS scratch memory through:
void* __restrict__ p_sharedBut the actual algorithm used that memory collectively.
Producer threads
→ write values into LDS
block_sync_lds()
→ synchronize the workgroup
Consumer threads
→ read values written by their peersThe pointer was not thread-private.
Cross-thread communication was the reason the scratch buffer existed.
On the affected MI300X setup, the PR author reported:
Current staging compiler
+
__restrict__ present
→ numerical failureSame compiler
+
__restrict__ removed
→ PASSCompiler with the relevant alias-analysis change reverted
+
__restrict__ present
→ PASSThe landed patch removed four __restrict__ qualifiers from two gridwise GEMM headers. It did not add another barrier or change the kernel’s tiling, layout, or arithmetic.
That leads to the deeper question:
Why could the existing workgroup barrier not preserve the values written by other threads?
The answer lies in the boundary between two different systems.
GPU barrier
→ orders operations that reach the hardwareCompiler alias model
→ decides which memory dependencies must remain in the programA barrier can order a second load.
It cannot force a second load to occur after the compiler has proven—under the source’s declared contract—that no legal external write could have changed the value.
__restrict__ becomes an LLVM memory fact
In the affected compilation path, Clang lowers the source-level:
__restrict__qualifier on a pointer parameter into LLVM IR’s:
noaliasparameter attribute.
Conceptually:
void run(void* __restrict__ p_shared);becomes a function argument carrying an exclusivity promise:
define void @run(ptr noalias %p_shared) {
...
}This is not a comment attached for human readers.
It is an input to:
alias analysis,
MemorySSA,
load elimination,
value forwarding,
loop optimization,
and other transformations that decide whether two memory operations can affect the same object.
The compiler may optimize according to that promise.
The program must make the promise true.
noalias is scoped to a dynamic function invocation
The important unit is not merely the source function’s text.
It is one dynamic invocation of that function.
On a GPU, many workitems execute the same kernel function concurrently.
Conceptually:
Thread 0
→ invocation F0
Thread 1
→ invocation F1
Thread 2
→ invocation F2Each invocation receives its own dynamic instance of the pointer argument.
The numerical address can be identical:
F0.p_shared
→ LDS address X
F1.p_shared
→ LDS address X
F2.p_shared
→ LDS address XBut each invocation carries its own noalias contract.
From the perspective of invocation F0, an access performed by F1 is an access from another thread during F0’s lifetime.
LLVM’s merged LangRef clarification states that noalias also applies to accesses from other threads. Conflicting concurrent accesses must either be based on the relevant noalias pointer or be ordered outside the function’s dynamic invocation—before entry or after exit.
That is the boundary the CK kernel violated.
Same address does not mean the access came from the same dynamic pointer
Consider two workitems.
Thread 0:
p_shared = address X
p_shared[0] = 42Thread 1:
p_shared = address X
value = p_shared[0]At the hardware level:
both accesses
→ same LDS locationAt the LLVM contract level, however:
Thread 0 store
→ based on Thread 0’s dynamic argument
Thread 1 load
→ based on Thread 1’s dynamic argumentThread 0’s access is not derived from Thread 1’s dynamic noalias argument, even though both argument values contain the same address.
That distinction is invisible in the pointer bits.
It exists in the provenance and invocation contract supplied to the optimizer.
The workgroup barrier occurs while both invocations are still alive
The execution timeline can be drawn like this:
Thread 0 invocation
enter F0
↓
write LDS
↓
block_sync_lds()
↓
read LDS
↓
exit F0Thread 1 invocation
enter F1
↓
write LDS
↓
block_sync_lds()
↓
read LDS
↓
exit F1Placed side by side:
F0: enter ── write ── barrier ── read ── exit
F1: enter ── write ── barrier ── read ── exitThe barrier orders operations inside the two overlapping invocations.
It does not make one invocation finish before the other begins.
For Thread 1’s noalias contract, Thread 0’s conflicting write still occurs:
after F1 entered
and
before F1 exitedThe synchronization is internal to the kernel.
The noalias clarification requires unrelated conflicting cross-thread accesses to be ordered outside the dynamic invocation if they are not based on the same noalias root.
Therefore:
in-function workgroup barrier
≠
end of noalias scopeA barrier does not reset pointer provenance
It is tempting to think of a barrier as dividing the kernel into independent phases.
Phase 1
→ before barrier
Phase 2
→ after barrierOperationally, that is useful.
The threads cannot enter the second phase until the required participants reach the synchronization point.
But the function invocation has not ended.
The parameter attributes still apply.
Function entry
↓
Phase 1
↓
barrier
↓
Phase 2
↓
Function exitThere is no semantic operation equivalent to:
drop all noalias assumptions
start a new invocation
re-acquire pointer stateat the barrier.
The same argument and its attributes remain active across both phases.
What synchronization outside the function would look like
The noalias contract can be compatible with cross-thread work when the conflicting phases belong to separate dynamic invocations.
For example:
Kernel A launch
threads write Buffer X
↓
Kernel A completes
↓
stream or event dependency
↓
Kernel B launch
threads read Buffer XThe accesses in Kernel B happen after Kernel A’s relevant invocations have exited.
Writer function exit
happens before
reader function entryThat is fundamentally different from:
Thread A and Thread B
inside the same concurrently executing kernel
communicating through LDS
across an internal barrierThe first separates the alias scopes.
The second does not.
Read-only sharing is not the same violation
The problem should not be generalized into:
Every pointer used by multiple GPU threads
must never be restrict.The critical condition is a conflicting cross-thread access.
Read-read sharing
Thread 0 reads A[i]
Thread 1 reads A[i]There is no conflicting write.
Several threads may read the same immutable input without creating the communication pattern at issue here.
Disjoint writes
Thread 0 writes C[0]
Thread 1 writes C[1]The memory locations do not overlap.
A kernel can legitimately require that its major input and output allocations do not alias one another while different threads operate on disjoint elements.
LDS communication
Thread 0 writes scratch[0]
barrier
Thread 1 reads scratch[0]This is a conflicting access to the same location across overlapping invocations.
It is the case that invalidated the exclusivity contract on p_shared.
The compiler ordinarily treats synchronization as a broad memory effect
Compiler optimizations need to understand that a synchronization operation may make another thread’s writes visible.
A common conservative model is:
fence or strong synchronization
→ may read and write broad memoryThis does not mean the barrier literally reads and writes every byte.
It is a ModRef model used to preserve ordering.
Consider conceptual LLVM-like code:
%before = load float, ptr %shared
call void @workgroup_barrier()
%after = load float, ptr %sharedIf the barrier is considered capable of affecting %shared:
%before
cannot automatically replace
%afterThe second load must remain because another thread may have changed the location before the barrier completed.
A synchronization operation need not clobber truly private memory
Now consider a real thread-private object:
%private = alloca float
%before = load float, ptr %private
fence syncscope("workgroup") seq_cst
%after = load float, ptr %privateSuppose:
%private never escapes
no other thread can obtain its address
no instruction writes it between the loadsThe workgroup barrier cannot cause a peer thread to modify that object.
Treating the barrier as a universal clobber would unnecessarily block optimization.
The two loads can safely be equivalent.
This is the class of case addressed by LLVM PR #196923.
The change explains that fences and strong atomic operations had been modeled as reading and writing all memory to enforce synchronization, even for identified function-local objects that never escape. It excluded those genuinely private objects from the broad synchronization effect.
For a true private local:
barrier
→ no effect on the object
second load
→ may reuse first valueThat is a valid optimization.
The problem was that shared LDS looked compatible with private reasoning
The CK scratch object was not a private alloca.
It was caller-provided workgroup-shared memory.
But the function signature supplied the compiler with an exclusivity fact:
ptr noalias %p_sharedThe reported failure emerged from the interaction between:
Source contract:
other accesses cannot legally conflict with p_sharedand:
Alias-analysis refinement:
synchronization need not clobber memory
that appears local and unaffected by peersUnder that model, the barrier no longer had to invalidate the value associated with the scratch location.
The compiler could treat a later load as redundant or preserve an earlier value in a register.
The CK PR describes this as the compiler being free to keep an LDS value in a register across block_sync_lds(), after which the kernel reads stale data and produces wrong results.
The hardware cannot reload a value when no reload was emitted
A simplified source pattern is:
float value_before = shared[index];
block_sync_lds();
float value_after = shared[index];The programmer sees:
load
barrier
load againThe optimizer may see:
load
operation that does not ModRef this location
same loadand transform it conceptually into:
float value_before = shared[index];
block_sync_lds();
float value_after = value_before;At the machine level:
one LDS load
one barrier
one register reuseThe barrier can correctly synchronize every wave in the workgroup.
It cannot change the register value.
Other thread writes new LDS value
hardware barrier completes
consumer uses old VGPR valueThe hardware performed exactly the instructions it received.
The lost dependency disappeared during compilation.
This transformation is a conceptual explanation of the reported mechanism. The PR does not publish a complete IR or ISA diff naming the exact load-forwarding pass or reused register.
Synchronization has two separate obligations
The kernel needs both.
Execution-order obligation
Consumer must not execute its dependent read
before producers finish their writes.This is what the workgroup barrier expresses.
Memory-dependence obligation
Compiler must preserve the fact that producer writes
can change the value observed by the consumer.This depends on alias and ModRef information.
The first does not imply the second automatically.
Correct barrier
+
incorrect alias model
→ stale compiled value remains possibleThe kernel had the ordering operation.
Its pointer contract undermined the memory dependency the ordering operation was supposed to protect.
The compiler was allowed to trust the declaration
From the program author’s perspective:
Thread 0 writes shared memory.
Thread 1 reads it after the barrier.
Therefore the second value must be fresh.From the optimizer’s perspective:
The argument is noalias.
A conflicting access from another thread during this invocation
is outside the declared contract.
Therefore the program is not entitled to rely on that access.Once the source enters behavior excluded by the IR contract, the optimizer is not required to preserve the programmer’s intended outcome.
That is why this event is better described as:
latent source-level undefined behavior
exposed by stronger optimizationrather than:
LLVM broke a valid synchronized programThe merged LangRef change made the boundary explicit
LLVM PR #211507 did not introduce a new optimization.
It clarified the written noalias contract.
The merged text explicitly says that:
noaliasapplies to accesses from other threads,conflicting concurrent accesses cannot simply occur during the invocation,
and unrelated accesses must be synchronized outside the function’s execution boundary.
The discussion directly raised GPU and SPMD code that places restrict on workgroup-shared buffers and then communicates through them across an in-kernel barrier. The response confirmed that current CUDA/LLVM-style semantics permit aggressive optimization under the assumption that such conflicting use does not occur.
The documentation change therefore reinforces the source-side repair:
Current noalias semantics
→ do not express the intended collective LDS ownership
Correct immediate fix
→ remove noalias from that pointerA proposed compiler-side alternative did not become the contract
A separate LLVM proposal explored treating cross-thread synchronization differently for non-byval pointer arguments and allowing targets to restore precision only for genuinely thread-private address spaces.
Its motivation was close to the CK failure:
caller-owned pointer argument
peer threads can hold the address
cross-thread synchronization should clobber itThat proposal was closed without merging.
The merged LangRef clarification instead describes the current noalias behavior as applying across threads.
Therefore, CK could not rely on a hypothetical weaker interpretation of the attribute.
The source had to satisfy the attribute that exists.
The desired optimization contract is weaker than current noalias
GPU programmers often want to communicate two facts at once.
Fact 1
p_shared does not overlap A, B, or output.Fact 2
other threads may modify p_shared,
and barriers make those modifications visible.Current argument-level noalias is too strong for that combination when peer invocations access the same locations.
What the kernel would prefer is something closer to:
Within one thread’s ordinary pointer expressions,
this region is disjoint from unrelated arguments.
But synchronization may expose modifications
performed by peer workitems through their own views.The LLVM discussion explicitly raises the possibility of a weaker attribute that composes with concurrency while retaining useful intra-thread alias precision. No such replacement was added by CK #10574 or LLVM #211507.
For the current compiler contract, the conservative correct representation was:
void* p_sharedwithout __restrict__.
The landed source leaves the optimization question open
The CK diff removes the qualifier with a comment:
void* p_shared; // FIXME: reinstate __restrict__ qualifierThe comment shows that the developers may still want a way to recover aliasing precision.
But simply restoring the same qualifier would restore the same invalid contract unless one of the following changes first:
the kernel ownership model changes
the computation is split into separate dynamic invocations
the compiler gains a weaker concurrency-aware alias contract
or
the pointer is proven to refer only to non-conflicting locationsThe FIXME is an optimization goal.
It is not evidence that the removed qualifier is currently safe.
Reverting alias analysis would hide the mismatch rather than repair it
The validation matrix showed that the qualifier-bearing source passed when the relevant AA change was reverted.
That does not make the old source valid.
It means the older compiler happened not to exploit the false promise in the same way.
Invalid noalias
+
conservative compiler
→ apparent correctnessReturning permanently to the conservative behavior would impose costs on valid programs with genuinely private objects.
LLVM PR #196923 exists to recover optimization opportunities for those objects.
Private non-escaping local
→ peer threads cannot modify it
→ synchronization need not clobber itThe more precise repair is to stop classifying collective LDS as exclusive.
Keep valid optimizer improvement
remove invalid source factThat is what CK #10574 did.
Adding another barrier would not repair the alias lifetime
Suppose the source became:
write LDS
barrier 1
barrier 2
read LDSBoth barriers still occur:
after function entry
before function exitNeither ends the dynamic invocation.
Neither changes the pointer provenance.
Neither makes the peer access legal under the current noalias contract.
The additional synchronization may change scheduling.
It does not repair the ownership declaration.
Marking the pointer volatile would address a different contract
volatile and noalias answer different questions.
volatile
→ how individual accesses must remain observablenoalias
→ which accesses may refer to the same memoryUsing volatile loads might inhibit some value reuse.
It would not make the false exclusivity statement true.
The source would still describe a program in which illegal cross-thread aliasing occurs.
The landed patch corrected the alias relation directly.
The input and output pointers did not need the same change
The diff removed __restrict__ only from p_shared.
Other pointers remained qualified.
That is consistent with the observed ownership model.
A and B
→ input allocations
output
→ separate destination allocation
p_shared
→ collective workgroup communication objectThe patch did not apply a blanket rule to every parameter.
It changed the argument whose legal use contradicted the qualifier.
This is an important review principle:
Pointer qualifiers belong to the ownership contract of each argument, not to a visual style applied uniformly across a function signature.
A barrier near a restricted pointer is a review signal
The following pattern deserves inspection:
void kernel(void* __restrict__ shared) {
...
write(shared);
block_barrier();
read(shared);
}Questions to ask are:
Do several workitems receive views of the same object?
Can one workitem write a location another later reads?
Do those dynamic function invocations overlap?
Is the cross-thread communication protected only by
a barrier inside the same function?
Does the frontend lower the qualifier into LLVM noalias?If the answers are yes, the qualifier may be expressing a stronger ownership rule than the algorithm can satisfy.
The presence of a barrier does not prove safety.
It is often evidence that ownership is collective.
A compiler upgrade can reveal an old source defect
The three-way validation matrix is a classic example.
Old compiler behavior
+
old source
→ PASSNew compiler behavior
+
old source
→ FAILNew compiler behavior
+
corrected source
→ PASSThe first PASS does not prove the source was correct.
It may prove only that the optimizer had not yet used all the information supplied by the program.
This is why code using strong attributes must be reviewed as a proof obligation.
restrict
noalias
nonnull
dereferenceable
alignment
nuw / nswcan all enable transformations that remain dormant until a compiler changes.
A source annotation can become part of numerical correctness
The failing lines did not perform arithmetic.
They declared function parameters.
void* __restrict__ p_sharedYet the observed result was:
137,150 incorrect values
22.78% numerical error rateThis is not unusual in optimized low-level code.
Metadata and qualifiers can determine:
whether a load is emitted,
whether a value is reused,
whether two stores are reordered,
whether a loop is vectorized,
and whether synchronization is considered relevant to one memory object.
The declaration layer is part of the kernel’s numerical contract.
The existing test caught the result, not the compiler invariant
The PR reused:
TestGroupedConvndBwdData2d/9.Test2Das the numerical before-and-after check.
That test protects an important end-to-end path.
grouped-convolution input
→ CK gridwise GEMM
→ LDS staging
→ compiler optimization
→ output comparisonBut the patch added no separately named test file specifically documenting:
cross-thread LDS modification
+
noalias
+
barrier
→ invalid contractThe repository policy bot noted that the two source headers changed without an accompanying new test file.
The current regression evidence therefore has two forms.
Runtime numerical regression
→ strong for the reproduced kernel pathMinimal compiler-contract regression
→ not added by this PRA dedicated compiler test could separate the layers
A focused IR test could start with:
define amdgpu_kernel void @shared_test(ptr noalias addrspace(3) %shared) {
%before = load float, ptr addrspace(3) %shared
call void @workgroup_barrier()
%after = load float, ptr addrspace(3) %shared
...
}It could then compare:
noalias form
versus
non-noalias formunder the alias-analysis pipeline that exposed the issue.
A device test could separately validate:
one workitem writes
barrier
another workitem reads
output equals the written valueSuch tests would pin the compiler boundary more directly.
They are proposed test designs, not coverage added by CK #10574.
The exact optimizer transformation remains unpublished
The evidence directly establishes:
restrict source
+
current staging compiler
→ wrong outputnon-restrict source
+
same compiler
→ correct outputrestrict source
+
reverted alias-analysis behavior
→ correct outputThe PR’s explanation is that LLVM retained an LDS value in a register across the barrier.
The public record does not include:
the full pre-fix LLVM IR,
the full post-fix LLVM IR,
the responsible optimization-pass trace,
a MemorySSA dump,
or an ISA diff identifying the exact missing LDS load.
The article can confidently describe the source contract and the observed compiler dependence.
It should not invent one exact deleted instruction as though it had been captured.
What this article directly confirms
The reviewed sources support the following statements:
Clang lowered the affected
__restrict__parameter into anoalias-style compiler contract.LLVM’s current documented
noaliassemantics apply to conflicting accesses performed by other threads.An in-function barrier does not end the function invocation or reset that contract.
LLVM PR #196923 narrows the broad ModRef effects of synchronization for genuinely non-escaping local objects.
The CK PR attributes its stale-LDS result to the interaction between that optimization and the false exclusivity declaration.
Removing the four qualifiers restored the reported numerical test on MI300X.
Reverting the alias-analysis change also made the qualifier-bearing source pass.
The merged source fix changed the alias contract rather than adding synchronization.
What this article does not establish
The public evidence does not establish that:
every workgroup-shared pointer annotated
restrictproduces a miscompile,all versions of Clang and LLVM exploit the contract identically,
every AMD architecture reproduces the CK numerical failure,
one specific optimization pass has been publicly isolated,
one exact load-to-register substitution has been published,
a weaker concurrency-aware alias attribute currently exists and is usable,
the unmerged LLVM proposal represents future accepted policy,
or removing
__restrict__has zero performance cost in every kernel.
The supported conclusion is narrower:
The affected CK kernel relied on conflicting cross-thread accesses to one LDS object during overlapping function invocations, while
p_sharedcarried a compiler contract that excluded that use. The workgroup barrier could order the intended operations, but it could not legalize or restore a memory dependency erased under the invalid contract.
The two-part failure chain
The full event can now be written as one sequence.
Block-shared LDS
↓
threads intentionally communicate through the same locations
↓
p_shared declared __restrict__
↓
Clang lowers the qualifier to noalias
↓
peer-thread conflicting writes are outside the declared contract
↓
alias analysis need not treat the barrier
as clobbering the LDS value
↓
earlier value can remain in a register
↓
consumer observes stale state
↓
grouped-convolution output becomes numerically incorrectThe repair changed the first false statement in that chain.
p_shared is not exclusiveOnce the compiler can again see that peer accesses may modify the object, the existing barrier can perform the role it was originally written to perform.
The barrier was necessary but not sufficient
Removing the barrier would still be wrong.
No barrier
→ consumer can run before producer writes completeKeeping the barrier while lying about aliasing was also wrong.
Barrier present
+
invalid noalias
→ compiler may remove the dependency the barrier ordersThe correct program needs both:
truthful memory ownership
+
correct execution synchronizationNeither substitutes for the other.
The final lesson is that synchronization begins before code generation
A programmer may think of synchronization as a machine-level operation.
Emit a barrier.
Wait for peer threads.
Read shared memory.The compiler first decides whether the shared-memory read is semantically dependent on those peer writes.
That decision is influenced by the source’s aliasing promises.
Truthful alias contract
↓
peer write remains a possible modification
↓
barrier remains relevant to the location
↓
consumer reload is preservedFalse exclusive contract
↓
peer write is not a legal modification
↓
barrier may have no ModRef effect on the location
↓
consumer can reuse an old valueA barrier orders dependencies that the compiler recognizes.
It cannot restore a dependency that the source contract declared impossible.
ROCm Composable Kernel #10574 was fixed not by adding stronger synchronization, but by making the pointer declaration tell the truth about who owned the memory.
Previous article
ROCm Composable Kernel #10574 — Why __restrict__ Was an Invalid Contract for Block-Shared LDS
Link the title above to Part 1 after publication.
Related material
ROCm Composable Kernel PR #10574 — Remove erroneous
__restrict__qualifierLLVM #196923 — No synchronization effects for never-escaping identified local
LLVM #211507 — Clarify interaction of
noaliasand synchronizationUnmerged LLVM #211486 — Proposed sync-scope-aware treatment for pointer arguments
gridwise_gemm_multiple_d_xdl_cshuffle.hppat the CK merge commit
Patch status: Merged into ROCm rocm-libraries develop
Affected contract: C++ __restrict__ lowered into LLVM noalias
Dynamic-scope boundary: One overlapping kernel-function invocation per workitem
Invalid access pattern: One workitem writes an LDS location that another workitem reads during both invocations’ lifetimes
Existing synchronization: block_sync_lds()
Why it was insufficient: The barrier did not end the noalias scope or restore an alias dependency excluded by the pointer contract
Compiler trigger: Narrower synchronization ModRef effects for objects considered non-escaping and locally unaffected
Source repair: Remove four __restrict__ qualifiers from p_shared
Observed validation: Numerical failure before, PASS after, and PASS with the relevant AA behavior reverted
Public evidence gap: No complete IR/ISA diff or new minimal compiler-specific regression was published
This is Part 2 and the final article in the ROCm Composable Kernel LDS aliasing series.
Part 1 examined why a workgroup-shared scratch pointer could not truthfully promise exclusive access when the GEMM algorithm intentionally transported values between threads through LDS.
This final article examined why noalias remains active across an in-kernel barrier, how synchronization ModRef modeling interacts with private-memory reasoning, and why the hardware barrier could not reload a value after the compiler had eliminated the need for that reload under an invalid source contract.
#ROCm #ComposableKernel #AMD #MI300X #LDS #GPUProgramming #Restrict #NoAlias #AliasAnalysis #MemoryModel #CompilerOptimization #CodeAnalysis