[Misc] Configurable subgroup size#455
Conversation
| VK_SUBGROUP_FEATURE_BALLOT_BIT) { | ||
| caps.set(DeviceCapability::spirv_has_subgroup_ballot, true); | ||
| } | ||
|
|
There was a problem hiding this comment.
superfluous newline
The NVIDIA Vulkan driver has a bug where repeated vkDestroyInstance/vkCreateInstance cycles corrupt SubgroupLocalInvocationId after ~11 cycles. The corruption is permanent for the process lifetime and cannot be recovered from, even with VK_EXT_subgroup_size_control + requiredSubgroupSize=32. Fix: keep the VkInstance alive in VulkanLoader (process-lifetime singleton) and reuse it across qd.init/qd.reset cycles. Only the VkDevice is destroyed and recreated. Verified: 30 consecutive Vulkan init/reset cycles all produce correct SubgroupLocalInvocationId = [0..15]. Reproducer: standalone C program (tmp/vk_subgroup_repro.c) confirms the bug is in the NVIDIA driver, not in Quadrants code.
…cles Cycles qd.init(vulkan)/qd.reset() 20 times and verifies that SubgroupLocalInvocationId stays stable (same values as first cycle). Vendor-agnostic: works regardless of the hardware's native subgroup size. Without the VkInstance reuse fix, this fails at cycle ~11 on NVIDIA due to a driver bug.
Enable VK_EXT_subgroup_size_control and chain VkPipelineShaderStageRequiredSubgroupSizeCreateInfo into compute pipeline creation, explicitly requesting maxSubgroupSize. Without this, the NVIDIA driver may reduce its default subgroup size (from 32 to 8) after several VkDevice creation/destruction cycles, causing SubgroupLocalInvocationId to wrap at 8 and breaking Tile16x16 ops.
…groupSize 32 matches the warp size on NVIDIA and SIMD group size on Apple/Metal. Drop the VkPhysicalDeviceSubgroupSizeControlProperties query since we don't need min/max values anymore.
Compute pipelines only have one shader stage, so a vector is unnecessary.
…e=N) Plumb subgroup_size from Python through the full compilation pipeline: loop_config → AST → IR → SPIR-V codegen → TaskAttributes → PipelineSourceDesc → VulkanPipeline::Params → create_shader_stages When subgroup_size > 0, use it as requiredSubgroupSize; otherwise default to 32 for backwards compatibility. Also expose qd.simt.min_subgroup_size() and max_subgroup_size() to let users query the device's supported range at runtime.
c80a905 to
b03cdd4
Compare
| block_dim_adaptive (bool): Whether to allow backends set block_dim adaptively, enabled by default | ||
| bit_vectorize (bool): Whether to enable bit vectorization of struct fors on quant_arrays. | ||
| name (str): Optional name for this loop, used in GPU kernel names for profiling and debugging. | ||
| subgroup_size (int): Required subgroup (warp/wavefront/SIMD group) size for Vulkan compute pipelines. Defaults to 32. Ignored on CUDA and Metal (fixed at 32). |
There was a problem hiding this comment.
shouldnt be ignored. should throw exception if invalid size
Instead of silently ignoring invalid subgroup_size values: - CUDA: only 32 is valid (warp size) - Metal: only 32 is valid (SIMD group width) - AMDGPU: 32 or 64 (wave size) - CPU: not supported, raises ValueError - Vulkan: passed through to requiredSubgroupSize (validated by driver)
- test_subgroup_size_valid_cuda: subgroup_size=32 accepted on CUDA - test_subgroup_size_invalid_cuda: subgroup_size=16 raises ValueError - test_subgroup_size_invalid_cpu: any subgroup_size raises ValueError - test_subgroup_size_vulkan: subgroup_size=32 produces correct IDs
|
|
||
|
|
||
| @test_utils.test(arch=qd.cuda) | ||
| def test_subgroup_size_valid_cuda(): |
There was a problem hiding this comment.
would be good to unify these tests
There was a problem hiding this comment.
thats not very unified. in one test, compare the max and min sub group sizes with expected values dependin on arch. in onother paraemterize over sub group sizes, and the
expected result depends on arch, for each size
Single test runs on all three backends: - CPU: rejects any subgroup_size (ValueError) - CUDA: accepts 32, rejects 16 (ValueError) - Vulkan: accepts 32, produces correct invocation IDs
test_subgroup_size_range: verify min/max subgroup size per GPU backend
- CUDA: 32/32, Vulkan: 8..128, Metal: 32/32, AMDGPU: >=32
test_subgroup_size_validation: parametrize over sg_size={8,16,32,64}
- CPU: all sizes raise ValueError
- CUDA: only 32 accepted, others raise ValueError
- Vulkan: sizes within device range accepted, others skipped
|
|
||
|
|
||
| @pytest.mark.parametrize("sg_size", [8, 16, 32, 64]) | ||
| @test_utils.test(arch=[qd.cpu, qd.cuda, qd.vulkan]) |
| elif arch == qd.vulkan: | ||
| min_sg = qd.simt.min_subgroup_size() | ||
| max_sg = qd.simt.max_subgroup_size() | ||
| if not (min_sg <= sg_size <= max_sg): |
There was a problem hiding this comment.
assert that min size <= max size
…ze-fix # Conflicts: # quadrants/rhi/vulkan/vulkan_device.cpp # quadrants/rhi/vulkan/vulkan_device.h # quadrants/rhi/vulkan/vulkan_device_creator.cpp # quadrants/runtime/gfx/runtime.cpp # quadrants/transforms/lower_ast.cpp # quadrants/transforms/offload.cpp # tests/python/test_simt.py
Document qd.loop_config(subgroup_size=N), qd.simt.min/max_subgroup_size(), and per-backend validation rules.
The default of 32 only applies when VK_EXT_subgroup_size_control is present. Note the assumption that all common GPUs support 32.
|
questoins to Opus: to what extend do the tests match what is described in the doc?Opus: tests should run on all hardware.Opus: all hardware, not just gpu, bu also cpu. The doc says it works also on cpu right?Opus: yes pleaseOpus: |
Runs on all GPU backends. Sets subgroup_size=32 and checks that invocation_id() produces all values in 0..31.
|
questions to Opus: to what extend do the tests match what is described in the doc?Opus: YesOpus: Committed and pushed as 29b20d2. The new test_subgroup_size_invocation_ids test runs on all 4 GPU backends (CUDA, Vulkan, Metal, AMDGPU), sets subgroup_size=32, and verifies |
address
|
what are the implications of "subgroup_size not in QD_STMT_DEF_FIELDS for loop statements"?yes |
RangeForStmt, StructForStmt, and MeshForStmt clone() methods were not copying subgroup_size, so cloned loops (e.g. during autodiff) would silently revert to the default.
thoughst on 'Default return value 32 in Device::get_min_subgroup_size() / get_max_subgroup_size(): The base class Device in public_device.h'? |
thoughts on 'VulkanLoader::check_vulkan_device() calls vkDestroyInstance:'? |
- Default subgroup_size=32 on Vulkan (no explicit setting) - StructForStmt (field-based struct-for loop) - Adaptive block_dim (block_dim not explicitly set) - Second-call cache round-trip (kernel reuse) - IR dump verification (subgroup_size=32 appears in offloaded IR) - CUDA graph compatibility (graph=True)
|
Request to opus: Add tests for: subgroup_size with StructForStmt (struct for loops over fields) Opus response: |
Instead of a separate test_subgroup_size_invocation_ids, the validation test now verifies invocation_id() correctness for all accepted subgroup sizes, parametrized across backends.
address test_subgroup_size_invocation_ids could be merged into test_subgroup_size_validation for the "accepted" cases — instead of just calling k() (which runs a no-op kernel), it could read and verify invocation IDs. |
Thoughts on '• Inconsistent Vulkan validation: On CUDA/Metal/AMDGPU, invalid sizes get a clean Python ValueError. On Vulkan, out-of-range values "cause a driver error" (per the docs). Butthe code has min_subgroup_size and max_subgroup_size available — it could easily validate in Python too, for a consistent experience.'? does the fix ahave any tradeoffs?lets implement this |
b6dc662 to
406ff44
Compare
There was a problem hiding this comment.
💡 Codex Review
The new min/max subgroup-size fields are only populated inside the vk_api_version >= 1.1 branch. If initialization falls back to Vulkan 1.0 (which this file already supports), these values remain zero, so qd.simt.min_subgroup_size()/max_subgroup_size() report [0, 0] and Vulkan validation rejects all positive subgroup_size values. A fallback assignment is needed for the < 1.1 path.
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| int32 end_value{0}; | ||
| int grid_dim{1}; | ||
| int block_dim{1}; | ||
| int subgroup_size{0}; |
There was a problem hiding this comment.
Preserve subgroup_size in OffloadedStmt clones
Adding subgroup_size to OffloadedStmt without updating OffloadedStmt::clone() drops the configured value whenever an offloaded IR node is cloned (the clone code still copies block_dim/loop_name but not this new field). In those clone paths, a loop configured with qd.loop_config(subgroup_size=...) silently reverts to subgroup_size=0, so Vulkan pipeline creation falls back to the default subgroup instead of the user-requested one.
Useful? React with 👍 / 👎.
| // NVIDIA driver bug, but this ensures correctness on drivers that | ||
| // support variable subgroup sizes (e.g. Intel Xe: 8/16/32). | ||
| if (code_view.stage == VK_SHADER_STAGE_COMPUTE_BIT && ti_device_.vk_caps().subgroup_size_control) { | ||
| uint32_t sg = params.subgroup_size > 0 ? params.subgroup_size : 32; |
There was a problem hiding this comment.
Validate default Vulkan subgroup size before pinning
When subgroup size control is enabled, compute stages unconditionally pin requiredSubgroupSize to 32 if no explicit value is provided. On Vulkan devices whose supported subgroup-size range does not include 32, this makes normal kernels fail at pipeline creation even though the user did not opt into a custom subgroup size. The default should be chosen from the device-reported supported range (or not pinned when 32 is unsupported).
Useful? React with 👍 / 👎.
| bool strictly_serialized{false}; | ||
| MemoryAccessOptions mem_access_opt; | ||
| int block_dim{0}; | ||
| int subgroup_size{0}; | ||
| bool uniform{false}; | ||
| std::string loop_name{""}; | ||
| }; |
There was a problem hiding this comment.
🔴 The PR adds 'int subgroup_size{0}' to ForLoopConfig but omits 'config.subgroup_size = 0' from ForLoopDecoratorRecorder::reset() (frontend_ir.h:885-893). In a kernel with two consecutive loops, if the first uses loop_config(subgroup_size=32), the second loop silently inherits subgroup_size=32 even if the user never called loop_config() for it, causing incorrect shader compilation on the second loop.
Extended reasoning...
What the bug is: ForLoopDecoratorRecorder::reset() in frontend_ir.h lines 885-893 explicitly resets seven fields of ForLoopConfig: is_bit_vectorized, num_cpu_threads, uniform, mem_access_opt, block_dim, strictly_serialized, and loop_name. The PR adds an eighth field, 'int subgroup_size{0}', to ForLoopConfig but the corresponding reset line 'config.subgroup_size = 0;' is absent from reset().
The code path that triggers it: After each for-loop is emitted, ASTBuilder::reset_snode_access_flag() (frontend_ir.h:1028) is called, which calls for_loop_dec_.reset(). This is the mechanism that ensures loop configuration directives are one-shot — they apply to the immediately following loop and no others. Because subgroup_size is not zeroed in reset(), it persists across that boundary.
Why existing code does not prevent it: The default value of 'int subgroup_size{0}' only protects the very first loop in a kernel (via struct-initialization). For any subsequent loop in the same kernel, reset() is the sole clearing mechanism, and it is broken for this field.
Impact: This is a silent data corruption bug. A kernel author writing two consecutive loops where only the first specifies loop_config(subgroup_size=32) will have the second loop silently compiled with requiredSubgroupSize=32 in the Vulkan pipeline. On hardware where the driver would otherwise choose a different subgroup size (e.g., Intel Xe with 8/16/32), this forces an unintended size, potentially corrupting results of SIMT operations.
How to fix it: Add 'config.subgroup_size = 0;' inside ForLoopDecoratorRecorder::reset() alongside the other field resets at frontend_ir.h:885-893.
Step-by-step proof:
- User calls qd.loop_config(subgroup_size=32) -> ASTBuilder::subgroup_size(32) -> for_loop_dec_.config.subgroup_size = 32
- The first for-loop is emitted; FrontendForStmt::init_config() copies config.subgroup_size=32 into the stmt
- reset_snode_access_flag() is called -> for_loop_dec_.reset() runs but does NOT clear config.subgroup_size; it remains 32
- The second for-loop is emitted with no prior loop_config(subgroup_size=...) call; init_config() copies the stale value 32 into the second stmt
- This propagates through lower_ast.cpp -> offload.cpp -> spirv_codegen.cpp -> TaskAttributes::subgroup_size -> PipelineSourceDesc::subgroup_size -> VkPipelineShaderStageRequiredSubgroupSizeCreateInfo::requiredSubgroupSize=32 on the second pipeline, even though the user never intended it.
| block_dim, strictly_serialized); | ||
| new_stmt->reversed = reversed; | ||
| new_stmt->loop_name = loop_name; | ||
| new_stmt->subgroup_size = subgroup_size; | ||
| return new_stmt; | ||
| } | ||
|
|
There was a problem hiding this comment.
🔴 OffloadedStmt::clone() does not copy subgroup_size, so any IR transform that clones an OffloadedStmt will silently revert the user-specified subgroup size to 0 (the default). The PR correctly adds the copy in RangeForStmt::clone(), StructForStmt::clone(), and MeshForStmt::clone(), but the same line is missing from OffloadedStmt::clone().
Extended reasoning...
The PR adds new_stmt->subgroup_size = subgroup_size to three loop statement clone() methods — RangeForStmt (statements.cpp:226), StructForStmt (statements.cpp:248), and MeshForStmt (statements.cpp:274) — but omits the equivalent line from OffloadedStmt::clone() (statements.cpp:355-410).
Reading OffloadedStmt::clone() directly confirms the omission: the method explicitly copies block_dim (line 365), loop_name (line 408), mem_access_opt (line 407), and many other fields, but there is no assignment of subgroup_size anywhere in the function. The method returns at line 409 with subgroup_size left at its default-constructed value of 0.
The field is unambiguously first-class on OffloadedStmt: statements.h adds int subgroup_size{0} to the struct alongside block_dim and includes it in QD_STMT_DEF_FIELDS. The offload pass (offload.cpp) correctly sets it when building OffloadedStmts from the three loop statement types, and spirv_codegen.cpp reads it to populate TaskAttributes::subgroup_size, which flows to the Vulkan requiredSubgroupSize. The clone() gap breaks that chain.
The practical trigger is any code path that calls OffloadedStmt::clone(). Block::clone() in ir.cpp clones every statement in a block, so any transform that clones a root kernel block after offloading will silently drop subgroup_size. Autodiff passes and kernel duplication utilities are concrete examples.
Step-by-step proof: (1) User writes qd.loop_config(subgroup_size=16). (2) Python-side validation passes (16 is in range). (3) lower_ast.cpp creates a RangeForStmt with subgroup_size=16. (4) offload.cpp creates an OffloadedStmt and sets offloaded->subgroup_size = s->subgroup_size = 16. (5) An IR pass calls OffloadedStmt::clone(). (6) clone() copies block_dim and loop_name but skips subgroup_size, so new_stmt->subgroup_size == 0. (7) spirv_codegen.cpp reads stmt->subgroup_size == 0 into TaskAttributes. (8) runtime.cpp copies 0 into PipelineSourceDesc::subgroup_size. (9) vulkan_device.cpp applies the fallback: sg = params.subgroup_size > 0 ? params.subgroup_size : 32, silently using 32 instead of 16.
The fix is a one-line addition in OffloadedStmt::clone(): new_stmt->subgroup_size = subgroup_size after the block_dim copy at line 365, mirroring exactly what the PR already did for all three frontend loop statement types. This is internally inconsistent — the PR description even states the clone() methods were all updated, but OffloadedStmt was missed.
|
|
||
| read_ids(out) | ||
| second_ids = out.to_numpy() | ||
| assert set(second_ids) == set(range(N)) | ||
| np.testing.assert_array_equal(first_ids, second_ids) | ||
|
|
||
|
|
||
| @test_utils.test(offline_cache=False) | ||
| def test_subgroup_size_in_ir_dump(tmp_path, monkeypatch): | ||
| """subgroup_size should appear in IR dumps when set.""" | ||
| monkeypatch.setenv("QD_DUMP_IR", "1") | ||
| qd.lang.impl.current_cfg().debug_dump_path = str(tmp_path) | ||
|
|
||
| N = 32 | ||
| out = qd.ndarray(dtype=qd.i32, shape=(N,)) | ||
|
|
||
| @qd.kernel | ||
| def k(result: qd.types.ndarray(dtype=qd.i32, ndim=1)): | ||
| qd.loop_config(block_dim=N, subgroup_size=32) | ||
| for i in range(N): | ||
| result[i] = i | ||
|
|
||
| k(out) | ||
| qd.sync() |
There was a problem hiding this comment.
🔴 test_subgroup_size_in_ir_dump is decorated with @test_utils.test(offline_cache=False) without an arch= restriction, so it runs on every backend including CPU. On CPU, qd.loop_config(block_dim=N, subgroup_size=32) raises ValueError, causing the test to fail rather than be skipped. Fix: add arch=[qd.vulkan, qd.cuda] (or similar GPU-only list) to the decorator, matching every other subgroup_size test in this file.
Extended reasoning...
The test at tests/python/test_simt.py:850 uses @test_utils.test(offline_cache=False) with no arch= argument. In test_utils.py, when arch is not specified the decorator falls back to the full set of archs returned by expected_archs(), which includes cpu. This means the test is scheduled to run on CPU backends alongside GPU backends.
The test body calls qd.loop_config(block_dim=N, subgroup_size=32). In python/quadrants/lang/misc.py the loop_config() function contains an explicit guard for CPU architectures that raises ValueError('subgroup_size is not supported on CPU backends'). This guard executes before the kernel is compiled, so on a CPU backend the test raises ValueError rather than skipping. This is a test failure, not a graceful skip.
Every other subgroup_size test added in this PR correctly restricts to GPU architectures. For example: test_subgroup_size_range uses arch=qd.gpu, test_subgroup_size_validation uses arch=[qd.cpu, qd.cuda, qd.vulkan, qd.metal, qd.amdgpu] (and intentionally handles the CPU raise-case), test_subgroup_size_with_struct_for uses arch=[qd.cuda, qd.vulkan, qd.metal, qd.amdgpu], and test_subgroup_size_with_adaptive_block_dim, test_subgroup_size_survives_second_call, and test_subgroup_size_with_graph all have explicit GPU arch lists. Only test_subgroup_size_in_ir_dump is missing the restriction.
The purpose of test_subgroup_size_in_ir_dump is to verify that the IR printer emits subgroup_size=32 in the offload dump. The IR printer changes are GPU-path-specific — there is nothing meaningful to verify for this test on CPU.
Concrete failure path: (1) test runner selects arch=cpu. (2) qd.init(arch=cpu) succeeds. (3) The kernel k is defined. (4) k(out) is called; the Python-side loop_config() runs immediately and raises ValueError. (5) pytest marks the test as FAILED.
The fix is to add arch=[qd.vulkan, qd.cuda] to the decorator: @test_utils.test(arch=[qd.vulkan, qd.cuda], offline_cache=False). The subgroup_size IR field is only populated via the SPIR-V path, so restricting to Vulkan and/or CUDA is appropriate.
| // Defense-in-depth: the primary fix (VkInstance reuse) prevents the | ||
| // NVIDIA driver bug, but this ensures correctness on drivers that | ||
| // support variable subgroup sizes (e.g. Intel Xe: 8/16/32). | ||
| if (code_view.stage == VK_SHADER_STAGE_COMPUTE_BIT && ti_device_.vk_caps().subgroup_size_control) { | ||
| uint32_t sg = params.subgroup_size > 0 ? params.subgroup_size : 32; | ||
| subgroup_size_info_.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_REQUIRED_SUBGROUP_SIZE_CREATE_INFO; | ||
| subgroup_size_info_.pNext = nullptr; | ||
| subgroup_size_info_.requiredSubgroupSize = sg; | ||
| shader_stage_info.pNext = &subgroup_size_info_; | ||
| } | ||
|
|
||
| shader_modules_.push_back(shader_module); | ||
| } | ||
| } |
There was a problem hiding this comment.
🔴 The fallback subgroup size of 32 in VulkanPipeline::create_shader_stages() is hardcoded and will cause vkCreateComputePipelines to fail on any Vulkan device where 32 is outside [minSubgroupSize, maxSubgroupSize] (e.g. AMD GCN/Vega in wavefront64 mode, where minSubgroupSize=64). When no subgroup_size is explicitly set by the user, the VkPipelineShaderStageRequiredSubgroupSizeCreateInfo pNext struct should either be omitted entirely (letting the driver choose its native size) or the fallback should use ti_device_.vk_caps().max_subgroup_size, which is already queried and stored.
Extended reasoning...
What the bug is and how it manifests
In vulkan_device.cpp (lines 370-383), VulkanPipeline::create_shader_stages() attaches a VkPipelineShaderStageRequiredSubgroupSizeCreateInfo struct to every compute shader stage when VK_EXT_subgroup_size_control is available. The struct's requiredSubgroupSize is computed as:
uint32_t sg = params.subgroup_size > 0 ? params.subgroup_size : 32;
When the user does not specify a subgroup size (params.subgroup_size == 0), the fallback is the hardcoded literal 32.
The specific code path that triggers it
This path executes whenever ti_device_.vk_caps().subgroup_size_control is true — i.e., whenever the device advertises and successfully enables VK_EXT_SUBGROUP_SIZE_CONTROL_EXTENSION_NAME. That means every single Vulkan compute pipeline on a qualifying device will unconditionally attempt to pin the subgroup size to 32, regardless of what the hardware actually supports.
Why existing code does not prevent it
The device's actual supported range (vk_caps_.min_subgroup_size / vk_caps_.max_subgroup_size) is queried from VkPhysicalDeviceSubgroupSizeControlProperties during device creation and stored in VulkanCapabilities. However, this information is never consulted when computing the fallback value at pipeline creation time. The fix is right next to the bug.
What the impact would be
The Vulkan spec (VUID-VkPipelineShaderStageRequiredSubgroupSizeCreateInfo-requiredSubgroupSize-02760) requires that requiredSubgroupSize be a power-of-two value in [minSubgroupSize, maxSubgroupSize] inclusive. On hardware where 32 is not in that range — AMD GCN/Vega GPUs in wavefront64-only mode (minSubgroupSize=64) or embedded/mobile Vulkan GPUs (maxSubgroupSize<32) — vkCreateComputePipelines returns VK_ERROR_INITIALIZATION_FAILED, causing every kernel pipeline creation to fail, including kernels that never call qd.loop_config(subgroup_size=N). The PR description acknowledges this as a known bad point.
How to fix it
There are two safe options: (1) Skip the pNext chain entirely when params.subgroup_size == 0, letting the driver use its native subgroup size. (2) Replace 32 with ti_device_.vk_caps().max_subgroup_size as the fallback, which is guaranteed to be in range. Option 1 is preferable because it avoids imposing any constraint when the user has not requested one.
Step-by-step proof
- User initializes Quadrants with qd.init(arch=qd.vulkan) on an AMD Vega GPU in wavefront64 mode.
- During device creation, VK_EXT_SUBGROUP_SIZE_CONTROL_EXTENSION_NAME is found: vk_caps().subgroup_size_control=true, min_subgroup_size=64, max_subgroup_size=64 are stored.
- User writes a kernel with a top-level for loop and does NOT call qd.loop_config(subgroup_size=N).
- At pipeline creation, create_shader_stages() enters the subgroup_size_control branch. params.subgroup_size==0, so sg=32.
- VkPipelineShaderStageRequiredSubgroupSizeCreateInfo.requiredSubgroupSize=32 is chained into VkPipelineShaderStageCreateInfo.pNext.
- vkCreateComputePipelines sees requiredSubgroupSize=32 < minSubgroupSize=64 — a spec violation. Pipeline creation fails for every kernel on this device.
There was a problem hiding this comment.
this seems theoretical for now
The amdgpu backend (LLVM codegen) does not implement subgroup size control -- it always runs at the hardware default (wave32 on RDNA). Requesting subgroup_size=64 was silently ignored, producing wrong invocation IDs. Reject it at validation time instead.
Runs on all backends, no need to enumerate them.
Without this, any IR transform that clones an OffloadedStmt silently reverts the user-specified subgroup size to 0. The same copy was already present in RangeForStmt, StructForStmt, and MeshForStmt.
Without this, a second loop in the same kernel silently inherits the subgroup_size from a preceding loop_config() call.
1496673 to
9be1daa
Compare
|
Closing this for now. If you would like to reopen it, see discussion at #479 |
Issue: #
Brief Summary
What's in the PR
This PR adds Vulkan subgroup size control to Quadrants. Three things:
silently picking a different size.
Program → Device::get_min/max_subgroup_size(), with VulkanDevice overriding to return the values from VkPhysicalDeviceSubgroupSizeControlProperties. Non-Vulkan backends
return 32/32.
Metal: 32 only, AMDGPU: 32 or 64, CPU: error, Vulkan: passed through). The value is threaded through the full IR pipeline down to the Vulkan requiredSubgroupSize.
The implementation threads subgroup_size through the full pipeline: Python → AST → frontend IR → lowered IR (RangeFor/StructFor/MeshFor) → OffloadedStmt → SPIRV TaskAttributes →
PipelineSourceDesc → Vulkan VkPipelineShaderStageRequiredSubgroupSizeCreateInfo.
Good points
• Clean, consistent plumbing: The subgroup_size field is added to every loop statement type (range-for, struct-for, mesh-for, offloaded), their clone() methods, and the IR
printer — nothing is missed. It follows the exact same pattern as block_dim and loop_name.
• Early Python-side validation: CUDA/Metal/AMDGPU/CPU get clear ValueError messages with supported values listed, rather than cryptic driver errors.
• Thorough test coverage: 10 new tests covering validation across all backends, struct-for propagation, adaptive block_dim, cache serialization round-trip, IR dump
verification, and CUDA graph compatibility.
• Defense-in-depth rationale is sound: Even with VkInstance reuse fixing the original NVIDIA bug, pinning the subgroup size is the correct Vulkan practice for any code that
depends on a specific subgroup width.
• Good docs: The user guide page has clear backend tables, code examples, and explains when/why you'd use this.
• Non-invasive: No existing behavior changes — the default is 32 which matches NVIDIA/AMD/Apple hardware, so existing code is unaffected.
Bad points
• Messy commit history: 27 commits including 3 merges and many iterative fixups ("fix: satisfy both ruff and pylint", "style: collapse unnecessary multi-line assignments",
"fix: linter failures and Mac Vulkan test assertion"). Should be squashed before merge.
• Inconsistent Vulkan validation: On CUDA/Metal/AMDGPU, invalid sizes get a clean Python ValueError. On Vulkan, out-of-range values "cause a driver error" (per the docs). But
the code has min_subgroup_size and max_subgroup_size available — it could easily validate in Python too, for a consistent experience.
• Hardcoded default of 32 can fail silently: If a device's minSubgroupSize > 32 (unusual but spec-legal), the default requiredSubgroupSize=32 will cause a Vulkan pipeline
creation failure. The docs say "you must set an explicit subgroup size" but the code doesn't detect or warn about this.
•
features2.pNextclobbering in device creator: The code temporarily sets features2.pNext = &sg_size_feature to query the feature, which overwrites the existing pNext chain.This works because it's a read-only query, but it's fragile and doesn't match the pattern used for other features in the same function (which chain via pNextEnd).
• No Vulkan range validation in Python despite having the data: The loop_config code validates CUDA/Metal/AMDGPU but just passes Vulkan values through unchecked — even though
min_subgroup_size() / max_subgroup_size() are available at that point.
• CUDA path is effectively a no-op: The subgroup_size field is threaded through SPIRV codegen but there's no CUDA codegen path for it. On CUDA, validation only allows 32 (the
hardware default), so the field never does anything. This is technically correct but the IR plumbing gives the impression it's used everywhere.
copilot:summary
Walkthrough
copilot:walkthrough