Skip to content

Defer GPU shared/heap allocation fusing to the offload site#9209

Open
abadams wants to merge 3 commits into
mainfrom
abadams/defer_fusing_gpu_allocations
Open

Defer GPU shared/heap allocation fusing to the offload site#9209
abadams wants to merge 3 commits into
mainfrom
abadams/defer_fusing_gpu_allocations

Conversation

@abadams

@abadams abadams commented Jul 15, 2026

Copy link
Copy Markdown
Member

fuse_gpu_loops fuses and renames allocations halfway through lowering, making a confusing mess like this (from camera_pipe):

     gpu_block<CUDA> (processed.s0.v1.v1.block_id_y, 0, t883 + -1) {
      gpu_block<CUDA> (processed.s0.v0.v0.v0.block_id_x, 0, t884 + -1) {
       allocate allocgroup__f1.0_f7.2__deinterleaved.1_f27.5__f13.3_f28.6__output.4[uint16 * 7370] in GPUShared
       gpu_thread<CUDA> (.thread_id_y, 0, 13) {
        gpu_thread<CUDA> (.thread_id_x, 0, 31) {
         let processed.s0.v1.v16.base = min(processed.s0.v1.v1.block_id_y*12, t885 + -12)
         let processed.s0.v0.v0.v15.base = min(processed.s0.v0.v0.v0.block_id_x*28, t886 + -28)
         if (.thread_id_y <= 9) {
          produce f1 {
           allocgroup__f1.0_f7.2__deinterleaved.1_f27.5__f13.3_f28.6__output.4[((.thread_id_y*64) + .thread_id_x)*2] = 
             let t900 = .thread_id_x + processed.s0.v0.v0.v15.base in
             let t901 = ((((processed.s0.v1.v16.base/2) + .thread_id_y)*2) + -4)*input.stride.1 in
             let t902 = (t900*2) + (t901 + t887) in
             min(input[t902 + 12], max(input[t902 + 10], max(input[t902 + 14], max(input[((t900*2) + (t901 + t888)) + 12], input[((t900*2) + (t901 + t889)) + 12]))))

           allocgroup__f1.0_f7.2__deinterleaved.1_f27.5__f13.3_f28.6__output.4[(((.thread_id_y*64) + .thread_id_x)*2) + 1] = 
             let t903 = .thread_id_x + processed.s0.v0.v0.v15.base in
             let t904 = ((((processed.s0.v1.v16.base/2) + .thread_id_y)*2) + -4)*input.stride.1 in
             let t905 = (t903*2) + (t904 + t887) in
             min(input[t905 + 13], max(input[t905 + 11], max(input[t905 + 15], max(input[((t903*2) + (t904 + t888)) + 13], input[((t903*2) + (t904 + t889)) + 13]))))

           allocgroup__f1.0_f7.2__deinterleaved.1_f27.5__f13.3_f28.6__output.4[(((.thread_id_y*64) + .thread_id_x)*2) + 64] = 
             let t906 = .thread_id_x + processed.s0.v0.v0.v15.base in
             let t907 = ((((processed.s0.v1.v16.base/2) + .thread_id_y)*2) + -3)*input.stride.1 in
             let t908 = (t906*2) + (t907 + t887) in
             min(input[t908 + 12], max(input[t908 + 10], max(input[t908 + 14], max(input[((t906*2) + (t907 + t888)) + 12], input[((t906*2) + (t907 + t889)) + 12]))))

           allocgroup__f1.0_f7.2__deinterleaved.1_f27.5__f13.3_f28.6__output.4[(((.thread_id_y*64) + .thread_id_x)*2) + 65] = 
             let t909 = .thread_id_x + processed.s0.v0.v0.v15.base in
             let t910 = ((((processed.s0.v1.v16.base/2) + .thread_id_y)*2) + -3)*input.stride.1 in
             let t911 = (t909*2) + (t910 + t887) in
             min(input[t911 + 13], max(input[t911 + 11], max(input[t911 + 15], max(input[((t909*2) + (t910 + t888)) + 13], input[((t909*2) + (t910 + t889)) + 13]))))
...

There is a single fused allocation for all the block-level funcs, and all stores and loads are rewritten to offset accesses to this single allocation. This is a problem for humans to read, but it also makes it hard for later lowering passes (the profiler change I'm working on) to attribute a store to a particular Func. This PR produces this instead:

     gpu_block<CUDA> (processed.s0.v1.v1.block_id_y, 0, t868 + -1) {
      gpu_block<CUDA> (processed.s0.v0.v0.v0.block_id_x, 0, t869 + -1) {
       allocate shared_alloc[uint16 * 7370] in GPUShared
       allocate f7.2[uint16 * 279] in GPUShared
        custom_new { (void *)offset_pointer(shared_alloc, 0) }
       allocate f1.0[uint16 * 1280] in GPUShared
        custom_new { (void *)offset_pointer(shared_alloc, 0) }
       allocate f27.5[int16 * 2436] in GPUShared
        custom_new { (void *)offset_pointer(shared_alloc, 1280) }
       allocate deinterleaved.1[uint16 * 1280] in GPUShared
        custom_new { (void *)offset_pointer(shared_alloc, 1280) }
       allocate f28.6[uint8 * 2436] in GPUShared
        custom_new { (void *)offset_pointer(shared_alloc, 7432) }
       allocate f13.3[uint16 * 279] in GPUShared
        custom_new { (void *)offset_pointer(shared_alloc, 3716) }
       allocate output.4[int16 * 2436] in GPUShared
        custom_new { (void *)offset_pointer(shared_alloc, 4934) }
       gpu_thread<CUDA> (.thread_id_y, 0, 13) {
        gpu_thread<CUDA> (.thread_id_x, 0, 31) {
         let processed.s0.v1.v16.base = min(processed.s0.v1.v1.block_id_y*12, t870 + -12)
         let processed.s0.v0.v0.v15.base = min(processed.s0.v0.v0.v0.block_id_x*28, t871 + -28)
         if (.thread_id_y <= 9) {
          produce f1 {
           f1.0[((.thread_id_y*64) + .thread_id_x)*2] = 
             let t885 = .thread_id_x + processed.s0.v0.v0.v15.base in
             let t886 = ((((processed.s0.v1.v16.base/2) + .thread_id_y)*2) + -4)*input.stride.1 in
             let t887 = (t885*2) + (t886 + t872) in
             min(input[t887 + 12], max(input[t887 + 10], max(input[t887 + 14], max(input[((t885*2) + (t886 + t873)) + 12], input[((t885*2) + (t886 + t874)) + 12]))))

           f1.0[(((.thread_id_y*64) + .thread_id_x)*2) + 1] = 
             let t888 = .thread_id_x + processed.s0.v0.v0.v15.base in
             let t889 = ((((processed.s0.v1.v16.base/2) + .thread_id_y)*2) + -4)*input.stride.1 in
             let t890 = (t888*2) + (t889 + t872) in
             min(input[t890 + 13], max(input[t890 + 11], max(input[t890 + 15], max(input[((t888*2) + (t889 + t873)) + 13], input[((t888*2) + (t889 + t874)) + 13]))))

           f1.0[(((.thread_id_y*64) + .thread_id_x)*2) + 64] = 
             let t891 = .thread_id_x + processed.s0.v0.v0.v15.base in
             let t892 = ((((processed.s0.v1.v16.base/2) + .thread_id_y)*2) + -3)*input.stride.1 in
             let t893 = (t891*2) + (t892 + t872) in
             min(input[t893 + 12], max(input[t893 + 10], max(input[t893 + 14], max(input[((t891*2) + (t892 + t873)) + 12], input[((t891*2) + (t892 + t874)) + 12]))))

           f1.0[(((.thread_id_y*64) + .thread_id_x)*2) + 65] = 
             let t894 = .thread_id_x + processed.s0.v0.v0.v15.base in
             let t895 = ((((processed.s0.v1.v16.base/2) + .thread_id_y)*2) + -3)*input.stride.1 in
             let t896 = (t894*2) + (t895 + t872) in
             min(input[t896 + 13], max(input[t896 + 11], max(input[t896 + 15], max(input[((t894*2) + (t895 + t873)) + 13], input[((t894*2) + (t895 + t874)) + 13]))))

Note the stores are now to f1.0, so we know which Func is being computed. The original distinct per-Func Allocate nodes remain, but they're just aliases of slices of the shared allocation. This IMO keeps things more understandable and will solve my profiler problem. In offload_gpu_loops the aliases are resolved and it's rewritten into the first form, so there's no impact on backends.

abadams and others added 2 commits July 15, 2026 12:59
fuse_gpu_thread_loops still hoists shared, heap, and register
allocations and normalizes GPU loop structure, and still computes the
lifetime/offset fusion analysis where the barrier structure is intact.
But instead of erasing the per-Func allocations and rewriting their
loads and stores onto the fused buffer, it now emits a backing
allocation plus one aliasing Allocate per Func, whose new_expr is a new
offset_pointer(backing, offset) intrinsic. Loads and stores keep their
original per-Func names.

A new flatten pass in inject_gpu_offload folds each aliasing
allocation's offset back into its loads and stores and drops the
aliasing nodes, reproducing the flat representation the device backends
expect. It runs after the conceptual stmt is captured, so the profiler
and the conceptual stmt see per-Func allocation names and stores can be
attributed to a specific Func.

Supporting fixes:
- offset_pointer is a non-pure Intrinsic so CSE/LICM won't lift it out
  of new_expr and escape the backing allocation's scope.
- RemoveDeadAllocations traverses new_expr, so a backing allocation
  referenced only through aliasing new_exprs isn't dropped as dead.
- PartitionLoops no longer moves a let inside an Allocate when the let
  is used in the allocation's new_expr or condition (evaluated in the
  outer scope), which previously left a dangling reference for
  dynamically-sized allocations.
- The flatten pass re-derives load/store alignment from the offset,
  rather than keeping the original alignment across a possibly
  misaligned shift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LowerWarpShuffles treated any non-shared allocation above a lane loop as
per-lane register storage, striping its size by the warp size and moving
the Allocate inside the lane loop. With GPU allocation fusing now
emitting per-Func aliasing allocations, a heap allocation used inside a
gpu_lanes loop (e.g. apps/iir_blur) reaches this pass as a real Allocate
node and was wrongly striped, which also separated it from its Free and
left a dangling reference. Heap is global memory and is never warp-level
storage, so skip it alongside GPUShared. On main no heap Allocate nodes
reach this pass, so this is a no-op there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.75000% with 9 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@3bb5d0c). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/FuseGPUThreadLoops.cpp 92.30% 0 Missing and 3 partials ⚠️
src/LowerWarpShuffles.cpp 0.00% 3 Missing ⚠️
src/PartitionLoops.cpp 33.33% 0 Missing and 2 partials ⚠️
src/OffloadGPULoops.cpp 96.66% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #9209   +/-   ##
=======================================
  Coverage        ?   69.81%           
=======================================
  Files           ?      255           
  Lines           ?    78593           
  Branches        ?    18802           
=======================================
  Hits            ?    54872           
  Misses          ?    18136           
  Partials        ?     5585           

☔ 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.

@mcourteaux

Copy link
Copy Markdown
Contributor

This is great. I think it's totally fine now to drop the complicated name of that alloc group. AFAICT, there will be only one. Just allocgroup0 for example would be fine.

@abadams

abadams commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Oh good point, I don't need the monster name now. Will change.

The per-Func names now live on the aliasing allocations, so the backing
allocation no longer needs a name concatenating every fused Func. Name
it unique_name("shared_alloc") or unique_name("global_alloc") instead of
the "allocgroup__f1__f2__..." monster name. The single-shared-allocation
case still names the backing after its Func, since there are no aliasing
wrappers there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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