Skip to content

Commit 37d354d

Browse files
committed
[ET-VK][conv2d] Cap im2col scratch memory via output-height tiling
Pull Request resolved: #20653 The im2col + GEMM conv2d path materializes an im2col scratch tensor of size M * K_total * elem (M = N * H_out * W_out, K_total = K_h * K_w * align_up_4(C_in)) as a single shared tensor, allocated during graph build and resident for the model's lifetime. For full-resolution convolutions this scratch is very large -- a 64-channel 3x3 conv at 256x256 in FP32 materializes ~144 MB, and at 512x512 in FP16 ~288 MB. On memory-constrained mobile GPUs, where GPU allocations come from unified, non-reclaimable system memory, this can nearly double peak process memory and trigger the OS low-memory killer. This change tiles the im2col + GEMM over output-height rows to a fixed scratch budget (kIm2colScratchBudgetBytes, 16 MB). A single scratch tensor sized to oh_tile output rows is reused across tiles, with an oh_offset selecting the live row window per tile. The GEMM inner loop is byte-identical, so the GEMM-based speedup is preserved; scratch becomes O(budget) instead of O(M * K_total), making it resolution-independent. Tiling along output-height (rather than flattened M) keeps the row -> (oh, ow) decode exact for all three storage variants (buffer, texture2d, texture3d). The fixed per-build tile count is safe because tensors are built at the dynamic upper bound, so runtime shapes only shrink and trailing tiles no-op via the shader's oh < H_out guard. oh_tile reaches the resize callbacks as a raw int packed into the resize_args slot (read via static_cast, not get_int) to avoid materializing a graph Value for a build-time constant. The direct-conv fallback for small shapes is unchanged. ghstack-source-id: 398747242 @exported-using-ghexport Differential Revision: [D110231992](https://our.internmc.facebook.com/intern/diff/D110231992/)
1 parent dcf1156 commit 37d354d

8 files changed

Lines changed: 985 additions & 207 deletions

File tree

backends/vulkan/runtime/graph/ops/glsl/conv2d_gemm.glsl

Lines changed: 66 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,30 @@
99
/*
1010
* conv2d_gemm: GEMM step of im2col-backed conv2d.
1111
*
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].
12+
* Reads one tile of the im2col'd input produced by conv2d_im2col.glsl — a 2D
13+
* matrix of shape [M_TILE, K_total] holding OH_TILE output-height rows
14+
* (M_TILE = OH_TILE * W_out, K_total = Kh*Kw*Cin_padded) starting at output
15+
* row OH_OFFSET — and writes the corresponding output rows as texture3D
16+
* channels-packed, logical shape [1, C_out, H_out, W_out]. The full im2col
17+
* matrix is processed OH_TILE rows per dispatch so the scratch tensor is bounded
18+
* to a fixed byte budget regardless of resolution; with tiling disabled the
19+
* caller passes OH_OFFSET = 0 and OH_TILE = H_out (one dispatch covers all M).
1620
*
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.
21+
* The im2col input tile can be any of:
22+
* - texture2d, width-packed: texel at (k4, r) holds 4 K values for tile-local
23+
* row r. IN_STORAGE=texture2d codegen.
24+
* - texture3d, channels-packed: texel at (ow, oh_local, k4) holds 4 K values
25+
* for tile-local row r = oh_local * W_out + ow. Used when the per-tile 2D
26+
* extent (M_TILE = OH_TILE * W_out) would exceed max_texture2d_dim — rare,
27+
* since OH_TILE is capped by the scratch byte budget. IN_STORAGE=texture3d
28+
* codegen.
29+
* - buffer: vec4 at offset r*K4 + k4, same K packing.
2430
* IN_STORAGE=buffer codegen.
2531
*
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.
32+
* The matmul interpretation (over this tile's rows) is:
33+
* out[r, n] = sum_k im2col[r, k] * weight[n, k] + bias[n]
34+
* with K = K_total, N = C_out, and r the tile-local row whose global output
35+
* spatial position is (OH_OFFSET + r / W_out, r % W_out).
2936
*/
3037

3138
#version 450 core
@@ -85,14 +92,23 @@ ${layout_declare_ubo(B, "ivec4", "out_sizes")}
8592
// dims), so it is safe to bake at build time even under dynamic shapes.
8693
// M = H_out * W_out IS shape-dependent, so it is derived at runtime from the
8794
// refreshed out_sizes UBO in main() rather than read from here.
95+
//
96+
// This dispatch consumes one tile of the im2col matrix: OH_TILE output-height
97+
// rows starting at output-height row OH_OFFSET. The im2col scratch (t_in) holds
98+
// OH_TILE * W_out tile-local rows; the GEMM reads tile-local rows and writes the
99+
// output at the corresponding global spatial position (OH_OFFSET + oh_local,
100+
// ow). OH_OFFSET / OH_TILE are shape-independent (fixed at build time); the
101+
// global W_out / H_out come from the refreshed out_sizes UBO.
88102
layout(push_constant) uniform restrict Block {
89-
ivec4 gemm_dims; // (K4_total, _unused, _unused, _unused)
103+
ivec4 gemm_dims; // (K4_total, OH_OFFSET, OH_TILE, _unused)
90104
vec4 clamp_vals; // (out_min, out_max, _unused, _unused)
91105
};
92106

93-
#define K4_TOTAL gemm_dims.x
94-
#define OUT_MIN clamp_vals.x
95-
#define OUT_MAX clamp_vals.y
107+
#define K4_TOTAL gemm_dims.x
108+
#define OH_OFFSET gemm_dims.y
109+
#define OH_TILE gemm_dims.z
110+
#define OUT_MIN clamp_vals.x
111+
#define OUT_MAX clamp_vals.y
96112

97113
layout(local_size_x_id = 0, local_size_y_id = 1, local_size_z_id = 2) in;
98114

@@ -104,30 +120,32 @@ ${layout_declare_spec_const(C, "int", "activation_type", "0")}
104120

105121
/*
106122
* 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.
123+
* The im2col scratch holds M_TILE tile-local rows in a contiguous
124+
* (M_TILE, K_total/4) matrix of vec4s; row here is the tile-local index, so the
125+
* load is a plain 2D fetch — no spatial decomposition. (The output store, not
126+
* this load, maps the tile-local row to its global spatial position.)
109127
*/
110128
void load_input_tile_with_checks(
111129
out FPInputTile tile,
112130
const int k4_start,
113131
const int m_start,
114132
const int K4,
115-
const int M,
133+
const int M_TILE,
116134
const int W_out) {
117135
// W_out is only consumed by the texture3d variant below.
118136
[[unroll]] for (int m = 0; m < TILE_M; ++m) {
119137
[[unroll]] for (int k4 = 0; k4 < TILE_K4; ++k4) {
120-
if (k4_start + k4 < K4 && m_start + m < M) {
138+
if (k4_start + k4 < K4 && m_start + m < M_TILE) {
121139
const int row = m_start + m;
122140
const int col = k4_start + k4;
123141
#if defined(INPUT_BUFFER)
124142
// Cast SSBO texel into the input tile type (f16vec4 for half, vec4 for
125143
// float).
126144
tile.data[m][k4] = LINEAR_FP_INPUT_TILE_VEC4_T(t_in[row * K4 + col]);
127145
#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.
146+
// texture3d scratch [1, K_total, OH_TILE, W_out]: the tile-local row
147+
// decomposes into (ow, oh_local) and K4 is along the Z axis. texelFetch
148+
// returns vec4 (fp32); cast to the input tile type.
131149
tile.data[m][k4] = LINEAR_FP_INPUT_TILE_VEC4_T(
132150
texelFetch(t_in, ivec3(row % W_out, row / W_out, col), 0));
133151
#else
@@ -141,17 +159,28 @@ void load_input_tile_with_checks(
141159
}
142160
}
143161

162+
// m_start is a tile-local row offset; the scratch read uses it directly, but
163+
// the output store maps it to the GLOBAL spatial position via oh_global =
164+
// OH_OFFSET + (m_local / W_out). Rows whose global oh lands past H_out (the
165+
// partial trailing tile, or a dynamic shape that shrinks H_out) are skipped by
166+
// the `oh < H_out` guard. The companion `m_local < M_TILE` guard enforces the
167+
// tile's UPPER oh bound: since M_TILE = OH_TILE * W_out, m_local < M_TILE means
168+
// oh_local < OH_TILE, i.e. oh < OH_OFFSET + OH_TILE — so no row this dispatch
169+
// writes can leak into a neighboring tile's output-row range.
144170
void store_output_tile_with_checks(
145171
const FPOutTile out_tile,
146172
const int n4_start,
147173
const int m_start,
148174
const int N4,
149-
const int M,
175+
const int M_TILE,
176+
const int H_out,
150177
const int W_out) {
151178
[[unroll]] for (int m = 0; m < TILE_M; ++m) {
152179
[[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;
180+
const int m_local = m_start + m;
181+
const int ow = m_local % W_out;
182+
const int oh = OH_OFFSET + m_local / W_out;
183+
if (m_local < M_TILE && oh < H_out && n4_start + n4 < N4) {
155184
// Cast the accumulator (f16vec4 for the buffer/half path) to the
156185
// texture3d output surface type for the activation clamp and store.
157186
OUT_VEC4_T texel = OUT_VEC4_T(out_tile.data[m][n4]);
@@ -160,8 +189,7 @@ void store_output_tile_with_checks(
160189
} else if (activation_type == 2) {
161190
texel = clamp(texel, OUT_VEC4_T(OUT_MIN), OUT_VEC4_T(OUT_MAX));
162191
}
163-
imageStore(
164-
t_out, ivec3(spatial % W_out, spatial / W_out, n4_start + n4), texel);
192+
imageStore(t_out, ivec3(ow, oh, n4_start + n4), texel);
165193
}
166194
}
167195
}
@@ -176,14 +204,16 @@ void main() {
176204

177205
const int W_out = out_sizes.x;
178206
const int H_out = out_sizes.y;
179-
// M = H_out * W_out is derived from the refreshed out_sizes UBO so it tracks
207+
// W_out / H_out are derived from the refreshed out_sizes UBO so they track
180208
// dynamic output shapes (out_sizes is virtual_resize'd on trigger_resize).
181-
const int M = W_out * H_out;
209+
// M_TILE = OH_TILE * W_out is the tile-local row count materialized in the
210+
// im2col scratch (t_in); the GEMM reads scratch rows in [0, M_TILE).
211+
const int M_TILE = OH_TILE * W_out;
182212
const int K4 = K4_TOTAL;
183213
const int N = out_sizes.z;
184214
const int N4 = div_up_4(N);
185215

186-
if (n4_start >= N4 || m_start >= M) {
216+
if (n4_start >= N4 || m_start >= M_TILE) {
187217
return;
188218
}
189219

@@ -194,7 +224,7 @@ void main() {
194224
FPWeightTile w_tile;
195225

196226
for (int k4 = 0; k4 < K4; k4 += TILE_K4) {
197-
load_input_tile_with_checks(in_tile, k4, m_start, K4, M, W_out);
227+
load_input_tile_with_checks(in_tile, k4, m_start, K4, M_TILE, W_out);
198228
load_packed_weight_tile_with_checks(w_tile, n4_start, k4, 0, N4, K4);
199229
fp_accumulate_with_fp_weight(out_tile, in_tile, w_tile);
200230
}
@@ -213,5 +243,6 @@ void main() {
213243
}
214244
}
215245

216-
store_output_tile_with_checks(out_tile, n4_start, m_start, N4, M, W_out);
246+
store_output_tile_with_checks(
247+
out_tile, n4_start, m_start, N4, M_TILE, H_out, W_out);
217248
}

backends/vulkan/runtime/graph/ops/glsl/conv2d_im2col.glsl

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,37 @@
99
/*
1010
* Im2col transformation for FP32 / FP16 conv2d.
1111
*
12-
* The output is a 2D matrix of shape [M, K_total] where
12+
* One dispatch materializes a tile of OH_TILE output-height rows (full W_out
13+
* each) of the im2col matrix, starting at output-height row OH_OFFSET. The full
14+
* matrix has logical shape [M, K_total] where
1315
* M = H_out * W_out (number of output spatial positions)
1416
* K_total = Kh * Kw * align_up_4(C_in) (flattened receptive field)
1517
*
18+
* Tiling by output-height rows bounds the scratch tensor to a fixed byte budget
19+
* regardless of resolution: the scratch holds OH_TILE * W_out rows, not M. A
20+
* tile-local row m_local decodes to oh_local = m_local / W_out,
21+
* ow = m_local % W_out; the SOURCE spatial position uses the global output row
22+
* oh = OH_OFFSET + oh_local. Tiling by H rows (rather than flat M rows) keeps
23+
* this row->(oh,ow) decode exact for the spatial texture3d layout too. When
24+
* tiling is disabled the caller passes OH_OFFSET = 0 and OH_TILE = H_out.
25+
*
1626
* K layout (so a 4-tile in K — one vec4 — holds the same kernel position):
1727
* K = (ki * Kw + kj) * Cin_padded + ci
1828
*
1929
* 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.
30+
* - texture2d, width-packed: texel at (k4, m_local) holds 4 K values for
31+
* tile-local row m_local. Extents = (K_total/4, OH_TILE * W_out).
32+
* - texture3d, channels-packed: texel at (ow, oh_local, k4) holds 4 K values
33+
* for output spatial position (OH_OFFSET + oh_local, ow). Extents =
34+
* (W_out, OH_TILE, K4). Used as a fallback when the per-tile 2D extent
35+
* (OH_TILE * W_out, K4) would exceed max_texture2d_dim.
36+
* - buffer: vec4 at offset (m_local * K4 + k4), same K packing.
2637
*
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).
38+
* The caller selects storage on the TILED scratch extent, not the full M.
39+
* Per device: Mali → buffer; others → texture2d when the per-tile 2D extent
40+
* (OH_TILE * W_out, K4) fits, texture3d when its 3D extents fit, else buffer.
41+
* Because OH_TILE is capped by the scratch byte budget, the tiled extent rarely
42+
* exceeds max_texture2d_dim, so texture2d is the common case.
2943
*/
3044

3145
#version 450 core
@@ -62,7 +76,7 @@ ${layout_declare_ubo(B, "ivec4", "in_sizes")}
6276
layout(push_constant) uniform restrict Block {
6377
ivec4 kernel_stride; // (Kh, Kw, Sh, Sw)
6478
ivec4 padding_dil; // (Ph, Pw, Dh, Dw)
65-
ivec4 dims; // (Cin_padded, _unused, _unused, K4_total)
79+
ivec4 dims; // (Cin_padded, OH_OFFSET, OH_TILE, K4_total)
6680
};
6781

6882
#define KERNEL_H kernel_stride.x
@@ -74,13 +88,17 @@ layout(push_constant) uniform restrict Block {
7488
#define DILATION_H padding_dil.z
7589
#define DILATION_W padding_dil.w
7690
#define CIN_PADDED dims.x
91+
#define OH_OFFSET dims.y
92+
#define OH_TILE dims.z
7793
#define K4_TOTAL dims.w
7894

7995
layout(local_size_x_id = 0, local_size_y_id = 1, local_size_z_id = 2) in;
8096

8197
void main() {
8298
const int k4 = int(gl_GlobalInvocationID.x);
83-
const int m = int(gl_GlobalInvocationID.y);
99+
// gl_GlobalInvocationID.y is the tile-local row m_local within this tile's
100+
// OH_TILE * W_out rows; it maps to the global output row via OH_OFFSET.
101+
const int m_local = int(gl_GlobalInvocationID.y);
84102

85103
// Derive the spatial output extents from the (refreshed-on-resize) input
86104
// sizes UBO so the im2col mapping tracks dynamic input shapes. in_sizes is
@@ -92,12 +110,20 @@ void main() {
92110
const int H_OUT =
93111
(in_sizes.y + 2 * PADDING_H - DILATION_H * (KERNEL_H - 1) - 1) / STRIDE_H +
94112
1;
95-
const int M = H_OUT * W_OUT;
113+
// Rows materialized by this tile (capped to the scratch extent).
114+
const int M_TILE = OH_TILE * W_OUT;
96115

97-
if (k4 >= K4_TOTAL || m >= M) {
116+
if (k4 >= K4_TOTAL || m_local >= M_TILE) {
98117
return;
99118
}
100119

120+
// Tile-local row m_local -> (oh_local, ow); global output row oh = OH_OFFSET +
121+
// oh_local. Rows past the real H_OUT (in a partial trailing tile, or when a
122+
// dynamic shape shrinks H_OUT below the build-time OH_OFFSET) write zeros.
123+
const int oh_local = m_local / W_OUT;
124+
const int ow = m_local % W_OUT;
125+
const int oh = OH_OFFSET + oh_local;
126+
101127
const int k_start = k4 * 4;
102128

103129
// K = (ki * Kw + kj) * Cin_padded + ci ; since Cin_padded % 4 == 0, all 4
@@ -109,24 +135,20 @@ void main() {
109135
const int ki = krow_idx / KERNEL_W;
110136
const int ci_blk = ci_start >> 2; // ci_start / 4
111137

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-
116138
// Compute the input spatial position for this (oh, ow, ki, kj).
117139
const int ih = oh * STRIDE_H - PADDING_H + ki * DILATION_H;
118140
const int iw = ow * STRIDE_W - PADDING_W + kj * DILATION_W;
119141

120142
VEC4_T out_texel = VEC4_T(0);
121-
if (ih >= 0 && ih < in_sizes.y && iw >= 0 && iw < in_sizes.x) {
143+
if (oh < H_OUT && ih >= 0 && ih < in_sizes.y && iw >= 0 && iw < in_sizes.x) {
122144
out_texel = texelFetch(t_in, ivec3(iw, ih, ci_blk), 0);
123145
}
124146

125147
#if defined(OUTPUT_BUFFER)
126-
t_out[m * K4_TOTAL + k4] = VEC4_BUF_T(out_texel);
148+
t_out[m_local * K4_TOTAL + k4] = VEC4_BUF_T(out_texel);
127149
#elif defined(OUTPUT_TEXTURE3D)
128-
imageStore(t_out, ivec3(ow, oh, k4), out_texel);
150+
imageStore(t_out, ivec3(ow, oh_local, k4), out_texel);
129151
#else
130-
imageStore(t_out, ivec2(k4, m), out_texel);
152+
imageStore(t_out, ivec2(k4, m_local), out_texel);
131153
#endif
132154
}

0 commit comments

Comments
 (0)