Skip to content

Commit fd2cf88

Browse files
ssjiaSS-JIA
authored andcommitted
[ETVK] Add benchmark binary + im2col/GEMM conv2d prototype
Pull Request resolved: #20058 This change does two related things on top of the existing direct conv2d path: it adds a new benchmark binary for general conv2d, and it adds an im2col-backed conv2d implementation that the benchmark exercises alongside the existing direct shader. **Why a benchmark binary** Profiling a sample CNN showed that the standard `conv2d_float` (general sliding window) shader accounts for ~93% of all conv time, with six 3x3 stride=1 same-channels shapes dominating. The existing custom-ops directory had benchmark binaries for pointwise and depthwise conv but no standalone way to iterate on the general kernel. The new `test_conv2d` binary fills that gap. `test_conv2d.cpp` includes 7 small accuracy configs (validated against a CPU float reference) and 13 performance configs covering the sample CNN's hotspots: the six dominant `C_in == C_out` 3x3 stride=1 shapes, several stride=2 downsample variants, two channel-reduction cases, and the 3-channel RGB stem. Perf configs are run in FP32 and FP16; accuracy configs are FP32-only because the reference is float. The binary uses 5 warmup + 20 timed iterations per case so the GPU governor reaches a stable clock before measurement. On a Pixel device, the reported per-call latencies for the direct path match the in-model profile within 0.84x-0.99x for all six dominant shapes, confirming the binary is a faithful proxy for in-model conv latency. **Why an im2col-backed conv2d** The im2col approach materializes the conv input into a `[1, K_total, H_out, W_out]` (or `[M, K_total]`) intermediate and runs the conv as a single tiled GEMM. The im2col K-axis layout `K = (ki * Kw + kj) * Cin_padded + ci` is chosen so that every 4-tile of K holds 4 consecutive `ci` values for the same `(ki, kj)` — that way each im2col output texel reads exactly one input texel and the GEMM can use a clean 1x1-style load pattern. On the sample CNN's hotspots this gives 1.20x-1.43x FP32 and 1.50x-1.80x FP16 speedups vs. the direct shader (estimated ~21% reduction in total FP32 conv time, ~36% in FP16) on Pixel 9 Pro XL. The implementation is split into three pieces so we can iterate on the GEMM step in isolation: - `conv2d_im2col.glsl` + `impl/Conv2dIm2Col.{h,cpp}`: the im2col dispatch only. - `conv2d_gemm.glsl` + the orchestration in `impl/Conv2dGemm.{h,cpp}`: a private GEMM shader for the im2col-backed case, separate from the production pointwise path so we can experiment with more aggressive optimizations (larger tiles, cooperative matrix, register blocking) without affecting `conv2d_pw_tiled`. - `pack_conv2d_gemm_weight.glsl` + the `prepack_conv2d_gemm_weight` helper in `impl/Conv2dGemm.{h,cpp}`: a GPU prepack shader, dispatched once at graph-build time via a `PrepackNode`, that reads the original serialized `[C_out, C_in, Kh, Kw]` weight directly and writes the matching `[C_out, K_total]` packed layout the GEMM consumes (no host-side repack of the serialized weight data). **Device-specific storage selection** Both shader templates codegen three variants of the im2col intermediate — `buffer`, `texture2d` width-packed `[K4_total, M]`, and `texture3d` channels-packed `[W_out, H_out, K4_total]` — and `conv2d_gemm_impl` picks at graph build time based on `graph.device_is_mali()` and the relevant max texture extents. Mali → buffer always (its texture sampling is comparatively slow vs SSBO reads). Adreno and others prefer `texture2d`, but for shapes where M would exceed `max_texture2d_dim` (e.g. `[1, 32, 144, 192]` with M = 27,648) the dispatch falls back to `texture3d`, then to `buffer` as a last resort. On Adreno (Samsung S921), the device-specific routing pushes wins to 0.47x-0.79x FP32 and 0.65x-0.96x FP16 on the dominant shapes. On Mali (Pixel 9 Pro XL), buffer routing pushes wins to 0.51x-0.78x FP32 and 0.34x-0.46x FP16. **Dynamic-shape support** The im2col/GEMM path handles dynamic input shapes. `resize_conv2d_gemm_node` recomputes the conv output H/W from the current input on resize and virtual-resizes the output; the im2col dispatch is a `DynamicDispatchNode` whose workgroup picker and `resize_conv2d_im2col_node` recompute M and resize the im2col `TmpTensor` (both the flat `[M, K_total]` layout and the texture3d `[1, K_total, H_out, W_out]` layout). The shaders derive `W_out`/`H_out`/`M` at runtime from the refreshed tensor-metadata UBOs (`out_sizes` for the GEMM, `in_sizes` for im2col) rather than baked push constants, so one built graph serves every shape up to the dynamic upper bound; shape-independent quantities (`K_total`, `K4_total`, `Cin_padded`) stay baked. Storage selection (Mali→buffer, texture2d vs texture3d) is fixed at build time from the upper-bound (largest) shape, so resize-to-smaller always fits the original allocation. **Test integration** `test_etvk.test_conv2d.default` switches between `aten.convolution.default` and `et_vk.conv2d_gemm.default` based on the `impl_selector` string ("im2col" picks the new path), so the same benchmark binary exercises both implementations back-to-back per shape. ghstack-source-id: 391667417 @exported-using-ghexport Differential Revision: [D105120966](https://our.internmc.facebook.com/intern/diff/D105120966/)
1 parent f0dff03 commit fd2cf88

13 files changed

Lines changed: 2064 additions & 0 deletions
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/*
10+
* conv2d_gemm: GEMM step of im2col-backed conv2d.
11+
*
12+
* Reads the im2col'd input produced by conv2d_im2col.glsl as a 2D matrix
13+
* of shape [M, K_total] (M = H_out * W_out, K_total = Kh*Kw*Cin_padded)
14+
* and writes the conv2d output as texture3D channels-packed
15+
* logical shape [1, C_out, H_out, W_out].
16+
*
17+
* The im2col input can be any of:
18+
* - texture2d, width-packed: texel at (k4, m) holds 4 K values for row m.
19+
* IN_STORAGE=texture2d codegen.
20+
* - texture3d, channels-packed: texel at (ow, oh, k4) holds 4 K values
21+
* for output spatial position (oh, ow). Used when M would exceed
22+
* max_texture2d_dim. IN_STORAGE=texture3d codegen.
23+
* - buffer: vec4 at offset m*K4 + k4, same K packing.
24+
* IN_STORAGE=buffer codegen.
25+
*
26+
* The matmul interpretation is:
27+
* out[m, n] = sum_k im2col[m, k] * weight[n, k] + bias[n]
28+
* with M = H_out * W_out, K = K_total, N = C_out.
29+
*/
30+
31+
#version 450 core
32+
33+
#define PRECISION ${PRECISION}
34+
35+
$if IN_STORAGE == "buffer" and DTYPE == "half":
36+
${define_explicit_type_extensions(DTYPE)}
37+
38+
// VEC4_T is the input storage's natural texel type, which is also the tile type
39+
// (the linear_fp_*_tile headers default the tile vec4 type to VEC4_T). For the
40+
// buffer/half path this resolves to f16vec4, so the GEMM inner loop accumulates
41+
// in true FP16 — the fma emits mad.f16 and the accumulators live in half-width
42+
// registers. Texture-sampled half always returns vec4, so FP16 accumulation is
43+
// naturally confined to the buffer (Mali) path; the texture variants (Adreno),
44+
// where FP16 accumulation regresses, stay vec4 / FP32 with no extra gating.
45+
#define VEC4_T ${texel_load_type(DTYPE, IN_STORAGE)}
46+
47+
// OUT_VEC4_T is the output surface type. t_out is always texture3d, whose
48+
// imageStore ABI takes vec4 (fp32) regardless of DTYPE, so the accumulator tile
49+
// is cast from VEC4_T to OUT_VEC4_T at store time.
50+
#define OUT_VEC4_T ${texel_load_type(DTYPE, "texture3d")}
51+
52+
#define TILE_M4 ${TILE_M4}
53+
#define TILE_K4 ${TILE_K4}
54+
#define TILE_N4 ${TILE_N4}
55+
56+
#define TILE_M ${TILE_M}
57+
#define TILE_K ${TILE_K4 * 4}
58+
#define TILE_N ${TILE_N4 * 4}
59+
60+
$if IN_STORAGE == "buffer":
61+
#define INPUT_BUFFER
62+
$elif IN_STORAGE == "texture3d":
63+
#define INPUT_TEXTURE3D
64+
65+
${define_required_extensions("texture3d", DTYPE)}
66+
$if IN_STORAGE == "buffer":
67+
${define_required_extensions("buffer", DTYPE)}
68+
69+
layout(std430) buffer;
70+
71+
#include "common.glslh"
72+
73+
${layout_declare_tensor(B, "w", "t_out", DTYPE, "texture3d")}
74+
$if IN_STORAGE == "buffer":
75+
${layout_declare_tensor(B, "r", "t_in", DTYPE, "buffer", is_scalar_array=False)}
76+
$else:
77+
${layout_declare_tensor(B, "r", "t_in", DTYPE, IN_STORAGE)}
78+
${layout_declare_tensor(B, "r", "t_weight_packed", DTYPE, "texture2d")}
79+
${layout_declare_tensor(B, "r", "t_bias", DTYPE, "texture2d")}
80+
81+
${layout_declare_ubo(B, "ivec4", "out_sizes")}
82+
83+
// Push constants are uploaded in 16-byte chunks (one ivec4 each).
84+
// K4_total is shape-independent (it depends only on C_in and the conv kernel
85+
// dims), so it is safe to bake at build time even under dynamic shapes.
86+
// M = H_out * W_out IS shape-dependent, so it is derived at runtime from the
87+
// refreshed out_sizes UBO in main() rather than read from here.
88+
layout(push_constant) uniform restrict Block {
89+
ivec4 gemm_dims; // (K4_total, _unused, _unused, _unused)
90+
vec4 clamp_vals; // (out_min, out_max, _unused, _unused)
91+
};
92+
93+
#define K4_TOTAL gemm_dims.x
94+
#define OUT_MIN clamp_vals.x
95+
#define OUT_MAX clamp_vals.y
96+
97+
layout(local_size_x_id = 0, local_size_y_id = 1, local_size_z_id = 2) in;
98+
99+
${layout_declare_spec_const(C, "int", "activation_type", "0")}
100+
101+
#include "linear_fp_input_tile.glslh"
102+
#include "linear_fp_packed_weight_tile_load.glslh"
103+
#include "linear_fp_output_tile_fp_compute.glslh"
104+
105+
/*
106+
* Load TILE_M rows × TILE_K4 K-tiles of the im2col'd input.
107+
* The im2col output is a contiguous (M, K_total/4) matrix of vec4s, so the
108+
* load is a plain 2D fetch — no spatial decomposition.
109+
*/
110+
void load_input_tile_with_checks(
111+
out FPInputTile tile,
112+
const int k4_start,
113+
const int m_start,
114+
const int K4,
115+
const int M,
116+
const int W_out) {
117+
// W_out is only consumed by the texture3d variant below.
118+
[[unroll]] for (int m = 0; m < TILE_M; ++m) {
119+
[[unroll]] for (int k4 = 0; k4 < TILE_K4; ++k4) {
120+
if (k4_start + k4 < K4 && m_start + m < M) {
121+
const int row = m_start + m;
122+
const int col = k4_start + k4;
123+
#if defined(INPUT_BUFFER)
124+
// Cast SSBO texel into the input tile type (f16vec4 for half, vec4 for
125+
// float).
126+
tile.data[m][k4] = LINEAR_FP_INPUT_TILE_VEC4_T(t_in[row * K4 + col]);
127+
#elif defined(INPUT_TEXTURE3D)
128+
// texture3d layout: row (the flat M index) decomposes into (ow, oh)
129+
// and K4 is along the Z axis. texelFetch returns vec4 (fp32); cast to
130+
// the input tile type.
131+
tile.data[m][k4] = LINEAR_FP_INPUT_TILE_VEC4_T(
132+
texelFetch(t_in, ivec3(row % W_out, row / W_out, col), 0));
133+
#else
134+
tile.data[m][k4] =
135+
LINEAR_FP_INPUT_TILE_VEC4_T(texelFetch(t_in, ivec2(col, row), 0));
136+
#endif
137+
} else {
138+
tile.data[m][k4] = LINEAR_FP_INPUT_TILE_VEC4_T(0.0);
139+
}
140+
}
141+
}
142+
}
143+
144+
void store_output_tile_with_checks(
145+
const FPOutTile out_tile,
146+
const int n4_start,
147+
const int m_start,
148+
const int N4,
149+
const int M,
150+
const int W_out) {
151+
[[unroll]] for (int m = 0; m < TILE_M; ++m) {
152+
[[unroll]] for (int n4 = 0; n4 < TILE_N4; ++n4) {
153+
if (m_start + m < M && n4_start + n4 < N4) {
154+
const int spatial = m_start + m;
155+
// Cast the accumulator (f16vec4 for the buffer/half path) to the
156+
// texture3d output surface type for the activation clamp and store.
157+
OUT_VEC4_T texel = OUT_VEC4_T(out_tile.data[m][n4]);
158+
if (activation_type == 1) {
159+
texel = max(texel, OUT_VEC4_T(0.0));
160+
} else if (activation_type == 2) {
161+
texel = clamp(texel, OUT_VEC4_T(OUT_MIN), OUT_VEC4_T(OUT_MAX));
162+
}
163+
imageStore(
164+
t_out, ivec3(spatial % W_out, spatial / W_out, n4_start + n4), texel);
165+
}
166+
}
167+
}
168+
}
169+
170+
void main() {
171+
const int tile_idx_n = int(gl_GlobalInvocationID.x);
172+
const int tile_idx_m = int(gl_GlobalInvocationID.y);
173+
174+
const int n4_start = tile_idx_n * TILE_N4;
175+
const int m_start = tile_idx_m * TILE_M;
176+
177+
const int W_out = out_sizes.x;
178+
const int H_out = out_sizes.y;
179+
// M = H_out * W_out is derived from the refreshed out_sizes UBO so it tracks
180+
// dynamic output shapes (out_sizes is virtual_resize'd on trigger_resize).
181+
const int M = W_out * H_out;
182+
const int K4 = K4_TOTAL;
183+
const int N = out_sizes.z;
184+
const int N4 = div_up_4(N);
185+
186+
if (n4_start >= N4 || m_start >= M) {
187+
return;
188+
}
189+
190+
FPOutTile out_tile;
191+
initialize(out_tile);
192+
193+
FPInputTile in_tile;
194+
FPWeightTile w_tile;
195+
196+
for (int k4 = 0; k4 < K4; k4 += TILE_K4) {
197+
load_input_tile_with_checks(in_tile, k4, m_start, K4, M, W_out);
198+
load_packed_weight_tile_with_checks(w_tile, n4_start, k4, 0, N4, K4);
199+
fp_accumulate_with_fp_weight(out_tile, in_tile, w_tile);
200+
}
201+
202+
// Apply bias. The bias texel depends only on n4, so fetch it once per n4 and
203+
// add it to every m row rather than re-fetching inside the M loop.
204+
[[unroll]] for (int n4 = 0; n4 < TILE_N4; ++n4) {
205+
if (n4_start + n4 < N4) {
206+
// t_bias is an fp32 texture2d; cast its texel to the accumulator type.
207+
const LINEAR_FP_OUTPUT_TILE_VEC4_T bias_texel =
208+
LINEAR_FP_OUTPUT_TILE_VEC4_T(
209+
texelFetch(t_bias, ivec2(n4_start + n4, 0), 0));
210+
[[unroll]] for (int m = 0; m < TILE_M; ++m) {
211+
out_tile.data[m][n4] += bias_texel;
212+
}
213+
}
214+
}
215+
216+
store_output_tile_with_checks(out_tile, n4_start, m_start, N4, M, W_out);
217+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
conv2d_gemm:
8+
parameter_names_with_default_values:
9+
DTYPE: float
10+
IN_STORAGE: texture2d
11+
TILE_M4: 1
12+
TILE_K4: 1
13+
TILE_N4: 1
14+
TILE_M: 4
15+
generate_variant_forall:
16+
combination:
17+
parameter_names: [IN_STORAGE, DTYPE]
18+
combos:
19+
- parameter_values: [texture2d, float]
20+
- parameter_values: [texture2d, half]
21+
- parameter_values: [texture3d, float]
22+
- parameter_values: [texture3d, half]
23+
- parameter_values: [buffer, float]
24+
- parameter_values: [buffer, half]
25+
shader_variants:
26+
- NAME: conv2d_gemm
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/*
10+
* Im2col transformation for FP32 / FP16 conv2d.
11+
*
12+
* The output is a 2D matrix of shape [M, K_total] where
13+
* M = H_out * W_out (number of output spatial positions)
14+
* K_total = Kh * Kw * align_up_4(C_in) (flattened receptive field)
15+
*
16+
* K layout (so a 4-tile in K — one vec4 — holds the same kernel position):
17+
* K = (ki * Kw + kj) * Cin_padded + ci
18+
*
19+
* Three codegen'd storage variants of the output tensor:
20+
* - texture2d, width-packed: texel at (k4, m) holds 4 K values for spatial
21+
* position m. Extents = (K_total/4, M).
22+
* - texture3d, channels-packed: texel at (ow, oh, k4) holds 4 K values
23+
* for output spatial position (oh, ow). Extents = (W_out, H_out, K4).
24+
* Used as a fallback when M would exceed max_texture2d_dim.
25+
* - buffer: vec4 at offset (m * K4 + k4), same K packing.
26+
*
27+
* The caller picks storage per device (Mali → buffer; others → texture2d
28+
* when its 2D extents fit, texture3d when its 3D extents fit, else buffer).
29+
*/
30+
31+
#version 450 core
32+
33+
#define PRECISION ${PRECISION}
34+
35+
#define VEC4_T ${texel_load_type(DTYPE, "texture3d")}
36+
37+
$if OUT_STORAGE == "buffer":
38+
#define OUTPUT_BUFFER
39+
#define VEC4_BUF_T ${texel_load_type(DTYPE, "buffer")}
40+
$elif OUT_STORAGE == "texture3d":
41+
#define OUTPUT_TEXTURE3D
42+
43+
${define_required_extensions("texture3d", DTYPE)}
44+
$if OUT_STORAGE == "buffer":
45+
${define_required_extensions("buffer", DTYPE)}
46+
47+
layout(std430) buffer;
48+
49+
$if OUT_STORAGE == "buffer":
50+
${layout_declare_tensor(B, "w", "t_out", DTYPE, "buffer", is_scalar_array=False)}
51+
$else:
52+
${layout_declare_tensor(B, "w", "t_out", DTYPE, OUT_STORAGE)}
53+
${layout_declare_tensor(B, "r", "t_in", DTYPE, "texture3d")}
54+
55+
${layout_declare_ubo(B, "ivec4", "in_sizes")}
56+
57+
// Push constants are uploaded in 16-byte chunks (one ivec4 each) to comply
58+
// with the per-entry size limit. All of these fields are shape-independent
59+
// (they depend only on the conv kernel params and C_in), so they are safe to
60+
// bake at build time even under dynamic shapes — W_out / H_out / M are derived
61+
// at runtime from the refreshed in_sizes UBO below.
62+
layout(push_constant) uniform restrict Block {
63+
ivec4 kernel_stride; // (Kh, Kw, Sh, Sw)
64+
ivec4 padding_dil; // (Ph, Pw, Dh, Dw)
65+
ivec4 dims; // (Cin_padded, _unused, _unused, K4_total)
66+
};
67+
68+
#define KERNEL_H kernel_stride.x
69+
#define KERNEL_W kernel_stride.y
70+
#define STRIDE_H kernel_stride.z
71+
#define STRIDE_W kernel_stride.w
72+
#define PADDING_H padding_dil.x
73+
#define PADDING_W padding_dil.y
74+
#define DILATION_H padding_dil.z
75+
#define DILATION_W padding_dil.w
76+
#define CIN_PADDED dims.x
77+
#define K4_TOTAL dims.w
78+
79+
layout(local_size_x_id = 0, local_size_y_id = 1, local_size_z_id = 2) in;
80+
81+
void main() {
82+
const int k4 = int(gl_GlobalInvocationID.x);
83+
const int m = int(gl_GlobalInvocationID.y);
84+
85+
// Derive the spatial output extents from the (refreshed-on-resize) input
86+
// sizes UBO so the im2col mapping tracks dynamic input shapes. in_sizes is
87+
// (W_in, H_in, C_in, N). dilation == 1 is guaranteed by the C++ routing
88+
// heuristic, but the general formula is used for correctness.
89+
const int W_OUT =
90+
(in_sizes.x + 2 * PADDING_W - DILATION_W * (KERNEL_W - 1) - 1) / STRIDE_W +
91+
1;
92+
const int H_OUT =
93+
(in_sizes.y + 2 * PADDING_H - DILATION_H * (KERNEL_H - 1) - 1) / STRIDE_H +
94+
1;
95+
const int M = H_OUT * W_OUT;
96+
97+
if (k4 >= K4_TOTAL || m >= M) {
98+
return;
99+
}
100+
101+
const int k_start = k4 * 4;
102+
103+
// K = (ki * Kw + kj) * Cin_padded + ci ; since Cin_padded % 4 == 0, all 4
104+
// K values in this texel share the same (ki, kj) and span 4 consecutive
105+
// ci values starting at ci_start.
106+
const int krow_idx = k_start / CIN_PADDED; // ki * Kw + kj
107+
const int ci_start = k_start % CIN_PADDED;
108+
const int kj = krow_idx % KERNEL_W;
109+
const int ki = krow_idx / KERNEL_W;
110+
const int ci_blk = ci_start >> 2; // ci_start / 4
111+
112+
// Decompose flat output position m back into (oh, ow).
113+
const int ow = m % W_OUT;
114+
const int oh = m / W_OUT;
115+
116+
// Compute the input spatial position for this (oh, ow, ki, kj).
117+
const int ih = oh * STRIDE_H - PADDING_H + ki * DILATION_H;
118+
const int iw = ow * STRIDE_W - PADDING_W + kj * DILATION_W;
119+
120+
VEC4_T out_texel = VEC4_T(0);
121+
if (ih >= 0 && ih < in_sizes.y && iw >= 0 && iw < in_sizes.x) {
122+
out_texel = texelFetch(t_in, ivec3(iw, ih, ci_blk), 0);
123+
}
124+
125+
#if defined(OUTPUT_BUFFER)
126+
t_out[m * K4_TOTAL + k4] = VEC4_BUF_T(out_texel);
127+
#elif defined(OUTPUT_TEXTURE3D)
128+
imageStore(t_out, ivec3(ow, oh, k4), out_texel);
129+
#else
130+
imageStore(t_out, ivec2(k4, m), out_texel);
131+
#endif
132+
}

0 commit comments

Comments
 (0)