Repository navigation
Conversation
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
8260374 to
c6f937c
Compare
|
/run bots |
|
The |
5d0db8c to
3702f0f
Compare
|
Jie Chen (@jchen10) I had also fixed out-of-bound issue, you could start to review the PR, thanks. |
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
🟡 Changes recommended
Segmented-weight loads and FP16 partial-sum overflow can produce incorrect convolution results.
2 open findings
What changed in this PR
Adds subgroup-matrix acceleration for eligible FP16, channels-last convolutions in the WebGPU execution provider, retaining existing fallback paths.
Changes:
- Adds an implicit-GEMM shader with bias and fused activations.
- Integrates capability checks, weight caching, and Intel-specific tiling.
- Adds convolution correctness tests across shapes and activations.
| File | Description |
|---|---|
| onnxruntime/test/providers/webgpu/subgroup_matrix_conv_test.cc | Adds convolution correctness coverage. |
| onnxruntime/core/providers/webgpu/vendor/intel/math/subgroup_matrix_tiling_selector.h | Declares Conv selector and shared scratch budget. |
| onnxruntime/core/providers/webgpu/vendor/intel/math/subgroup_matrix_tiling_selector.cc | Adds Conv-specific tiling and safety clamps. |
| onnxruntime/core/providers/webgpu/nn/subgroup_matrix_conv.h | Declares optimized Conv interfaces. |
| onnxruntime/core/providers/webgpu/nn/subgroup_matrix_conv.cc | Implements eligibility, weight caching, and dispatch. |
| onnxruntime/core/providers/webgpu/nn/subgroup_matrix_conv_8x16x16.wgsl.template | Implements subgroup-matrix convolution. |
| onnxruntime/core/providers/webgpu/nn/conv.h | Adds optimized implementation state. |
| onnxruntime/core/providers/webgpu/nn/conv.cc | Integrates optimized dispatch and prepacking decisions. |
🧠 Review effort: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| return false; | ||
| } | ||
|
|
||
| const std::optional<SubgroupMatrixConfig> config = GetSupportedConfig(context); |
| let a_scratch_base = sg_index * kTileM * kSgMatK; // this subgroup's A staging slot | ||
|
|
||
| // Accumulators: kSgMatCountM M blocks x kSgMatCountN N blocks (sg_mat_c<mi>_<ni>). | ||
| var sg_mat_c0_0: subgroup_matrix_result<f16, sg_mat_n, sg_mat_m>; |
|
There were concerns regarding the Tuned Tree on the special devices, so I had removed the Conv Tuned Tree, now the PR only has Heuristic Tiling function. If there is a better way, we could add the Conv Tuned Tree in another PR.
|
| let channel_i_vec = uniforms.channel_i / kVecSize; | ||
| let oh = m / uniforms.output_w; | ||
| let ow = m % uniforms.output_w; | ||
| let cin_vec = k_vec % channel_i_vec; |
There was a problem hiding this comment.
Use cin or channel_i naming consistently.
There was a problem hiding this comment.
Do you mean the same name as uniforms.channel_i? But channel_i_vec is related with kVecSize in line 99.
There was a problem hiding this comment.
How do you like this way:
let cin_vec = uniforms.cin / kVecSize;
let oh = m / uniforms.output_w;
let ow = m % uniforms.output_w;
let k_cin = k_vec % cin_vec;
let k_hw = k_vec / cin_vec;
| if (ih < 0 || ih >= i32(uniforms.src_h) || iw < 0 || iw >= i32(uniforms.src_w)) { | ||
| return input_src_value_t(); | ||
| } | ||
| let src_vec_idx = src_vec_batch_offset + | ||
| (u32(ih) * uniforms.src_w + u32(iw)) * channel_i_vec + cin_vec; |
There was a problem hiding this comment.
Similarly, use input_w/h and output_w/h, or src_w/h and dst_w/h. Don't mix them, like src_w, output_w.
| let k_vec_base = kb * kSgMatK / kVecSize; | ||
| let k_vec_per_row = kSgMatK / kVecSize; |
There was a problem hiding this comment.
let k_vec_per_sgmat_row = kSgMatK / kVecSize;
let k_vec_base = kb * k_vec_per_sgmat_row;
| return; | ||
| } | ||
| let slice_idx = workgroup_idx % tiles_per_slice; // tile index within the slice | ||
| let n_tile = slice_idx % uniforms.num_n_tile; // which kTileN-wide column tile |
There was a problem hiding this comment.
tiles_per_slice is actually tiles_per_batch, right? So, slice_idx should be tile_idx. It seems there is no need to introduce a distracting concept of slice.
| let src_vec_batch_offset = batch_id * uniforms.src_h * uniforms.src_w * (uniforms.channel_i / kVecSize); | ||
| let out_batch_offset = batch_id * uniforms.M * uniforms.N; |
There was a problem hiding this comment.
base, start, and offset are used for naming a bit too casually. we'd better cleanup a little bit. Personally I am in favor in_batch_base_vec over src_vec_batch_offset, and out_batch_base over out_batch_offset.
| if (parent.PrepackedKernel() != nullptr) { | ||
| return Status::OK(); | ||
| } |
There was a problem hiding this comment.
Use ORT_ENFORCE instead?
There was a problem hiding this comment.
Cannot use ORT_ENFORCE here, I update the code comments
| // Safety net: the Conv selector already enforces the Conv scratch budget | ||
| // (output tiles + im2col A staging), so this clamp normally does nothing; it | ||
| // guards against a stray oversized split-K from the selector. Only split_k is | ||
| // clamped, not the tile area: ConvHeuristicTiling always picks the smallest | ||
| // tile_m (8), so the largest candidate tile needs 8 * (64 + 16) = 640 elements | ||
| // at split_k 1, or 8 * 8 * 80 = 5120 at the largest split_k (8), well inside | ||
| // the budget. | ||
| uint32_t split_k = tiling->split_k; | ||
| while (split_k > 1 && | ||
| split_k * tile_m * (tile_n + config.K) > intel::kMaxScratchElems) { | ||
| split_k /= 2; | ||
| } |
There was a problem hiding this comment.
As the comment says, this logic is unnecessary. Simply remove it or add an ORT_ENFORCE.
| // Combined workgroup-memory (SLM) budget in f16 elements for the two scratch | ||
| // buffers the conv kernel uses: the split-K output tiles | ||
| // (split_k * tile_m * tile_n) plus the per-subgroup im2col A staging | ||
| // (split_k * tile_m * sg_mat_k). split_k is clamped to fit this. Shares | ||
| // intel::kMaxScratchElems rather than hardcoding a second copy of the same | ||
| // budget the selector already sizes tilings against. |
There was a problem hiding this comment.
The comment seems irrelevant to the code lines nearby.
| // Largest per-tile subgroup-matrix counts the WGSL template unrolls: it generates the | ||
| // accumulators sg_mat_c<mi>_<ni> for mi < 8 and ni < 4. A tiling above these would be | ||
| // accepted by the selector but silently mis-computed -- the kernel would accumulate | ||
| // only the blocks it generated while the write-out still covers the whole tile. The | ||
| // tile candidates the selector picks from stay inside this, so this is enforced as a | ||
| // safety net rather than something expected to fire. | ||
| constexpr uint32_t kMaxSgMatCountM = 8; | ||
| constexpr uint32_t kMaxSgMatCountN = 4; |
There was a problem hiding this comment.
The selector should ensure this, and we don't need to bother to check it again here?
There was a problem hiding this comment.
If a future change to the tiling selector ever violated this, the WGSL template would silently under-accumulate (it only unrolls ni < 4 accumulators) instead of crashing — a wrong answer with no visible error.
There was a problem hiding this comment.
The guard itself somewhat makes sense, but should we check it here? The values are specific to the 8x16x16 WGSL template. Perhaps we'd better put it somewhere template specific. Not mandatory in this PR. We can do it later.
xhcao
left a comment
There was a problem hiding this comment.
Jie, please take a look again, thanks
| if (parent.PrepackedKernel() != nullptr) { | ||
| return Status::OK(); | ||
| } |
There was a problem hiding this comment.
Cannot use ORT_ENFORCE here, I update the code comments
| // Largest per-tile subgroup-matrix counts the WGSL template unrolls: it generates the | ||
| // accumulators sg_mat_c<mi>_<ni> for mi < 8 and ni < 4. A tiling above these would be | ||
| // accepted by the selector but silently mis-computed -- the kernel would accumulate | ||
| // only the blocks it generated while the write-out still covers the whole tile. The | ||
| // tile candidates the selector picks from stay inside this, so this is enforced as a | ||
| // safety net rather than something expected to fire. | ||
| constexpr uint32_t kMaxSgMatCountM = 8; | ||
| constexpr uint32_t kMaxSgMatCountN = 4; |
There was a problem hiding this comment.
If a future change to the tiling selector ever violated this, the WGSL template would silently under-accumulate (it only unrolls ni < 4 accumulators) instead of crashing — a wrong answer with no visible error.
| // Safety net: the Conv selector already enforces the Conv scratch budget | ||
| // (output tiles + im2col A staging), so this clamp normally does nothing; it | ||
| // guards against a stray oversized split-K from the selector. Only split_k is | ||
| // clamped, not the tile area: ConvHeuristicTiling always picks the smallest | ||
| // tile_m (8), so the largest candidate tile needs 8 * (64 + 16) = 640 elements | ||
| // at split_k 1, or 8 * 8 * 80 = 5120 at the largest split_k (8), well inside | ||
| // the budget. | ||
| uint32_t split_k = tiling->split_k; | ||
| while (split_k > 1 && | ||
| split_k * tile_m * (tile_n + config.K) > intel::kMaxScratchElems) { | ||
| split_k /= 2; | ||
| } |
| // Combined workgroup-memory (SLM) budget in f16 elements for the two scratch | ||
| // buffers the conv kernel uses: the split-K output tiles | ||
| // (split_k * tile_m * tile_n) plus the per-subgroup im2col A staging | ||
| // (split_k * tile_m * sg_mat_k). split_k is clamped to fit this. Shares | ||
| // intel::kMaxScratchElems rather than hardcoding a second copy of the same | ||
| // budget the selector already sizes tilings against. |
| let channel_i_vec = uniforms.channel_i / kVecSize; | ||
| let oh = m / uniforms.output_w; | ||
| let ow = m % uniforms.output_w; | ||
| let cin_vec = k_vec % channel_i_vec; |
There was a problem hiding this comment.
Do you mean the same name as uniforms.channel_i? But channel_i_vec is related with kVecSize in line 99.
| if (ih < 0 || ih >= i32(uniforms.src_h) || iw < 0 || iw >= i32(uniforms.src_w)) { | ||
| return input_src_value_t(); | ||
| } | ||
| let src_vec_idx = src_vec_batch_offset + | ||
| (u32(ih) * uniforms.src_w + u32(iw)) * channel_i_vec + cin_vec; |
| return; | ||
| } | ||
| let slice_idx = workgroup_idx % tiles_per_slice; // tile index within the slice | ||
| let n_tile = slice_idx % uniforms.num_n_tile; // which kTileN-wide column tile |
| let src_vec_batch_offset = batch_id * uniforms.src_h * uniforms.src_w * (uniforms.channel_i / kVecSize); | ||
| let out_batch_offset = batch_id * uniforms.M * uniforms.N; |
| let k_vec_base = kb * kSgMatK / kVecSize; | ||
| let k_vec_per_row = kSgMatK / kVecSize; |
| constexpr uint32_t kMatMulScratchNPad = 0; | ||
| constexpr uint32_t kConvScratchNPad = kSubgroupMatrixK; |
There was a problem hiding this comment.
ScratchNPad doesn't look like a straightforward and informative name. May ask AI to suggest a better one.
| // This tile fills the machine on its own. Among all such tiles, keep the | ||
| // largest-area one (best operand reuse); this also supersedes any | ||
| // under-filling pick made earlier. | ||
| if (!filled || area > best_area) { | ||
| filled = true; | ||
| best_area = area; | ||
| tile_m = tm; | ||
| tile_n = tn; | ||
| } | ||
| } else if (!filled && (tiles > best_tiles || (tiles == best_tiles && area > best_area))) { | ||
| // No tile has filled the machine yet: chase occupancy by maximizing the | ||
| // number of independent tiles, breaking ties toward larger area. |
There was a problem hiding this comment.
Keep the original comments as the corresponding code logic is not easy to read.
| // Add split-K only while the independent tiles under-fill the machine; cap the | ||
| // total at 2x hardware and keep enough K work per subgroup and scratch budget. |
There was a problem hiding this comment.
Keep the comment as well
| // Halves split_k until it fits K's block count and the scratch budget. No-op today | ||
| // (ConvHeuristicTiling already guarantees both); a safety net for future callers. | ||
| void ClampSplitKToProblem(SubgroupMatrixTiling& t, uint32_t K, uint32_t scratch_n_pad) { | ||
| const uint32_t k_blocks = K / kSubgroupMatrixK; | ||
| while (t.split_k > 1 && (t.split_k > k_blocks || ScratchElems(t, scratch_n_pad) > kMaxScratchElems)) { | ||
| t.split_k /= 2; | ||
| } | ||
| } |
There was a problem hiding this comment.
Remove the unused logic to be less distracting.
| if (N < tiling->tile_n) { | ||
| return Status::OK(); | ||
| } |
There was a problem hiding this comment.
Use ORT_ENFORCE?
There was a problem hiding this comment.
No, decline means that subgroup matrix path cannot handle the current Conv node, let the others path to handle it. It does not need to throw an error here.
| // Largest per-tile subgroup-matrix counts the WGSL template unrolls: it generates the | ||
| // accumulators sg_mat_c<mi>_<ni> for mi < 8 and ni < 4. A tiling above these would be | ||
| // accepted by the selector but silently mis-computed -- the kernel would accumulate | ||
| // only the blocks it generated while the write-out still covers the whole tile. The | ||
| // tile candidates the selector picks from stay inside this, so this is enforced as a | ||
| // safety net rather than something expected to fire. | ||
| constexpr uint32_t kMaxSgMatCountM = 8; | ||
| constexpr uint32_t kMaxSgMatCountN = 4; |
There was a problem hiding this comment.
The guard itself somewhat makes sense, but should we check it here? The values are specific to the 8x16x16 WGSL template. Perhaps we'd better put it somewhere template specific. Not mandatory in this PR. We can do it later.
| let channel_i_vec = uniforms.channel_i / kVecSize; | ||
| let oh = m / uniforms.output_w; | ||
| let ow = m % uniforms.output_w; | ||
| let cin_vec = k_vec % channel_i_vec; |
There was a problem hiding this comment.
How do you like this way:
let cin_vec = uniforms.cin / kVecSize;
let oh = m / uniforms.output_w;
let ow = m % uniforms.output_w;
let k_cin = k_vec % cin_vec;
let k_hw = k_vec / cin_vec;
| if (N < tiling->tile_n) { | ||
| return Status::OK(); | ||
| } |
There was a problem hiding this comment.
No, decline means that subgroup matrix path cannot handle the current Conv node, let the others path to handle it. It does not need to throw an error here.
| let channel_i_vec = uniforms.channel_i / kVecSize; | ||
| let oh = m / uniforms.output_w; | ||
| let ow = m % uniforms.output_w; | ||
| let cin_vec = k_vec % channel_i_vec; |
| constexpr uint32_t kMatMulScratchNPad = 0; | ||
| constexpr uint32_t kConvScratchNPad = kSubgroupMatrixK; |
| // This tile fills the machine on its own. Among all such tiles, keep the | ||
| // largest-area one (best operand reuse); this also supersedes any | ||
| // under-filling pick made earlier. | ||
| if (!filled || area > best_area) { | ||
| filled = true; | ||
| best_area = area; | ||
| tile_m = tm; | ||
| tile_n = tn; | ||
| } | ||
| } else if (!filled && (tiles > best_tiles || (tiles == best_tiles && area > best_area))) { | ||
| // No tile has filled the machine yet: chase occupancy by maximizing the | ||
| // number of independent tiles, breaking ties toward larger area. |
| // Add split-K only while the independent tiles under-fill the machine; cap the | ||
| // total at 2x hardware and keep enough K work per subgroup and scratch budget. |
| // Halves split_k until it fits K's block count and the scratch budget. No-op today | ||
| // (ConvHeuristicTiling already guarantees both); a safety net for future callers. | ||
| void ClampSplitKToProblem(SubgroupMatrixTiling& t, uint32_t K, uint32_t scratch_n_pad) { | ||
| const uint32_t k_blocks = K / kSubgroupMatrixK; | ||
| while (t.split_k > 1 && (t.split_k > k_blocks || ScratchElems(t, scratch_n_pad) > kMaxScratchElems)) { | ||
| t.split_k /= 2; | ||
| } | ||
| } |
|
LGTM, thanks! Jiajia Qin (@qjia7) PTAL |
|
/run bots |
|
The request was received. Approve workflow runs. Thanks. |
|
/run bots |
|
The request was received. Approve workflow runs. Thanks. |
|
/run failed bots |
|
The request was received. Failed workflow jobs were queued for rerun. Thanks. |
| // trailing partial N tile back to N - tile_n, which needs tile_n <= N, and N | ||
| // itself need not be a multiple of kSubgroupMatrixN. Callers must have already | ||
| // rejected N < kSubgroupMatrixN. | ||
| void ClampTileNToProblem(SubgroupMatrixTiling& t, uint32_t N) { |
There was a problem hiding this comment.
How about renaming to CapTileNToAlignedN?
aligned_N = (N / subgroup_N) * subgroup_N;
tile_n = min(tile_n, aligned_N);
| auto transform_dim = [](int64_t dim) { return static_cast<int32_t>(dim); }; | ||
| std::transform(local_pads.begin(), local_pads.end(), std::back_inserter(pads), transform_dim); | ||
| std::transform(local_strides.begin(), local_strides.end(), std::back_inserter(strides), transform_dim); | ||
| std::transform(local_dilations.begin(), local_dilations.end(), std::back_inserter(dilations), transform_dim); |
There was a problem hiding this comment.
Could we extract lines 116–147 into a shared Conv geometry helper and reuse it in conv.cc? Both paths duplicate attribute defaulting and padding/output-shape inference. Keeping that logic shared would prevent differences in auto_pad handling. Please leave output allocation outside the helper so the optimized path can still decline safely.
Or Could we move the subgroup matrix Conv attempt after the common geometry calculation in conv.cc and pass in the resolved geometry? That would avoid duplicating shape/padding inference. We should keep the attempt before output allocation and path-specific transformations so fallback can reuse the same geometry safely.
There was a problem hiding this comment.
Done. The first option was adopted.
| // this layout) -- blocked on PrePackInternal committing from the weight alone | ||
| // while Compute can still decline at runtime onto the im2col path. | ||
| Tensor run_local_ohwi_weight; | ||
| const Tensor* ohwi_weight = nullptr; |
There was a problem hiding this comment.
Could we reuse the existing OHWI prepacking for constant weights instead of introducing a separate lazy transpose cache? Both paths consume the same layout. We would need to let subgroup Conv accept the packed weights and ensure runtime fallback remains valid after the original initializer is released. Non-constant weights would still need a per-run transpose.
There was a problem hiding this comment.
Done. Now SubgroupMatrixConv supported OHWI and HWIO layout, and removed the related code in original initializer.
| return false; | ||
| } | ||
|
|
||
| LOGS_DEFAULT(VERBOSE) << "SubgroupMatrixConv ACCEPTED: kernel_shape[OIHW]=" << kernel_shape.ToString() |
There was a problem hiding this comment.
Could we rename “ACCEPTED” to “initial eligibility checks passed”? Compute() can still decline afterward. Please put the actual dispatch message immediately before RunProgram() so it identifies the selected implementation reliably.
xhcao
left a comment
There was a problem hiding this comment.
Jiajia Qin (@qjia7) Please take a look, again. Thanks.
| // trailing partial N tile back to N - tile_n, which needs tile_n <= N, and N | ||
| // itself need not be a multiple of kSubgroupMatrixN. Callers must have already | ||
| // rejected N < kSubgroupMatrixN. | ||
| void ClampTileNToProblem(SubgroupMatrixTiling& t, uint32_t N) { |
| auto transform_dim = [](int64_t dim) { return static_cast<int32_t>(dim); }; | ||
| std::transform(local_pads.begin(), local_pads.end(), std::back_inserter(pads), transform_dim); | ||
| std::transform(local_strides.begin(), local_strides.end(), std::back_inserter(strides), transform_dim); | ||
| std::transform(local_dilations.begin(), local_dilations.end(), std::back_inserter(dilations), transform_dim); |
There was a problem hiding this comment.
Done. The first option was adopted.
| // this layout) -- blocked on PrePackInternal committing from the weight alone | ||
| // while Compute can still decline at runtime onto the im2col path. | ||
| Tensor run_local_ohwi_weight; | ||
| const Tensor* ohwi_weight = nullptr; |
There was a problem hiding this comment.
Done. Now SubgroupMatrixConv supported OHWI and HWIO layout, and removed the related code in original initializer.
| return false; | ||
| } | ||
|
|
||
| LOGS_DEFAULT(VERBOSE) << "SubgroupMatrixConv ACCEPTED: kernel_shape[OIHW]=" << kernel_shape.ToString() |
|
/run bots |
|
The request was received. Approve workflow runs. Thanks. |
| let b_base = kb * kSgMatK * uniforms.N + global_base_n; | ||
| var sg_mat_b0: subgroup_matrix_right<f16, sg_mat_n, sg_mat_k> = | ||
| subgroupMatrixLoad<subgroup_matrix_right<f16, sg_mat_n, sg_mat_k>, row_major>( | ||
| &input_b, b_base + 0 * kSgMatN, uniforms.N); |
There was a problem hiding this comment.
C2 [P1, new]: Guard odd-N HWIO weights before row-major subgroup-matrix loads
Location: onnxruntime/core/providers/webgpu/nn/subgroup_matrix_conv_8x16x16.wgsl.template:233-238; layout selection at subgroup_matrix_conv.cc:164-173.
The new shared-prepack route accepts HWIO weights and loads them as a row-major FP16 matrix with stride uniforms.N. On a supported Intel D3D12 adapter, take a constant-weight FusedConv with HardSwish, auto_pad=NOTSET, X NCHW [1,16,10,10], and W OIHW [17,16,3,3]. HardSwish is excluded from Im2ColMatMul, so PrePackInternal packs HWIO, while subgroup Conv still accepts K=144 and N=17. The new branch passes a 34-byte row stride; its shifted second N tile also starts at byte offset 2.
The DirectX matrix-load contract requires Wave-scope device-memory load strides and starting addresses to be at least 4-byte aligned. This is not just an old MatMul comment: the pinned Dawn v20261013.193955 storage-access lowering converts the FP16 offset/stride to bytes without padding, and its printer uses MatrixScope::Wave. The sibling MatMul explicitly pads odd FP16 N for this requirement.
Consequently, a valid model now reaches an unsupported/undefined matrix load, so numerical correctness is not guaranteed. At the previous reviewed head the same eligible Conv skipped prepacking and transposed to OHWI, whose K=144 column stride and tile offsets are aligned. This regression is introduced by the new HWIO path, not by sharing OHWI itself.
Please decline odd-N HWIO weights before output allocation, preserving the existing HWIO fallback, or provide an aligned/staged weight representation and matching tile addressing. Keep odd-N OHWI support. Add a path-verified HardSwish/odd-Cout constant-weight regression; the new HWIO test uses only Cout=32. The alignment violation is established by code and contract, not a claimed GPU reproduction.

Description
Adds a subgroup-matrix Conv path for the WebGPU EP that computes the convolution as
an implicit GEMM. Covers F16, channels-last,
group == 1convs on devices reportingan 8x16x16 subgroup-matrix config, and falls back to the existing Conv paths
otherwise.
Motivation and Context
Conv dominates runtime in vision models, but the existing WebGPU Conv paths do not
use the subgroup-matrix units that the MatMul path already exploits on Intel
Xe2/Xe3. This brings the same acceleration to Conv.