Skip to content

Check for gpu register crosstalk - #9325

Open
abadams wants to merge 13 commits into
mainfrom
abadams/gpu_register_crosstalk_check
Open

Check for gpu register crosstalk#9325
abadams wants to merge 13 commits into
mainfrom
abadams/gpu_register_crosstalk_check

Conversation

@abadams

@abadams abadams commented Aug 12, 2026

Copy link
Copy Markdown
Member

It's currently possible to compute something at the gpu block level but store it in registers. This is a problem, because in general you can't read registers written by another thread (unless it's a warp shuffle). These allocations were silently thread-local instead of actually shared, so it's currently possible to write a schedule that produces garbage this way. We could just forbid using this memory type at the block level, but the canonical CUDA sgemm does indeed use register storage at the block scope to store its accumulators, so forbidding it would make that schedule impossible.

This PR instead adds a pass that tries to check it's safe, in the spirit of the way in which we allow parallelization of some rvars, if it can be proved hazard-free.

I tried and failed to make this a property of or a check on the schedule. The robust place to do it turned out to be early in lowering after you have some loops nests stamped down.

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.99225% with 40 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.13%. Comparing base (e3e0ca0) to head (74fa54b).

Files with missing lines Patch % Lines
src/CheckGPUCrossTalk.cpp 69.29% 28 Missing and 11 partials ⚠️
src/Lower.cpp 50.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9325      +/-   ##
==========================================
+ Coverage   70.11%   70.13%   +0.02%     
==========================================
  Files         259      260       +1     
  Lines       79135    79264     +129     
  Branches    19286    19320      +34     
==========================================
+ Hits        55487    55594     +107     
- Misses      17880    17898      +18     
- Partials     5768     5772       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread test/error/gpu_register_crosstalk.cpp Outdated

@alexreinking alexreinking left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to fix the error test in Make

Comment thread test/error/gpu_register_stored_by_one_warp.cpp Outdated

@alexreinking alexreinking left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM when green.

Base automatically changed from abadams/peel_lets to main August 16, 2026 22:20
abadams and others added 13 commits August 16, 2026 15:20
Register and stack memory is private to a GPU thread, so an allocation of one
outside the loops over threads gives every thread its own copy of the whole
thing rather than one copy shared by the block. A thread that reads a part of
it another thread computed reads its own uninitialized copy of that part
instead. Halide accepted such schedules and quietly computed the wrong answer.

Check that each thread keeps to its own part of the allocation, in the manner
of can_parallelize_rvar: describe the same access made by some other thread,
and require that the two can never meet. Only the variables bound at or inside
the loops over threads are renamed to make the other thread, because the rest,
such as the base of the block's tile, are shared by the whole block.

The check has to sit between storage folding and storage flattening, which is
later in lowering than validation usually goes. See the comment in
CheckGPUCrossTalk.h.

Also lifts RenameFreeVars and the substitution of boolean lets out of
ParallelRVar.cpp, which is where this technique already lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An allocation of thread-private memory outside the loops over threads is also
broken when a store to it is not in as many loops over threads as the loads
are. Fusing the loops leaves such a store guarded by a test that only the first
thread of the missing dimensions passes, so every other thread reads its own
copy of something it never wrote. A Func computed at the block level in
registers hits this, which is an ordinary thing to write.

Drop the exemption for stores that do not depend on which thread they are in.
The loops over threads are unordered, so it does not make a store safe, and for
a store outside them it is not even true that every thread does it.

Review notes: indent the new Makefile entries, have RenameFreeVars make names
that are unique rather than suffixed by hand, and expand chained boolean lets
in one pass rather than substituting each through the rest.

Test the cases that work as well as the ones that do not: one element per
thread and several per thread, against a neighbour read, a scalar stored by one
thread, a row stored by one warp, and a shifted one-to-one read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…isjoint

Requiring each thread to keep to a disjoint part of the allocation is stronger
than it needs to be. A tail strategy has neighbouring threads recompute each
other's values, so their parts overlap, and each still only reads what it wrote
itself. Ask for that instead: every load must be within a region this same
thread has already stored.

Three things that took a test each to find. The store has to come first in the
body, or the load an update definition does of the site it is about to store to
matches that very store. It has to be in at least as many loops over threads,
or a store done by one thread stands in for all of them. And the region is
taken with the thread symbolic but the loops within a thread's part bounded:
bounding the thread widens every region to cover all of them, which is what we
are trying to tell apart, and leaving the inner loops unbounded cannot describe
a tile. Tail strategies wrap their clamp in a likely intrinsic, which has to
come off before any of that folds.

Test a tile per thread walked by serial loops with such a tail, and two stages
that agree, against two stages that do not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A load has to be shown to be within the region the same thread stores, and the
loops a thread runs inside its own part of the allocation are what bound how
far its accesses reach. Only constant bounds were kept, so a loop that starts
at the thread's own part of the allocation was dropped, its region came back
unbounded, and the check failed. That happens as soon as anything is staged
through a wrapper computed inside the thread loops.

Keep the bounds whatever they are, and canonicalize them the way the accesses
themselves are canonicalized, so that a bound naming one loop over threads and
an access naming another are talking about the same thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The check was reformulated twice, and each formulation left something behind.

The first one proved that no two threads could meet, which meant renaming one
thread's variables to make a second instance of it, so the machinery for that
was hoisted out of ParallelRVar.cpp. Reading a thread's own stores instead
needs none of it, and nothing but ParallelRVar.cpp has used it since. Put those
files back the way they were.

Along the same lines, drop per_thread, which nothing reads, and the includes
and helper that went with the formulations that are gone.

An access no longer carries its own copy of the loops over threads it sits in.
Its arguments are put in terms of the fused loops when it is recorded, which is
all anything downstream wanted, along with how deep it sits and how many
threads there are. Its position in the list is what says whether it has already
happened, so it needs no separate ordering.

Each access's region is now computed once rather than once per candidate
partner, which is the difference between one simplifier pass per access per
dimension and one per pair of accesses per dimension.

Note in both places that decide what a loop over threads is why a loop over
lanes counts for one and not the other: the lanes of a warp share registers,
which is what makes warp shuffles work, but two threads share nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Substituting copies each let's value into every use, so a chain of lets that
each build on the one before expands into something the size of their product.
Wrap the definitions around the index instead. Everything the index is then put
through - substitute, simplify, and bounds_of_expr_in_scope - already handles
lets.

The one place that wants them expanded is the description of an access in an
error message, which only happens on the way to aborting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
resolve() rewrapped the peeled lets by hand, testing each one against the body
with expr_uses_var, which is quadratic in the number of lets. rewrap_used_lets
does the same thing in one pass.

An access in an error message is now printed the way it was written rather than
with its lets expanded. Expanding them produces a bigger expression, not a
clearer one, and the names it drops are the ones the schedule used.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every place that put a let-bound index back together went on to rewrite it in
terms of the fused loops over threads, so canonicalize does both, and as a
member it needs neither the lets nor the loops passed to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The check is not specific to CUDA, so the tests for it shouldn't be either.
Use the GPU the environment names and skip when it names none, the way other
GPU tests do. Verified that all five still report the error under OpenCL as
well as CUDA, and that the one that should compile still does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An error test that skips produces no error, which the Makefile's test
harness reads as a failure. These are compile-time checks, so they need
a GPU API but not a GPU: name one when the environment doesn't, and the
check gets tested everywhere rather than only where a GPU is attached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An error test that returns cleanly produces no error, which the
Makefile's test harness reads as a failure. Skip the way the other error
tests that can't always run do, with an assert to report something.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@abadams
abadams force-pushed the abadams/gpu_register_crosstalk_check branch from 65b8f0b to 74fa54b Compare August 16, 2026 22:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants