馃悰 Describe the bug
conv1d.glsl runs at roughly 3 percent of the throughput the same GPU reaches on a matmul with the same MAC count. It is the single reason the Whisper-tiny encoder loses to XNNPACK on Adreno.
Re-expressing the two nn.Conv1d layers of the Whisper-tiny encoder as nn.Conv2d with a singleton height dim, which routes them to the existing conv2d_im2col + conv2d_gemm path, is 25x faster on the conv frontend and turns the whole encoder from 0.79x XNNPACK into 1.40x.
Measurements
Samsung Galaxy S26 Ultra, Snapdragon SM8850 (Adreno 840), ExecuTorch at c27baa8, fp32.
Whisper-tiny encoder end to end, interleaved A/B with the order reversed each round, warm-up iteration discarded, 60 timed iterations per arm:
| arm |
median |
vs XNNPACK |
| Vulkan |
163.3 ms |
0.79x |
| XNNPACK |
129.7 ms |
1.00x |
| Vulkan, conv1d re-expressed as conv2d with H=1 |
92.4 ms |
1.40x |
Per-dispatch GPU timestamps from the shader query pool, one encoder execution (153.4 ms of GPU time against 154 ms of wall clock, so this is entirely GPU bound):
| kernel |
dispatches |
ms |
share |
| conv1d_float |
2 |
65.7 |
42.8% |
| matmul_vec_texture3d_float |
8 |
22.0 |
14.3% |
| linear_vec_bias_texture3d_texture2d_float |
20 |
21.6 |
14.1% |
| softmax_texture3d_float |
4 |
16.8 |
11.0% |
| view_texture_float |
37 |
10.4 |
6.8% |
| clone_float |
32 |
9.0 |
5.9% |
Two dispatches, 43 percent of the runtime. Converting those to useful arithmetic:
| dispatch |
shape |
useful throughput |
conv1d_float gwg [3000, 384, 1] |
80 -> 384, k=3, L=3000 |
31.3 GFLOP/s |
conv1d_float gwg [1500, 384, 1] |
384 -> 384, k=3, s=2, L=1500 |
27.6 GFLOP/s |
linear_vec_bias gwg [384, 375, 1] |
M=1500, N=1536, K=384 |
921 GFLOP/s |
linear_vec_bias gwg [96, 375, 1] |
M=1500, N=384, K=1536 |
900 GFLOP/s |
Same GPU, same run, seconds apart, and the two linear dispatches have an identical MAC count to each other. conv1d is 30x off what the machine demonstrably does.
Why
Two things in the conv1d path:
-
conv1d.glsl computes one output element per invocation with no tiling, no shared memory and no register blocking. Every tap re-fetches both a weight texel and an input texel, so nothing is reused across invocations. The 384 -> 384 layer issues 1152 iterations x 2 texture fetches per invocation across 576k invocations, about 1.3 G texture fetches for 664 M MACs.
-
conv1d_gwg sets z = div_up_4(batch), so the texel's four lanes span the batch dimension. At batch 1, which is every audio and sequence model I am aware of, three of every four lanes are dead. The nchw_to_image dispatch for a [1, 80, 3000] input is [3000, 80, 1], a 4x over-allocated texture, against [3000, 1, 20] for the equivalent [1, 80, 1, 3000].
The conv2d path already solves this
should_use_conv2d_im2col() routes SlidingWindow conv2d to conv2d_im2col.glsl + conv2d_gemm.glsl when groups == 1 && dilation == 1 && (device_is_mali() || c_out >= 128). conv1d has no equivalent and always dispatches the naive shader.
Isolated test, just the Whisper conv frontend (two convs plus gelu), same weights in both, both verified against the CPU reference at cosine 1.00000000 and max abs diff 4.9e-04:
| form |
GPU time |
dispatches |
Conv1d |
66.5 ms |
conv1d 17.7 + conv1d 47.7 |
Conv2d, H=1 |
2.64 ms |
im2col 0.06 + gemm 0.64 + im2col 0.19 + gemm 1.47 |
25x on the frontend, 27.6x on the conv dispatches alone.
Full encoder with the conv layers wrapped so they lower as conv2d: one delegate blob, cosine 0.99999684 against the CPU reference, and the 92.4 ms in the first table.
Suggested fix
Rewrite aten.convolution with a 3-D input into unsqueeze -> conv2d -> squeeze before partitioning, so the existing conv2d machinery (im2col selection, layout tagging, weight prepacking) applies unchanged. I will send a PR.
Note that Whisper on Adreno additionally needs #22327 to be correct at all; this issue is only about speed.
Versions
ExecuTorch at c27baa8. Device: Samsung Galaxy S26 Ultra, Snapdragon SM8850, Adreno 840, Android 16.
馃悰 Describe the bug
conv1d.glslruns at roughly 3 percent of the throughput the same GPU reaches on a matmul with the same MAC count. It is the single reason the Whisper-tiny encoder loses to XNNPACK on Adreno.Re-expressing the two
nn.Conv1dlayers of the Whisper-tiny encoder asnn.Conv2dwith a singleton height dim, which routes them to the existingconv2d_im2col+conv2d_gemmpath, is 25x faster on the conv frontend and turns the whole encoder from 0.79x XNNPACK into 1.40x.Measurements
Samsung Galaxy S26 Ultra, Snapdragon SM8850 (Adreno 840), ExecuTorch at c27baa8, fp32.
Whisper-tiny encoder end to end, interleaved A/B with the order reversed each round, warm-up iteration discarded, 60 timed iterations per arm:
Per-dispatch GPU timestamps from the shader query pool, one encoder execution (153.4 ms of GPU time against 154 ms of wall clock, so this is entirely GPU bound):
Two dispatches, 43 percent of the runtime. Converting those to useful arithmetic:
conv1d_floatgwg [3000, 384, 1]conv1d_floatgwg [1500, 384, 1]linear_vec_biasgwg [384, 375, 1]linear_vec_biasgwg [96, 375, 1]Same GPU, same run, seconds apart, and the two linear dispatches have an identical MAC count to each other. conv1d is 30x off what the machine demonstrably does.
Why
Two things in the conv1d path:
conv1d.glslcomputes one output element per invocation with no tiling, no shared memory and no register blocking. Every tap re-fetches both a weight texel and an input texel, so nothing is reused across invocations. The 384 -> 384 layer issues 1152 iterations x 2 texture fetches per invocation across 576k invocations, about 1.3 G texture fetches for 664 M MACs.conv1d_gwgsetsz = div_up_4(batch), so the texel's four lanes span the batch dimension. At batch 1, which is every audio and sequence model I am aware of, three of every four lanes are dead. Thenchw_to_imagedispatch for a[1, 80, 3000]input is[3000, 80, 1], a 4x over-allocated texture, against[3000, 1, 20]for the equivalent[1, 80, 1, 3000].The conv2d path already solves this
should_use_conv2d_im2col()routes SlidingWindow conv2d toconv2d_im2col.glsl+conv2d_gemm.glslwhengroups == 1 && dilation == 1 && (device_is_mali() || c_out >= 128). conv1d has no equivalent and always dispatches the naive shader.Isolated test, just the Whisper conv frontend (two convs plus gelu), same weights in both, both verified against the CPU reference at cosine 1.00000000 and max abs diff 4.9e-04:
Conv1dConv2d, H=125x on the frontend, 27.6x on the conv dispatches alone.
Full encoder with the conv layers wrapped so they lower as conv2d: one delegate blob, cosine 0.99999684 against the CPU reference, and the 92.4 ms in the first table.
Suggested fix
Rewrite
aten.convolutionwith a 3-D input intounsqueeze -> conv2d -> squeezebefore partitioning, so the existing conv2d machinery (im2col selection, layout tagging, weight prepacking) applies unchanged. I will send a PR.Note that Whisper on Adreno additionally needs #22327 to be correct at all; this issue is only about speed.
Versions
ExecuTorch at c27baa8. Device: Samsung Galaxy S26 Ultra, Snapdragon SM8850, Adreno 840, Android 16.