Repository navigation
[vulkan] Batch operations into one submission, and spin before blocking on fences - #9677
mcourteaux wants to merge 2 commits into
Conversation
derek-gerstmann
left a comment
There was a problem hiding this comment.
PR Review: [vulkan] Batch operations into one submission, and spin before blocking on fences
Summary
Batches up to 32 Vulkan operations (compute dispatches, buffer clears, host-to-device uploads, and device-to-device copies) into a single VulkanSubmission command buffer, narrows inter-command barriers from ALL_COMMANDS to COMPUTE_SHADER | TRANSFER, and spins on vkGetFenceStatus for up to 100 µs before blocking in vkWaitForFences. Scope matches the PR description across 1 commit and 5 files (+437/-134). Overall risk is Medium-High due to subtle interactions between deferred command buffer submission, mid-recording scalar buffer allocation, and RegionAllocator buffer destruction when splitting/coalescing released regions.
- Review mode: standard
Change Surface
- 21 symbols changed/added (
vk_wait_for_fences,vk_release_staging_regions,vk_retire_submission,vk_create_submission_queue,vk_submit,vk_flush_submissions,vk_wait_for_submissions,vk_destroy_submission_queue,vk_start_submission,vk_begin_submission,vk_submission_is_done,vk_flush_submissions_if_gpu_needs_work,vk_end_submission,vk_attach_staging_region,vk_throttle_staging,vk_create_submission_descriptor_set,vk_submission_args_region,vk_create_descriptor_pool,halide_vulkan_finalize_kernels,halide_vulkan_copy_to_device,halide_vulkan_run) - Starred (high-impact):
halide_vulkan_run,halide_vulkan_copy_to_device,halide_vulkan_finalize_kernels,vk_begin_submission,vk_end_submission,vk_wait_for_submissions - Surfaces touched:
src/runtime/vulkan.cpp,src/runtime/vulkan_internal.h,src/runtime/vulkan_resources.h,test/correctness/CMakeLists.txt,test/correctness/gpu_many_launches_without_sync.cpp - Scope: match
Blast Radius
- Checked all callers of
vk_create_descriptor_pool,vk_begin_submission,vk_end_submission,vk_create_submission_descriptor_set, andvk_wait_for_submissionsacrosssrc/runtime/vulkan*. - Unupdated callers:
VulkanMemoryAllocator::collect(src/runtime/vulkan_memory.h:510) callsvk_wait_for_submissions(user_context, this, false)assuming it only waits on already-submitted work; in this PRvk_wait_for_submissionsflushessubmission_queue->recording, which breaks ifcollect()is triggered insidehalide_vulkan_runwhile a submission is mid-recording. - Additionally, when
halide_can_reuse_device_allocationsis true,halide_vulkan_device_free(src/runtime/vulkan.cpp:162) marks regionsAvailablewhile unsubmitted commands insubmission_queue->recording(or in-flight submissions) still reference theirVkBuffer. If a subsequentallocator->reserve()splits (RegionAllocator::split_block_region) or coalesces (BlockAllocator::reserve_memory_region->RegionAllocator::collect->coalesce_block_regions) that region,deallocate_block_region_if_unusedimmediately callsvkDestroyBuffer(vulkan_memory.h:1252) without flushing or waiting for submissions. - Dangling references: none
- New orphans (dead_code): none
Prior Knowledge Surfaced
- none relevant
Test Gaps
- Varying-size
device_free()+ reallocation while commands referencing the freed buffer are still buffered insubmission_queue->recordingor in flight on the GPU.
Affected-Test Run
- Author verified
correctness_gpu_many_launches_without_syncand 237-test GPU correctness subset on RTX 4070 (227 pass, 10 skip, 0 fail).
Lint
git diff --checkpassed cleanly.
Security
- No issues found
Verdict
Request changes
Blocking items to resolve before merge:
- Prevent
VulkanMemoryAllocator::collect()from flushing and endingsubmission_queue->recordingmid-command whenvk_submission_args_regionallocates a scalar uniform buffer duringhalide_vulkan_run. - Guard
allocator == nullptrinvk_wait_for_submissions/vk_flush_submissions(and sibling submission queue helpers) sohalide_vulkan_device_releasedoes not dereference a nullallocatorwhen called before context creation. - Ensure splitting or coalescing an
Availableregion inRegionAllocatordoes not callvkDestroyBufferwhile unsubmitted (submission_queue->recording) or in-flight submissions still reference itsVkBuffer.
| return halide_error_code_success; | ||
| } | ||
|
|
||
| int error_code = vk_flush_submissions(user_context, allocator); |
There was a problem hiding this comment.
[HIGH] vk_wait_for_submissions now calls vk_flush_submissions, which ends (vkEndCommandBuffer) and submits (vkQueueSubmit) submission_queue->recording. However, halide_vulkan_run (src/runtime/vulkan.cpp:1174-1196) calls vk_submission_args_region -> vk_create_scalar_uniform_buffer -> allocator->reserve() after vk_begin_submission has started recording into submission. If vkCreateBuffer inside VulkanMemoryAllocator::allocate_region (src/runtime/vulkan_memory.h:1165) fails and triggers instance->collect(user_context) -> vk_wait_for_submissions(user_context, this, false), submission will be ended and submitted mid-recording before vk_record_dispatch_call (vulkan.cpp:1226) and vk_end_submission (vulkan.cpp:1235) run on it.
There was a problem hiding this comment.
Agreed. This branch is rebased onto #9676, whose deallocate_region now also waits for submissions, so this got wider than the vkCreateBuffer-failure path: a split while reserving the scalar-argument buffer would flush too. Fixed in 35c57dc: while vk_submission_args_region reserves or destroys an args buffer, the submission queue is marked as holding a half-recorded command. During that window vk_flush_submissions doesn't submit the recording submission (waits still wait for submitted work), and VulkanMemoryAllocator::collect skips collection. That window only splits, coalesces or frees uniform-buffer regions, which no recorded or submitted work uses once they are released.
🤖 Generated with Claude Code
|
|
||
| // Submits the submission being recorded (if any). | ||
| int vk_flush_submissions(void *user_context, VulkanMemoryAllocator *allocator) { | ||
| VulkanSubmissionQueue *submission_queue = allocator->submission_queue; |
There was a problem hiding this comment.
[HIGH] vk_flush_submissions (as well as vk_wait_for_submissions at line 409, vk_destroy_submission_queue at line 453, vk_flush_submissions_if_gpu_needs_work at line 612, and vk_throttle_staging at line 650) dereferences allocator->submission_queue without checking allocator == nullptr. When halide_vulkan_device_release (src/runtime/vulkan.cpp:302) is called before a Vulkan context has been initialized, halide_default_vulkan_acquire_context(..., create=false) returns halide_error_code_success with *allocator = nullptr, causing a null pointer dereference in vk_wait_for_submissions.
There was a problem hiding this comment.
Agreed. vk_wait_for_submissions and vk_destroy_submission_queue got the guard in #9676 (98bde6f, with correctness_gpu_device_release_idle), and 66de740 adds it to vk_flush_submissions, vk_flush_submissions_if_gpu_needs_work and vk_throttle_staging.
🤖 Generated with Claude Code
| if (submission->descriptor_pool != VK_NULL_HANDLE) { | ||
|
|
||
| // The submission is idle here: its descriptor pool can be replaced with a bigger one. | ||
| if ((submission->descriptor_pool != VK_NULL_HANDLE) && (storage_buffer_count > submission->descriptor_pool_storage_buffer_count)) { |
There was a problem hiding this comment.
[MEDIUM] Two issues with descriptor pool replacement here:
- If
submission->descriptor_poolis destroyed on line 509 andvk_create_descriptor_poolfails on line 515,submission->descriptor_poolisVK_NULL_HANDLEwhilesubmission->descriptor_pool_storage_buffer_countretains its old non-zero value (and doubles again on retry). - Because
vk_start_submissiononly receives the single launch'sstorage_buffer_count, a batch flushed early at line 572 due to cumulative pool exhaustion (e.g. 9 launches of 26 buffers vs 256 capacity) passesstorage_buffer_count = 26 <= 256here, so the pool never grows and every such batch stays capped at 9 launches instead of 32.
There was a problem hiding this comment.
Agreed on both, fixed in 66de740. (1) The count is cleared before the pool is created, so a failed create leaves a null pool with a zero count; the next start creates one again. (2) When a batch is submitted because it ran out of descriptors, the next submission asks for at least twice that pool's storage-buffer count, so pools grow geometrically until full batches fit.
🤖 Generated with Claude Code
|
|
||
| // submit the copy: the submission releases the staging buffer once it is done | ||
| // end the copy: the submission releases the staging buffer once it is done | ||
| vk_attach_staging_region(submission, staging_region, request.size); |
There was a problem hiding this comment.
[MEDIUM] If vk_begin_submission at line 538 returns nullptr (which in this PR can also happen when flushing a full batch via vk_submit fails), staging_region reserved at line 482 is not released via vk_release_region before returning.
There was a problem hiding this comment.
Agreed. Since the rebase onto #9676 (98bde6f), the copy functions hold their staging buffer in a ScopedStagingRegion that releases it on every early return, including a failed vk_begin_submission, unless it was handed to a submission.
🤖 Generated with Claude Code
| reset(a, 0); | ||
| Buffer<int32_t> *src = &a, *dst = &b; | ||
| const int iterations = 500; | ||
| for (int i = 0; i < iterations; i++) { |
There was a problem hiding this comment.
[HIGH] In this "freed buffers" test, tmp(W, H) has the exact same size on every iteration, so RegionAllocator always reuses the same BlockRegion and VkBuffer handle in-place. If subsequent allocations use a different size (e.g., alternating H and H / 2 or H * 2), RegionAllocator::reserve splits the released region (split_block_region at src/runtime/internal/region_allocator.h:453) or coalesces it (BlockAllocator::reserve_memory_region -> RegionAllocator::collect -> coalesce_block_regions at region_allocator.h:399), which calls deallocate_block_region_if_unused -> vkDestroyBuffer (vulkan_memory.h:1252) immediately on the host without flushing or waiting for submission_queue->recording or in-flight submissions that still reference that VkBuffer (violating VUID-vkDestroyBuffer-buffer-00922). Please also vary tmp's dimensions across iterations here to cover region split/coalesce.
There was a problem hiding this comment.
Agreed, done in 66de740: the freed buffer's height now cycles through H, 1.5H and 2H, and the launches use an H-row crop of it, so freed regions get split and coalesced while launches that use them are still recorded or in flight. The rebase also brings in correctness_gpu_free_while_in_flight from #9676, which covers the same case with larger size swings.
🤖 Generated with Claude Code
…re blocking on fences Every command buffer costs ~11-16 us on the GPU side however it is submitted, many times a small kernel. Kernel launches, uploads, clears and device-to-device copies are now recorded into the submission being recorded (up to 32 commands), which is submitted when it is full, when the GPU is running out of work (at most one earlier submission still running, also checked when a pipeline returns), or before anything waits. Each command is preceded by a compute|transfer barrier instead of an ALL_COMMANDS barrier at the start of each command buffer: inside a batch, the latter costs ~5 us per command. Waits for fences check them with vkGetFenceStatus for up to 100 us before blocking in vkWaitForFences: waking up from a blocking wait takes tens of microseconds. On an RTX 4070 (performance_gpu_dispatch_overhead), a back-to-back launch goes from 15.3 to ~4-5.6 us, launch + device_sync from 47-57 to ~17-19 us, a 64 KiB upload from 18 to ~8-9 us, and malloc + free from 12.8 to ~2 us. Adds correctness_gpu_many_launches_without_sync, which runs long chains of dependent launches, uploads, many-buffer launches and frees without synchronizing in between. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FRemjkLh26DpFGTumbznR
…ed sizes - When a batch runs out of descriptors before it is full, give the next submission a pool at least twice as big, so that batches grow to full size. - Keep a submission's descriptor pool count consistent when creating the pool fails. - Don't dereference a missing allocator in vk_flush_submissions, vk_flush_submissions_if_gpu_needs_work and vk_throttle_staging. - Vary the size of the freed buffers in gpu_many_launches_without_sync, so that their regions get split and coalesced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FRemkorK6ZfoSzt7ZTqXw
58948fa to
66de740
Compare
Stacked on #9676 (base branch
mcourteaux/vulkan-dispatch-overhead). It addresses the first two items of that PR's "What's left".Why
A standalone raw-Vulkan microbenchmark on the RTX 4070 shows where the time goes after #9676:
vkQueueSubmitALL_COMMANDSbarrier between eachvkWaitForFencesvkGetFenceStatusSo:
Changes
Record several operations into one submission. Kernel launches, uploads, clears and device-to-device copies are recorded into the submission currently being recorded (at most 32 commands). It is submitted:
halide_vulkan_finalize_kernels),device_sync, frees that wait, pipeline recreation,device_release).The descriptor pool, scalar-argument buffers and staging-buffer list of a submission now hold one entry per command. The descriptor pool grows when a launch needs more storage buffers than it has room for.
Narrower barrier between commands. Each command is preceded by a compute|transfer → compute|transfer barrier, instead of the
ALL_COMMANDSbarrier at the start of each command buffer. The runtime records only compute and transfer work, and host writes beforevkQueueSubmitare visible to the work it submits. Inside a batch, theALL_COMMANDSbarrier costs ~5 µs per command (table above).Spin before blocking on fences. Waits check the fences with
vkGetFenceStatusfor up to 100 µs before falling back tovkWaitForFences. This applies both to waits for results and to waits for a ring slot.Results (
performance_gpu_dispatch_overhead, RTX 4070, µs unless noted)device_syncdevice_syncBoth columns were measured back to back on the same build; only the runtime sources differ.
Behaviour to be aware of
halide_device_sync, copy to host), or once the GPU is running out of work. Results were already only guaranteed afterhalide_device_sync([vulkan] Reduce the fixed per-operation cost of the runtime #9676). If the application does long CPU work right after a pipeline without calling into the runtime, that tail runs later than it would with [vulkan] Reduce the fixed per-operation cost of the runtime #9676 alone. Flushing unconditionally inhalide_vulkan_finalize_kernelswould avoid this, but it gives up batching across pipeline calls: with one launch per call, the back-to-back case then stays at ~15 µs.Tests
correctness_gpu_many_launches_without_sync: long chains of dependent launches with no syncs (filling batches and wrapping the ring), uploads interleaved with launches, launches with 26 buffers each (exhausting descriptor pools mid-batch), and buffers freed right after the launches that use them. It passes on Vulkan and CUDA. With the barrier between commands disabled, it fails on Vulkan in 3 of 3 runs.HL_JIT_TARGET=host-vulkan-vk_v13-vk_int8-vk_int16-vk_int64, run directly becausectestforcesHL_JIT_TARGET=host: a 237-test correctness subset gives 227 pass, 10 skip (inapplicable to the target) and 0 fail. The subset covers gpu, device, async, atomics, vulkan, shared memory, tuples, extern stages,gpu_object_lifetime_*,leak_device_memory, reductions, specialization and similar.🤖 Generated with Claude Code
https://claude.ai/code/session_011FRemjkLh26DpFGTumbznR