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_shared

But 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 peers

The 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 failure
Same compiler
+
__restrict__ removed
→ PASS
Compiler with the relevant alias-analysis change reverted
+
__restrict__ present
→ PASS

The 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 hardware
Compiler alias model
→ decides which memory dependencies must remain in the program

A 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:

noalias

parameter 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 F2

Each 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 X

But 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] = 42
Thread 1:

p_shared = address X
value = p_shared[0]

At the hardware level:

both accesses
→ same LDS location

At the LLVM contract level, however:

Thread 0 store
→ based on Thread 0’s dynamic argument

Thread 1 load
→ based on Thread 1’s dynamic argument

Thread 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 F0
Thread 1 invocation

enter F1
   ↓
write LDS
   ↓
block_sync_lds()
   ↓
read LDS
   ↓
exit F1

Placed side by side:

F0: enter ── write ── barrier ── read ── exit
F1: enter ── write ── barrier ── read ── exit

The 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 exited

The 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 scope

A 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 barrier

Operationally, 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 exit

There is no semantic operation equivalent to:

drop all noalias assumptions

start a new invocation

re-acquire pointer state

at 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 X

The accesses in Kernel B happen after Kernel A’s relevant invocations have exited.

Writer function exit
happens before
reader function entry

That is fundamentally different from:

Thread A and Thread B

inside the same concurrently executing kernel

communicating through LDS

across an internal barrier

The 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 memory

This 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 %shared

If the barrier is considered capable of affecting %shared:

%before
cannot automatically replace
%after

The 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 %private

Suppose:

%private never escapes

no other thread can obtain its address

no instruction writes it between the loads

The 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 value

That 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_shared

The reported failure emerged from the interaction between:

Source contract:

other accesses cannot legally conflict with p_shared

and:

Alias-analysis refinement:

synchronization need not clobber memory
that appears local and unaffected by peers

Under 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 again

The optimizer may see:

load

operation that does not ModRef this location

same load

and 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 reuse

The 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 value

The 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 possible

The 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 optimization

rather than:

LLVM broke a valid synchronized program

The 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:

  • noalias applies 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 pointer

A 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 it

That 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_shared

without __restrict__.


The landed source leaves the optimization question open

The CK diff removes the qualifier with a comment:

void* p_shared;  // FIXME: reinstate __restrict__ qualifier

The 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 locations

The 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 correctness

Returning 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 it

The more precise repair is to stop classifying collective LDS as exclusive.

Keep valid optimizer improvement

remove invalid source fact

That 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 LDS

Both barriers still occur:

after function entry

before function exit

Neither 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 observable
noalias
→ which accesses may refer to the same memory

Using 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 object

The 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
→ PASS
New compiler behavior
+
old source
→ FAIL
New compiler behavior
+
corrected source
→ PASS

The 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 / nsw

can 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_shared

Yet the observed result was:

137,150 incorrect values

22.78% numerical error rate

This 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.Test2D

as 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 comparison

But the patch added no separately named test file specifically documenting:

cross-thread LDS modification

+

noalias

+

barrier

→ invalid contract

The 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 path
Minimal compiler-contract regression
→ not added by this PR

A 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 form

under 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 value

Such 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 output
non-restrict source
+
same compiler
→ correct output
restrict source
+
reverted alias-analysis behavior
→ correct output

The 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 a noalias-style compiler contract.

  • LLVM’s current documented noalias semantics 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 restrict produces 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_shared carried 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 incorrect

The repair changed the first false statement in that chain.

p_shared is not exclusive

Once 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 complete

Keeping the barrier while lying about aliasing was also wrong.

Barrier present
+
invalid noalias
→ compiler may remove the dependency the barrier orders

The correct program needs both:

truthful memory ownership

+

correct execution synchronization

Neither 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 preserved
False exclusive contract
        ↓
peer write is not a legal modification
        ↓
barrier may have no ModRef effect on the location
        ↓
consumer can reuse an old value

A 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


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

Popular posts from this blog

AMD Is Trying to Turn Kernel Diversity Into One Software Asset

PyTorch #188031 — Why Did Forward Use 64-Bit Indexing While Backward Still Used uint32_t?

NVIDIA CUTLASS #3017 — Why load() and store() Mapped the Same Tile Coordinate to Different Addresses